Skip to content

Commit 7c65d3d

Browse files
committed
fix: apply in-page channel codecs to shared state
1 parent 5f6d5be commit 7c65d3d

4 files changed

Lines changed: 85 additions & 6 deletions

File tree

‎docs/content/1.guide/12.in-page-channel.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ const channel = connectPanelChannel<MyChannelProtocol>({
165165
})
166166
```
167167

168+
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`.
169+
168170
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.
169171

170172
## Multiple tabs

‎packages/devframe/src/in-page-channel/in-page-channel.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,77 @@ describe('in-page channel over bring-your-own ports', () => {
325325
})
326326

327327
describe('in-page channel shared state', () => {
328+
it.each(['patch', 'full state'] as const)('round-trips %s updates through both endpoint codecs', async (mode) => {
329+
function codec(sender: string, receiver: string) {
330+
return {
331+
serialize: vi.fn(value => ({ encodedBy: sender, value })),
332+
deserialize: vi.fn((wire: unknown) => {
333+
expect(wire).toHaveProperty('encodedBy', receiver)
334+
return (wire as { value: unknown }).value
335+
}),
336+
}
337+
}
338+
const pageCodec = codec('page-script', 'panel')
339+
const panelCodec = codec('panel', 'page-script')
340+
const { pageScript, panel, dispose } = createLinkedPair({ pageScript: pageCodec, panel: panelCodec })
341+
try {
342+
const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 1 } })
343+
const mirror = await panel.sharedState.get('doc')
344+
expect(mirror.value()).toEqual({ count: 1 })
345+
expect(pageCodec.serialize).toHaveBeenCalledWith({ count: 1 })
346+
expect(panelCodec.serialize.mock.calls[0]?.[0]).toBe('doc')
347+
348+
function update(state: typeof authority, count: number) {
349+
if (mode === 'patch') {
350+
state.mutate((draft) => {
351+
draft.count = count
352+
})
353+
}
354+
else {
355+
state.patch([{ op: 'replace', path: ['count'], value: count }])
356+
}
357+
}
358+
359+
update(authority, 2)
360+
await until(() => mirror.value().count === 2)
361+
update(mirror, 3)
362+
await until(() => authority.value().count === 3)
363+
await panel.call('echo', 'flushed')
364+
expect(authority.value()).toEqual({ count: 3 })
365+
expect(mirror.value()).toEqual({ count: 3 })
366+
}
367+
finally {
368+
dispose()
369+
}
370+
})
371+
372+
it('deserializes both subscription snapshots and subsequent notifications', async () => {
373+
function restore(value: unknown): unknown {
374+
if (Array.isArray(value))
375+
return value.map(restore)
376+
if (value && typeof value === 'object') {
377+
const restored = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, restore(item)]))
378+
return 'count' in restored ? { ...restored, label: 'restored' } : restored
379+
}
380+
return value
381+
}
382+
const { pageScript, panel, dispose } = createLinkedPair({
383+
panel: { deserialize: restore },
384+
})
385+
try {
386+
const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 1 } })
387+
const mirror = await panel.sharedState.get('doc')
388+
expect(mirror.value()).toEqual({ count: 1, label: 'restored' })
389+
390+
authority.mutate(() => ({ count: 2 }))
391+
await until(() => mirror.value().count === 2)
392+
expect(mirror.value()).toEqual({ count: 2, label: 'restored' })
393+
}
394+
finally {
395+
dispose()
396+
}
397+
})
398+
328399
it('seeds an equal snapshot and skips unchanged writes on both endpoints', async () => {
329400
const { pageScript, panel, dispose } = createLinkedPair()
330401
try {

‎packages/devframe/src/in-page-channel/page-script.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
7171
yield {
7272
subscribedStates: peer.subscribedStates,
7373
callEventRaw: (method: string, args: unknown[]) => {
74-
void peer.attached.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {})
74+
void peer.attached.rpc.$callRaw({ method, args: serializeArgs(codec, args), event: true, optional: true }).catch(() => {})
7575
},
7676
}
7777
}
@@ -99,11 +99,14 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
9999
internal.internalHandlers = stateHost.createPeerHandlers({
100100
subscribedStates: internal.subscribedStates,
101101
callEventRaw: (method, args) => {
102-
void internal.attached.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {})
102+
void internal.attached.rpc.$callRaw({ method, args: serializeArgs(codec, args), event: true, optional: true }).catch(() => {})
103103
},
104104
})
105+
const stateRegistry = createLocalFunctionRegistry(codec)
106+
for (const [name, handler] of Object.entries(internal.internalHandlers))
107+
stateRegistry.register({ name, handler })
105108
internal.attached = attachChannelPort(port, {
106-
resolveLocal: fnName => internal.internalHandlers[fnName] ?? registry.resolve(fnName),
109+
resolveLocal: fnName => stateRegistry.resolve(fnName) ?? registry.resolve(fnName),
107110
onControl: (kind) => {
108111
if (kind === 'ping')
109112
internal.attached.postControl('pong')

‎packages/devframe/src/in-page-channel/panel.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,12 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
8484

8585
const stateHost = createPanelStateHost<P>({
8686
isConnected: () => status === 'connected',
87-
callEvent: (method, args) => sendEvent(method, args),
88-
call: (method, args) => enqueueCall(method, args),
87+
callEvent: (method, args) => sendEvent(method, serializeArgs(codec, args)),
88+
call: (method, args) => enqueueCall(method, serializeArgs(codec, args)),
8989
})
90+
const stateRegistry = createLocalFunctionRegistry(codec)
91+
for (const [name, handler] of Object.entries(stateHost.handlers))
92+
stateRegistry.register({ name, handler })
9093

9194
function sendEventNow(method: string, args: unknown[]): void {
9295
void attached?.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {})
@@ -138,7 +141,7 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
138141
// another instance the user pinned to) replaces the previous port.
139142
attached?.dispose({ bye: true, reason: 'the panel adopted a newer port' })
140143
attached = attachChannelPort(port, {
141-
resolveLocal: fnName => stateHost.handlers[fnName] ?? registry.resolve(fnName),
144+
resolveLocal: fnName => stateRegistry.resolve(fnName) ?? registry.resolve(fnName),
142145
onControl: (kind) => {
143146
if (kind === 'ping')
144147
attached?.postControl('pong')

0 commit comments

Comments
 (0)