Files
Pete Miller 3e5b3073de createInterfaceApi - a browser proxy store (#29601)
* createInterfaceApi - a browser proxy store

A way to generate a state store browser proxy from any remote API, designed for mojom (or extension) APIs

The intention is to create something requiring minimal configuration and remove often repeated patterns to do with WebUI <-> Browser communication and
- storing and caching fetch results across UI components
- event listening to data updates and upadting local cache
- passing through actions to UI components
- mocking data for unit tests and storybook

This is not intended to replace all local state management. The intention is to manage only the data that is owned by a remote (e.g. the browser) and present it to the UI. Any local-only state should still be managed by the relevant framework (i.e. React, Lit, or Svelte) or a separate state manager.
However, this does add another relevant utility:

Used to send an instance of an api to a tree of React components. However, since it accepts any function it can also conveniently be used to store global state for that tree, using React hooks (`useState`, `useMemo`) or derived state from values retrieved from the API.
React Context is not that performant, as the whole tree will re-render when any pieces of state change, so this should be used sparingly. Instead, hooks for each individual endpoint should be used via the API instance which is passed down via Context. The API instance itself won't change and cause re-renders, but the individual hooks will cause subscriptions to be made to each piece of data.

A helper to generate endpoints from an interface. Prevents having to re-declare the mojom function signature as Typescript can deduce it from the generated mojom JS.

A helper to mark an interface function as an 'event'. Prevents having to re-declare the mojom function signature as Typescript can deduce it from the generated mojom JS.

See `components/common/api/readme.md` for examples.
2026-02-08 15:54:40 -08:00

128 lines
3.9 KiB
TypeScript

// Copyright (c) 2025 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
import { actionsFor } from './actions_for'
describe('actionsFor', () => {
// Simulates a class instance where methods are on the prototype
// (like Mojo remotes)
class MockRemote {
private internalState = 'initial'
methodA(arg: string) {
this.internalState = arg
return `A: ${arg}`
}
methodB(num: number) {
return `B: ${num}, state: ${this.internalState}`
}
methodC() {
return Promise.resolve('C result')
}
notExposed() {
return 'should not be accessible'
}
}
it('should extract specified methods from a class instance', () => {
const remote = new MockRemote()
const actions = actionsFor(remote, ['methodA', 'methodB'] as const)
expect(actions.methodA).toBeDefined()
expect(actions.methodB).toBeDefined()
expect(typeof actions.methodA).toBe('function')
expect(typeof actions.methodB).toBe('function')
})
it('should not include methods not in the keys array', () => {
const remote = new MockRemote()
const actions = actionsFor(remote, ['methodA'] as const)
expect(actions.methodA).toBeDefined()
// @ts-expect-error - methodB should not be on the result type
expect(actions.methodB).toBeUndefined()
// @ts-expect-error - notExposed should not be on the result type
expect(actions.notExposed).toBeUndefined()
})
it('should bind methods to preserve `this` context', () => {
const remote = new MockRemote()
const actions = actionsFor(remote, ['methodA', 'methodB'] as const)
// Call methodA which modifies internal state
const resultA = actions.methodA('changed')
expect(resultA).toBe('A: changed')
// methodB should see the changed internal state
const resultB = actions.methodB(42)
expect(resultB).toBe('B: 42, state: changed')
})
it('should work with async methods', async () => {
const remote = new MockRemote()
const actions = actionsFor(remote, ['methodC'] as const)
const result = await actions.methodC()
expect(result).toBe('C result')
})
it('should return an object that can be spread', () => {
const remote = new MockRemote()
const actions = actionsFor(remote, ['methodA', 'methodB'] as const)
// Verify the result is a plain object with own enumerable properties
const keys = Object.keys(actions)
expect(keys).toContain('methodA')
expect(keys).toContain('methodB')
expect(keys.length).toBe(2)
// Should be spreadable
const spread = { ...actions }
expect(spread.methodA).toBeDefined()
expect(spread.methodB).toBeDefined()
})
it('should work with an empty keys array', () => {
const remote = new MockRemote()
const actions = actionsFor(remote, [] as const)
expect(Object.keys(actions)).toEqual([])
})
it('should handle objects that are not class instances', () => {
const plainObject = {
doSomething: (x: number) => x * 2,
doSomethingElse: () => 'done',
}
const actions = actionsFor(plainObject, ['doSomething'] as const)
expect(actions.doSomething(5)).toBe(10)
// @ts-expect-error - doSomethingElse not in keys
expect(actions.doSomethingElse).toBeUndefined()
})
it('should skip non-function properties', () => {
const mixedObject = {
method: () => 'result',
property: 'string value',
numberProp: 42,
}
// TypeScript would normally prevent this, but testing runtime behavior
const actions = actionsFor(mixedObject, [
'method',
'property' as any,
] as const)
expect(actions.method).toBeDefined()
// Non-function properties should not be included
expect((actions as any).property).toBeUndefined()
})
})