Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/1.guide/12.in-page-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ const channel = connectPanelChannel<MyChannelProtocol>({
})
```

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
Expand Down
72 changes: 72 additions & 0 deletions packages/devframe/src/in-page-channel/in-page-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }])
}
}
Comment thread
antfu marked this conversation as resolved.

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 {
Expand Down
9 changes: 6 additions & 3 deletions packages/devframe/src/in-page-channel/page-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
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(() => {})
},
}
}
Expand Down Expand Up @@ -99,11 +99,14 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
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')
Expand Down
9 changes: 6 additions & 3 deletions packages/devframe/src/in-page-channel/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(

const stateHost = createPanelStateHost<P>({
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(() => {})
Expand Down Expand Up @@ -138,7 +141,7 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
// 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')
Expand Down
Loading