Skip to content

Commit 3215f3a

Browse files
committed
perf(shared-state): skip unchanged mutation notifications
1 parent 6ba7c59 commit 3215f3a

7 files changed

Lines changed: 130 additions & 3 deletions

File tree

‎docs/content/1.guide/4.shared-state.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ state.mutate((draft) => {
7272
})
7373
```
7474

75-
Devframe applies the recipe to a draft, emits `updated` (with `SharedStatePatch[]` if enabled), and broadcasts to RPC clients; a `syncIds` set keeps mutations idempotent on replay.
75+
Devframe applies the recipe to a draft. When Immer returns a new state reference, Devframe emits `updated` and broadcasts it to RPC clients. Explicit replacement objects also notify. With patches enabled, `updated` carries `SharedStatePatch[]`. Sync IDs remain recorded for unchanged recipes, so replays stay idempotent.
7676

7777
## Patches (advanced)
7878

‎packages/devframe/src/client/rpc-shared-state.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ function makeFakeRpc() {
2020
}
2121

2222
describe('client shared state', () => {
23+
it('does not forward unchanged writes to the RPC server', async () => {
24+
const { rpc, events, setCalls } = makeFakeRpc()
25+
const state = await createRpcSharedStateClientHost(rpc).get('k', { initialValue: { count: 1 } })
26+
events.emit('rpc:is-trusted:updated', true)
27+
28+
state.mutate((draft) => {
29+
draft.count = 1
30+
})
31+
expect(setCalls).toHaveLength(0)
32+
state.mutate((draft) => {
33+
draft.count = 2
34+
})
35+
expect(setCalls).toHaveLength(1)
36+
expect(setCalls[0]?.[1]).toEqual({ count: 2 })
37+
})
38+
2339
it('registers the server-sync bridge once across repeated trust flips', async () => {
2440
const { rpc, events, setCalls } = makeFakeRpc()
2541
const host = createRpcSharedStateClientHost(rpc)

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,45 @@ describe('in-page channel over bring-your-own ports', () => {
307307
})
308308

309309
describe('in-page channel shared state', () => {
310+
it('seeds an equal snapshot and skips unchanged writes on both endpoints', async () => {
311+
const { pageScript, panel, dispose } = createLinkedPair()
312+
try {
313+
const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 1 } })
314+
const mirror = await panel.sharedState.get('doc', { initialValue: { count: 1 } })
315+
const initialMirror = mirror.value()
316+
await until(() => mirror.value() !== initialMirror)
317+
expect(mirror.value()).toEqual({ count: 1 })
318+
319+
const authorityUpdated = vi.fn()
320+
const mirrorUpdated = vi.fn()
321+
authority.on('updated', authorityUpdated)
322+
mirror.on('updated', mirrorUpdated)
323+
authority.mutate((draft) => {
324+
draft.count = 1
325+
})
326+
mirror.mutate((draft) => {
327+
draft.count = 1
328+
})
329+
await panel.call('echo', 'flushed')
330+
expect(authorityUpdated).not.toHaveBeenCalled()
331+
expect(mirrorUpdated).not.toHaveBeenCalled()
332+
333+
authority.mutate((draft) => {
334+
draft.count = 2
335+
})
336+
await until(() => mirror.value().count === 2)
337+
mirror.mutate((draft) => {
338+
draft.count = 3
339+
})
340+
await until(() => authority.value().count === 3)
341+
expect(authorityUpdated).toHaveBeenCalledTimes(2)
342+
expect(mirrorUpdated).toHaveBeenCalledTimes(2)
343+
}
344+
finally {
345+
dispose()
346+
}
347+
})
348+
310349
it('replays the authority snapshot and streams patches to panels', async () => {
311350
const { pageScript, panel, dispose } = createLinkedPair()
312351
try {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { DevframeNodeContext } from 'devframe/types'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { RpcFunctionsHostImpl } from '../host-functions'
4+
5+
describe('node-side shared state', () => {
6+
it('broadcasts the first RPC snapshot and deduplicates later echoes', async () => {
7+
const rpc = new RpcFunctionsHostImpl({} as DevframeNodeContext)
8+
const broadcast = vi.spyOn(rpc, 'broadcast').mockResolvedValue()
9+
10+
await rpc.invokeLocal('devframe:rpc:server-state:set', 'counter', { count: 1 }, 'first')
11+
expect(broadcast).toHaveBeenCalledTimes(1)
12+
expect(broadcast).toHaveBeenLastCalledWith({
13+
method: 'devframe:rpc:client-state:updated',
14+
args: ['counter', { count: 1 }, 'first'],
15+
filter: expect.any(Function),
16+
})
17+
18+
const state = await rpc.sharedState.get<{ count: number }>('counter')
19+
await rpc.invokeLocal('devframe:rpc:server-state:set', 'counter', { count: 2 }, 'first')
20+
expect(state.value()).toEqual({ count: 1 })
21+
expect(broadcast).toHaveBeenCalledTimes(1)
22+
23+
await rpc.invokeLocal('devframe:rpc:server-state:set', 'counter', { count: 2 }, 'second')
24+
expect(state.value()).toEqual({ count: 2 })
25+
expect(broadcast).toHaveBeenCalledTimes(2)
26+
27+
state.mutate((draft) => {
28+
draft.count = 2
29+
})
30+
expect(broadcast).toHaveBeenCalledTimes(2)
31+
})
32+
})

‎packages/devframe/src/node/rpc-shared-state.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ export function createRpcSharedStateServerHost(
130130
const state = await host.get(key, {
131131
initialValue: value,
132132
})
133-
state.mutate(() => value, syncId)
133+
// Publish the snapshot even when get() just created the state with this value.
134+
state.patch([{ op: 'replace', path: [], value }], syncId)
134135
},
135136
})
136137

‎packages/devframe/src/utils/shared-state.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,40 @@ import { describe, expect, it, vi } from 'vitest'
33
import { createSharedState } from './shared-state'
44

55
describe('shared-state', () => {
6+
it.each([false, true])('skips unchanged recipes and keeps sync IDs with patches enabled: %s', (enablePatches) => {
7+
const initialValue = { count: 0 }
8+
const state = createSharedState({ initialValue, enablePatches })
9+
const updated = vi.fn()
10+
state.on('updated', updated)
11+
12+
state.mutate(() => {}, 'empty')
13+
state.mutate((draft) => {
14+
draft.count = 0
15+
}, 'same-value')
16+
state.mutate(() => initialValue, 'same-reference')
17+
expect(state.value()).toBe(initialValue)
18+
expect(updated).not.toHaveBeenCalled()
19+
expect([...state.syncIds]).toEqual(['empty', 'same-value', 'same-reference'])
20+
21+
state.mutate((draft) => {
22+
draft.count = 99
23+
}, 'same-value')
24+
expect(state.value().count).toBe(0)
25+
state.mutate((draft) => {
26+
draft.count = 1
27+
}, 'changed')
28+
expect(state.value().count).toBe(1)
29+
expect(updated).toHaveBeenCalledTimes(1)
30+
31+
state.mutate(() => ({ count: 1 }), 'replacement')
32+
expect(updated).toHaveBeenCalledTimes(2)
33+
expect(() => state.mutate(() => {
34+
throw new Error('recipe failed')
35+
})).toThrow('recipe failed')
36+
expect(state.value().count).toBe(1)
37+
expect(updated).toHaveBeenCalledTimes(2)
38+
})
39+
640
describe('immutability', () => {
741
it('should return immutable state from get()', () => {
842
const state = createSharedState({

‎packages/devframe/src/utils/shared-state.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,16 @@ export function createSharedState<T extends object>(
126126
state as Objectish,
127127
fn as (draft: any) => void,
128128
)
129+
if (nextState === state)
130+
return
129131
state = nextState as T
130132
events.emit('updated', state, patches as SharedStatePatch[], syncId)
131133
}
132134
else {
133-
state = produce(state as Objectish, fn as (draft: any) => void) as T
135+
const nextState = produce(state as Objectish, fn as (draft: any) => void) as T
136+
if (nextState === state)
137+
return
138+
state = nextState
134139
events.emit('updated', state, undefined, syncId)
135140
}
136141
},

0 commit comments

Comments
 (0)