Skip to content

Commit ad47ad6

Browse files
authored
perf(shared-state): skip unchanged mutation notifications (#362)
1 parent 78816f7 commit ad47ad6

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
@@ -325,6 +325,45 @@ describe('in-page channel over bring-your-own ports', () => {
325325
})
326326

327327
describe('in-page channel shared state', () => {
328+
it('seeds an equal snapshot and skips unchanged writes on both endpoints', async () => {
329+
const { pageScript, panel, dispose } = createLinkedPair()
330+
try {
331+
const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 1 } })
332+
const mirror = await panel.sharedState.get('doc', { initialValue: { count: 1 } })
333+
const initialMirror = mirror.value()
334+
await until(() => mirror.value() !== initialMirror)
335+
expect(mirror.value()).toEqual({ count: 1 })
336+
337+
const authorityUpdated = vi.fn()
338+
const mirrorUpdated = vi.fn()
339+
authority.on('updated', authorityUpdated)
340+
mirror.on('updated', mirrorUpdated)
341+
authority.mutate((draft) => {
342+
draft.count = 1
343+
})
344+
mirror.mutate((draft) => {
345+
draft.count = 1
346+
})
347+
await panel.call('echo', 'flushed')
348+
expect(authorityUpdated).not.toHaveBeenCalled()
349+
expect(mirrorUpdated).not.toHaveBeenCalled()
350+
351+
authority.mutate((draft) => {
352+
draft.count = 2
353+
})
354+
await until(() => mirror.value().count === 2)
355+
mirror.mutate((draft) => {
356+
draft.count = 3
357+
})
358+
await until(() => authority.value().count === 3)
359+
expect(authorityUpdated).toHaveBeenCalledTimes(2)
360+
expect(mirrorUpdated).toHaveBeenCalledTimes(2)
361+
}
362+
finally {
363+
dispose()
364+
}
365+
})
366+
328367
it('replays the authority snapshot and streams patches to panels', async () => {
329368
const { pageScript, panel, dispose } = createLinkedPair()
330369
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)