diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 7209d759..0e9cc85c 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -165,6 +165,8 @@ const channel = connectPanelChannel({ }) ``` +These hooks also apply to shared-state subscription snapshots, full-state updates, and patch arrays in both directions. Hooks that restore nested values should traverse objects and arrays, including each patch's `value`. + Declaring a function `jsonSerializable: true` additionally enforces strict JSON on its payloads at the receiving endpoint, turning a would-be silent coercion or cryptic `DataCloneError` into a coded error naming the offending path. ## Multiple tabs diff --git a/packages/devframe/src/in-page-channel/in-page-channel.test.ts b/packages/devframe/src/in-page-channel/in-page-channel.test.ts index 838e1384..f57f235b 100644 --- a/packages/devframe/src/in-page-channel/in-page-channel.test.ts +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -325,6 +325,78 @@ describe('in-page channel over bring-your-own ports', () => { }) describe('in-page channel shared state', () => { + it.each(['patch', 'full state'] as const)('round-trips %s updates through both endpoint codecs', async (mode) => { + function codec(sender: string, receiver: string) { + return { + serialize: vi.fn(value => ({ encodedBy: sender, value })), + deserialize: vi.fn((wire: unknown) => { + expect(wire).toHaveProperty('encodedBy', receiver) + return (wire as { value: unknown }).value + }), + } + } + const pageCodec = codec('page-script', 'panel') + const panelCodec = codec('panel', 'page-script') + const { pageScript, panel, dispose } = createLinkedPair({ pageScript: pageCodec, panel: panelCodec }) + try { + const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 1 } }) + const mirror = await panel.sharedState.get('doc') + expect(mirror.value()).toEqual({ count: 1 }) + expect(pageCodec.serialize).toHaveBeenCalledWith({ count: 1 }) + expect(panelCodec.serialize.mock.calls[0]?.[0]).toBe('doc') + + function update(state: typeof authority, count: number) { + // With enablePatches=true, mutate() emits patch arrays, while patch() emits an update with patches=undefined (full-state path). + if (mode === 'patch') { + state.mutate((draft) => { + draft.count = count + }) + } + else { + state.patch([{ op: 'replace', path: ['count'], value: count }]) + } + } + + update(authority, 2) + await until(() => mirror.value().count === 2) + update(mirror, 3) + await until(() => authority.value().count === 3) + await panel.call('echo', 'flushed') + expect(authority.value()).toEqual({ count: 3 }) + expect(mirror.value()).toEqual({ count: 3 }) + } + finally { + dispose() + } + }) + + it('deserializes both subscription snapshots and subsequent notifications', async () => { + function restore(value: unknown): unknown { + if (Array.isArray(value)) + return value.map(restore) + if (value && typeof value === 'object') { + const restored = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, restore(item)])) + return 'count' in restored ? { ...restored, label: 'restored' } : restored + } + return value + } + const { pageScript, panel, dispose } = createLinkedPair({ + panel: { deserialize: restore }, + }) + try { + const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 1 } }) + const mirror = await panel.sharedState.get('doc') + expect(mirror.value()).toEqual({ count: 1, label: 'restored' }) + + authority.mutate(() => ({ count: 2 })) + await until(() => mirror.value().count === 2) + expect(mirror.value()).toEqual({ count: 2, label: 'restored' }) + } + finally { + dispose() + } + }) + it('seeds an equal snapshot and skips unchanged writes on both endpoints', async () => { const { pageScript, panel, dispose } = createLinkedPair() try { diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index 6dc245a7..a4975983 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -71,7 +71,7 @@ export function createPageScriptChannel

( yield { subscribedStates: peer.subscribedStates, callEventRaw: (method: string, args: unknown[]) => { - void peer.attached.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {}) + void peer.attached.rpc.$callRaw({ method, args: serializeArgs(codec, args), event: true, optional: true }).catch(() => {}) }, } } @@ -99,11 +99,14 @@ export function createPageScriptChannel

( internal.internalHandlers = stateHost.createPeerHandlers({ subscribedStates: internal.subscribedStates, callEventRaw: (method, args) => { - void internal.attached.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {}) + void internal.attached.rpc.$callRaw({ method, args: serializeArgs(codec, args), event: true, optional: true }).catch(() => {}) }, }) + const stateRegistry = createLocalFunctionRegistry(codec) + for (const [name, handler] of Object.entries(internal.internalHandlers)) + stateRegistry.register({ name, handler }) internal.attached = attachChannelPort(port, { - resolveLocal: fnName => internal.internalHandlers[fnName] ?? registry.resolve(fnName), + resolveLocal: fnName => stateRegistry.resolve(fnName) ?? registry.resolve(fnName), onControl: (kind) => { if (kind === 'ping') internal.attached.postControl('pong') diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 374197d1..12f553fa 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -84,9 +84,12 @@ export function connectPanelChannel

( const stateHost = createPanelStateHost

({ isConnected: () => status === 'connected', - callEvent: (method, args) => sendEvent(method, args), - call: (method, args) => enqueueCall(method, args), + callEvent: (method, args) => sendEvent(method, serializeArgs(codec, args)), + call: (method, args) => enqueueCall(method, serializeArgs(codec, args)), }) + const stateRegistry = createLocalFunctionRegistry(codec) + for (const [name, handler] of Object.entries(stateHost.handlers)) + stateRegistry.register({ name, handler }) function sendEventNow(method: string, args: unknown[]): void { void attached?.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {}) @@ -138,7 +141,7 @@ export function connectPanelChannel

( // another instance the user pinned to) replaces the previous port. attached?.dispose({ bye: true, reason: 'the panel adopted a newer port' }) attached = attachChannelPort(port, { - resolveLocal: fnName => stateHost.handlers[fnName] ?? registry.resolve(fnName), + resolveLocal: fnName => stateRegistry.resolve(fnName) ?? registry.resolve(fnName), onControl: (kind) => { if (kind === 'ping') attached?.postControl('pong')