@sanity/sdk-react 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk-react",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -48,38 +48,38 @@
48
48
  "@sanity/types": "^5.26.0",
49
49
  "groq": "3.88.1-typegen-experimental.0",
50
50
  "react-compiler-runtime": "19.1.0-rc.2",
51
- "react-error-boundary": "^6.1.1",
51
+ "react-error-boundary": "^6.1.2",
52
52
  "rxjs": "^7.8.2",
53
- "@sanity/sdk": "2.12.0"
53
+ "@sanity/sdk": "2.13.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@sanity/browserslist-config": "^1.0.5",
57
57
  "@sanity/comlink": "^4.0.1",
58
- "@sanity/pkg-utils": "^8.1.29",
58
+ "@sanity/pkg-utils": "^9.2.3",
59
59
  "@sanity/prettier-config": "^1.0.6",
60
60
  "@testing-library/jest-dom": "^6.9.1",
61
61
  "@testing-library/react": "^16.3.2",
62
62
  "@types/node": "^24.12.4",
63
63
  "@types/react": "^19.2.15",
64
64
  "@types/react-dom": "^19.2.3",
65
- "@vitejs/plugin-react": "^4.7.0",
66
- "@vitest/coverage-v8": "4.1.6",
65
+ "@vitejs/plugin-react": "^5.2.0",
66
+ "@vitest/coverage-v8": "^4.1.8",
67
67
  "babel-plugin-react-compiler": "19.1.0-rc.1",
68
68
  "eslint": "^9.39.4",
69
- "groq-js": "^1.30.1",
69
+ "groq-js": "^1.30.2",
70
70
  "jsdom": "^29.1.1",
71
71
  "prettier": "^3.8.3",
72
72
  "react": "^19.2.6",
73
73
  "react-dom": "^19.2.6",
74
74
  "rollup-plugin-visualizer": "^5.14.0",
75
75
  "typescript": "^5.9.3",
76
- "vite": "^7.3.3",
77
- "vitest": "^4.1.6",
76
+ "vite": "^7.3.5",
77
+ "vitest": "^4.1.8",
78
78
  "@repo/config-eslint": "0.0.0",
79
79
  "@repo/config-test": "0.0.1",
80
- "@repo/tsconfig": "0.0.1",
80
+ "@repo/package.bundle": "3.82.0",
81
81
  "@repo/package.config": "0.0.1",
82
- "@repo/package.bundle": "3.82.0"
82
+ "@repo/tsconfig": "0.0.1"
83
83
  },
84
84
  "peerDependencies": {
85
85
  "react": "^18.0.0 || ^19.0.0",
@@ -92,6 +92,7 @@ export {type ProjectWithoutMembers, useProjects} from '../hooks/projects/useProj
92
92
  export {useQuery} from '../hooks/query/useQuery'
93
93
  export {useActiveReleases} from '../hooks/releases/useActiveReleases'
94
94
  export {useAllReleases} from '../hooks/releases/useAllReleases'
95
+ export {useApplyReleaseActions} from '../hooks/releases/useApplyReleaseActions'
95
96
  export {usePerspective} from '../hooks/releases/usePerspective'
96
97
  export {type UserResult, useUser} from '../hooks/users/useUser'
97
98
  export {type UsersResult, useUsers} from '../hooks/users/useUsers'
@@ -1,12 +1,15 @@
1
1
  import {applyDocumentActions, createSanityInstance} from '@sanity/sdk'
2
- import {renderHook as reactRenderHook} from '@testing-library/react'
3
2
  import {describe, it} from 'vitest'
4
3
 
5
4
  import {renderHook} from '../../../test/test-utils'
6
- import {SanityInstanceContext} from '../../context/SanityInstanceContext'
7
5
  import {useSanityInstance} from '../context/useSanityInstance'
8
6
  import {useApplyDocumentActions} from './useApplyDocumentActions'
9
7
 
8
+ // Resource resolution, mismatch detection, and context fallback are covered
9
+ // by hooks/helpers/useApplyActions.test.tsx — both this hook and
10
+ // useApplyReleaseActions are typed wrappers over that shared implementation.
11
+ // These tests just verify the wrapper forwards document actions through.
12
+
10
13
  vi.mock('@sanity/sdk', async (importOriginal) => {
11
14
  const original = await importOriginal<typeof import('@sanity/sdk')>()
12
15
  return {...original, applyDocumentActions: vi.fn()}
@@ -22,7 +25,7 @@ describe('useApplyDocumentActions', () => {
22
25
  vi.mocked(useSanityInstance).mockReturnValueOnce(instance)
23
26
  })
24
27
 
25
- it('uses the effective context resource', async () => {
28
+ it('forwards a document action to applyDocumentActions with the resolved resource', () => {
26
29
  const {result} = renderHook(() => useApplyDocumentActions())
27
30
  result.current({
28
31
  type: 'document.edit',
@@ -36,168 +39,10 @@ describe('useApplyDocumentActions', () => {
36
39
  type: 'document.edit',
37
40
  documentType: 'post',
38
41
  documentId: 'abc',
39
- // resource named in test-utils
40
42
  resource: {projectId: 'test', dataset: 'test'},
41
43
  },
42
44
  ],
43
45
  resource: {projectId: 'test', dataset: 'test'},
44
46
  })
45
47
  })
46
-
47
- it('uses the SanityInstance when resource is not provided', async () => {
48
- const {result} = reactRenderHook(() => useApplyDocumentActions(), {
49
- wrapper: ({children}) => (
50
- <SanityInstanceContext.Provider value={instance}>{children}</SanityInstanceContext.Provider>
51
- ),
52
- })
53
- result.current(
54
- {
55
- type: 'document.edit',
56
- documentType: 'post',
57
- documentId: 'abc',
58
- },
59
- {},
60
- )
61
-
62
- expect(applyDocumentActions).toHaveBeenCalledExactlyOnceWith(instance, {
63
- actions: [
64
- {
65
- type: 'document.edit',
66
- documentType: 'post',
67
- documentId: 'abc',
68
- resource: {projectId: 'p', dataset: 'd'},
69
- },
70
- ],
71
- resource: {projectId: 'p', dataset: 'd'},
72
- })
73
- })
74
-
75
- it('resolves resource from projectId and dataset in action', async () => {
76
- const {result} = renderHook(() => useApplyDocumentActions())
77
- result.current({
78
- type: 'document.edit',
79
- documentType: 'post',
80
- documentId: 'abc',
81
- projectId: 'p',
82
- dataset: 'd123',
83
- })
84
-
85
- expect(applyDocumentActions).toHaveBeenCalledExactlyOnceWith(instance, {
86
- actions: [
87
- {
88
- type: 'document.edit',
89
- documentType: 'post',
90
- documentId: 'abc',
91
- resource: {projectId: 'p', dataset: 'd123'},
92
- },
93
- ],
94
- resource: {projectId: 'p', dataset: 'd123'},
95
- })
96
- })
97
-
98
- it('throws when actions have mismatched project IDs', async () => {
99
- const {result} = renderHook(() => useApplyDocumentActions())
100
- expect(() => {
101
- result.current([
102
- {
103
- type: 'document.edit',
104
- documentType: 'post',
105
- documentId: 'abc',
106
- projectId: 'p123',
107
- dataset: 'd',
108
- },
109
- {
110
- type: 'document.edit',
111
- documentType: 'post',
112
- documentId: 'def',
113
- projectId: 'p456',
114
- dataset: 'd',
115
- },
116
- ])
117
- }).toThrow(/Mismatched resources found in actions/)
118
- })
119
-
120
- it('throws when actions have mismatched datasets', async () => {
121
- const {result} = renderHook(() => useApplyDocumentActions())
122
- expect(() => {
123
- result.current([
124
- {
125
- type: 'document.edit',
126
- documentType: 'post',
127
- documentId: 'abc',
128
- projectId: 'p',
129
- dataset: 'd1',
130
- },
131
- {
132
- type: 'document.edit',
133
- documentType: 'post',
134
- documentId: 'def',
135
- projectId: 'p',
136
- dataset: 'd2',
137
- },
138
- ])
139
- }).toThrow(/Mismatched resources found in actions/)
140
- })
141
-
142
- it('throws when actions have mismatched resources', async () => {
143
- const {result} = renderHook(() => useApplyDocumentActions())
144
- expect(() => {
145
- result.current([
146
- {
147
- type: 'document.edit',
148
- documentType: 'post',
149
- documentId: 'abc',
150
- resource: {projectId: 'p', dataset: 'd1'},
151
- },
152
- {
153
- type: 'document.edit',
154
- documentType: 'post',
155
- documentId: 'def',
156
- resource: {projectId: 'p', dataset: 'd2'},
157
- },
158
- ])
159
- }).toThrow(/Mismatched resources found in actions/)
160
- })
161
-
162
- it('throws when mixing projectId/dataset and resource with a mismatch (projectId first)', async () => {
163
- const {result} = renderHook(() => useApplyDocumentActions())
164
- expect(() => {
165
- result.current([
166
- {
167
- type: 'document.edit',
168
- documentType: 'post',
169
- documentId: 'abc',
170
- projectId: 'p1',
171
- dataset: 'd',
172
- },
173
- {
174
- type: 'document.edit',
175
- documentType: 'post',
176
- documentId: 'def',
177
- resource: {projectId: 'p2', dataset: 'd'},
178
- },
179
- ])
180
- }).toThrow(/Mismatched resources found in actions/)
181
- })
182
-
183
- it('throws when mixing resource and projectId/dataset with a mismatch (resource first)', async () => {
184
- const {result} = renderHook(() => useApplyDocumentActions())
185
- expect(() => {
186
- result.current([
187
- {
188
- type: 'document.edit',
189
- documentType: 'post',
190
- documentId: 'abc',
191
- resource: {projectId: 'p1', dataset: 'd'},
192
- },
193
- {
194
- type: 'document.edit',
195
- documentType: 'post',
196
- documentId: 'def',
197
- projectId: 'p2',
198
- dataset: 'd',
199
- },
200
- ])
201
- }).toThrow(/Mismatched resources found in actions/)
202
- })
203
48
  })
@@ -1,15 +1,8 @@
1
- import {type ActionsResult, applyDocumentActions, type DocumentAction} from '@sanity/sdk'
2
- import {isDeepEqual} from '@sanity/sdk/_internal'
1
+ import {type ActionsResult, type DocumentAction} from '@sanity/sdk'
3
2
  import {type SanityDocument} from 'groq'
4
- import {useContext} from 'react'
5
3
 
6
4
  import {type ResourceHandle} from '../../config/handles'
7
- import {ResourcesContext} from '../../context/ResourcesContext'
8
- import {useSanityInstance} from '../context/useSanityInstance'
9
- import {
10
- normalizeResourceOptions,
11
- useEffectiveContextResource,
12
- } from '../helpers/useNormalizedResourceOptions'
5
+ import {useApplyActions} from '../helpers/useApplyActions'
13
6
  // this import is used in an `{@link useEditDocument}`
14
7
  // eslint-disable-next-line import/consistent-type-specifier-style
15
8
  import type {useEditDocument} from './useEditDocument'
@@ -214,44 +207,5 @@ interface UseApplyDocumentActions {
214
207
  * ```
215
208
  */
216
209
  export const useApplyDocumentActions: UseApplyDocumentActions = () => {
217
- const instance = useSanityInstance()
218
- const resources = useContext(ResourcesContext)
219
- const effectiveContextResource = useEffectiveContextResource()
220
-
221
- return (actionOrActions, options) => {
222
- const actions = Array.isArray(actionOrActions) ? actionOrActions : [actionOrActions]
223
- const optionsResource = options
224
- ? normalizeResourceOptions(options, resources, effectiveContextResource).resource
225
- : undefined
226
-
227
- const normalizedActions = actions.map((action) =>
228
- normalizeResourceOptions(action, resources, effectiveContextResource),
229
- )
230
- let resource
231
-
232
- for (const action of normalizedActions) {
233
- if (!resource && action.resource) resource = action.resource
234
- if (!isDeepEqual(action.resource, resource)) {
235
- throw new Error(
236
- `Mismatched resources found in actions. All actions must belong to the same resource. Found "${JSON.stringify(action.resource)}" but expected "${JSON.stringify(resource)}".`,
237
- )
238
- }
239
- }
240
-
241
- if (optionsResource && resource && !isDeepEqual(optionsResource, resource)) {
242
- throw new Error(
243
- `Mismatched resources found in actions. Found top-level resource "${JSON.stringify(optionsResource)}" but expected resource from action handles "${JSON.stringify(resource)}".`,
244
- )
245
- }
246
-
247
- const effectiveResource = resource ?? optionsResource ?? effectiveContextResource
248
- if (!effectiveResource) {
249
- throw new Error('No resource found. Provide a resource via the action handle or context.')
250
- }
251
-
252
- return applyDocumentActions(instance, {
253
- actions: normalizedActions as DocumentAction[],
254
- resource: effectiveResource,
255
- })
256
- }
210
+ return useApplyActions() as ReturnType<UseApplyDocumentActions>
257
211
  }
@@ -0,0 +1,220 @@
1
+ import {applyDocumentActions, createSanityInstance} from '@sanity/sdk'
2
+ import {renderHook as reactRenderHook} from '@testing-library/react'
3
+ import {describe, it} from 'vitest'
4
+
5
+ import {renderHook} from '../../../test/test-utils'
6
+ import {SanityInstanceContext} from '../../context/SanityInstanceContext'
7
+ import {useSanityInstance} from '../context/useSanityInstance'
8
+ import {useApplyActions} from './useApplyActions'
9
+
10
+ vi.mock('@sanity/sdk', async (importOriginal) => {
11
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
12
+ return {...original, applyDocumentActions: vi.fn()}
13
+ })
14
+
15
+ vi.mock('../context/useSanityInstance')
16
+
17
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
18
+
19
+ describe('useApplyActions', () => {
20
+ beforeEach(() => {
21
+ vi.resetAllMocks()
22
+ vi.mocked(useSanityInstance).mockReturnValueOnce(instance)
23
+ })
24
+
25
+ it('uses the effective context resource', async () => {
26
+ const {result} = renderHook(() => useApplyActions())
27
+ result.current({
28
+ type: 'document.edit',
29
+ documentType: 'post',
30
+ documentId: 'abc',
31
+ })
32
+
33
+ expect(applyDocumentActions).toHaveBeenCalledExactlyOnceWith(instance, {
34
+ actions: [
35
+ {
36
+ type: 'document.edit',
37
+ documentType: 'post',
38
+ documentId: 'abc',
39
+ // resource named in test-utils
40
+ resource: {projectId: 'test', dataset: 'test'},
41
+ },
42
+ ],
43
+ resource: {projectId: 'test', dataset: 'test'},
44
+ })
45
+ })
46
+
47
+ it('uses the SanityInstance when resource is not provided', async () => {
48
+ const {result} = reactRenderHook(() => useApplyActions(), {
49
+ wrapper: ({children}) => (
50
+ <SanityInstanceContext.Provider value={instance}>{children}</SanityInstanceContext.Provider>
51
+ ),
52
+ })
53
+ result.current(
54
+ {
55
+ type: 'document.edit',
56
+ documentType: 'post',
57
+ documentId: 'abc',
58
+ },
59
+ {},
60
+ )
61
+
62
+ expect(applyDocumentActions).toHaveBeenCalledExactlyOnceWith(instance, {
63
+ actions: [
64
+ {
65
+ type: 'document.edit',
66
+ documentType: 'post',
67
+ documentId: 'abc',
68
+ resource: {projectId: 'p', dataset: 'd'},
69
+ },
70
+ ],
71
+ resource: {projectId: 'p', dataset: 'd'},
72
+ })
73
+ })
74
+
75
+ it('resolves resource from projectId and dataset on the action', async () => {
76
+ const {result} = renderHook(() => useApplyActions())
77
+ result.current({
78
+ type: 'document.edit',
79
+ documentType: 'post',
80
+ documentId: 'abc',
81
+ projectId: 'p',
82
+ dataset: 'd123',
83
+ })
84
+
85
+ expect(applyDocumentActions).toHaveBeenCalledExactlyOnceWith(instance, {
86
+ actions: [
87
+ {
88
+ type: 'document.edit',
89
+ documentType: 'post',
90
+ documentId: 'abc',
91
+ resource: {projectId: 'p', dataset: 'd123'},
92
+ },
93
+ ],
94
+ resource: {projectId: 'p', dataset: 'd123'},
95
+ })
96
+ })
97
+
98
+ it('throws when actions have mismatched project IDs', async () => {
99
+ const {result} = renderHook(() => useApplyActions())
100
+ expect(() => {
101
+ result.current([
102
+ {
103
+ type: 'document.edit',
104
+ documentType: 'post',
105
+ documentId: 'abc',
106
+ projectId: 'p123',
107
+ dataset: 'd',
108
+ },
109
+ {
110
+ type: 'document.edit',
111
+ documentType: 'post',
112
+ documentId: 'def',
113
+ projectId: 'p456',
114
+ dataset: 'd',
115
+ },
116
+ ])
117
+ }).toThrow(/Mismatched resources found in actions/)
118
+ })
119
+
120
+ it('throws when actions have mismatched datasets', async () => {
121
+ const {result} = renderHook(() => useApplyActions())
122
+ expect(() => {
123
+ result.current([
124
+ {
125
+ type: 'document.edit',
126
+ documentType: 'post',
127
+ documentId: 'abc',
128
+ projectId: 'p',
129
+ dataset: 'd1',
130
+ },
131
+ {
132
+ type: 'document.edit',
133
+ documentType: 'post',
134
+ documentId: 'def',
135
+ projectId: 'p',
136
+ dataset: 'd2',
137
+ },
138
+ ])
139
+ }).toThrow(/Mismatched resources found in actions/)
140
+ })
141
+
142
+ it('throws when actions have mismatched resources', async () => {
143
+ const {result} = renderHook(() => useApplyActions())
144
+ expect(() => {
145
+ result.current([
146
+ {
147
+ type: 'document.edit',
148
+ documentType: 'post',
149
+ documentId: 'abc',
150
+ resource: {projectId: 'p', dataset: 'd1'},
151
+ },
152
+ {
153
+ type: 'document.edit',
154
+ documentType: 'post',
155
+ documentId: 'def',
156
+ resource: {projectId: 'p', dataset: 'd2'},
157
+ },
158
+ ])
159
+ }).toThrow(/Mismatched resources found in actions/)
160
+ })
161
+
162
+ it('throws when mixing projectId/dataset and resource with a mismatch (projectId first)', async () => {
163
+ const {result} = renderHook(() => useApplyActions())
164
+ expect(() => {
165
+ result.current([
166
+ {
167
+ type: 'document.edit',
168
+ documentType: 'post',
169
+ documentId: 'abc',
170
+ projectId: 'p1',
171
+ dataset: 'd',
172
+ },
173
+ {
174
+ type: 'document.edit',
175
+ documentType: 'post',
176
+ documentId: 'def',
177
+ resource: {projectId: 'p2', dataset: 'd'},
178
+ },
179
+ ])
180
+ }).toThrow(/Mismatched resources found in actions/)
181
+ })
182
+
183
+ it('throws when mixing resource and projectId/dataset with a mismatch (resource first)', async () => {
184
+ const {result} = renderHook(() => useApplyActions())
185
+ expect(() => {
186
+ result.current([
187
+ {
188
+ type: 'document.edit',
189
+ documentType: 'post',
190
+ documentId: 'abc',
191
+ resource: {projectId: 'p1', dataset: 'd'},
192
+ },
193
+ {
194
+ type: 'document.edit',
195
+ documentType: 'post',
196
+ documentId: 'def',
197
+ projectId: 'p2',
198
+ dataset: 'd',
199
+ },
200
+ ])
201
+ }).toThrow(/Mismatched resources found in actions/)
202
+ })
203
+
204
+ it('throws when a top-level options resource conflicts with an action resource', async () => {
205
+ const {result} = renderHook(() => useApplyActions())
206
+ expect(() => {
207
+ result.current(
208
+ [
209
+ {
210
+ type: 'document.edit',
211
+ documentType: 'post',
212
+ documentId: 'abc',
213
+ resource: {projectId: 'p', dataset: 'd1'},
214
+ },
215
+ ],
216
+ {resource: {projectId: 'p', dataset: 'd2'}},
217
+ )
218
+ }).toThrow(/Mismatched resources found in actions/)
219
+ })
220
+ })
@@ -0,0 +1,68 @@
1
+ import {type Action, type ActionsResult, applyDocumentActions} from '@sanity/sdk'
2
+ import {isDeepEqual} from '@sanity/sdk/_internal'
3
+ import {useContext} from 'react'
4
+
5
+ import {type ResourceHandle} from '../../config/handles'
6
+ import {ResourcesContext} from '../../context/ResourcesContext'
7
+ import {useSanityInstance} from '../context/useSanityInstance'
8
+ import {normalizeResourceOptions, useEffectiveContextResource} from './useNormalizedResourceOptions'
9
+
10
+ /**
11
+ * @internal
12
+ *
13
+ * Shared implementation behind `useApplyDocumentActions` and
14
+ * `useApplyReleaseActions`. Resolves the effective resource from the action
15
+ * handles, top-level `options`, or context (in that order), validates that
16
+ * everything agrees on a single resource, and forwards to
17
+ * `applyDocumentActions`.
18
+ *
19
+ * The two public hooks differ only in their input/output types — at runtime
20
+ * they're identical because core's `applyDocumentActions` accepts the full
21
+ * `Action` union.
22
+ */
23
+ export function useApplyActions(): (
24
+ actionOrActions: Action | Action[],
25
+ options?: ResourceHandle,
26
+ ) => Promise<ActionsResult> {
27
+ const instance = useSanityInstance()
28
+ const resources = useContext(ResourcesContext)
29
+ const effectiveContextResource = useEffectiveContextResource()
30
+
31
+ return (actionOrActions, options) => {
32
+ const actions = Array.isArray(actionOrActions) ? actionOrActions : [actionOrActions]
33
+ const optionsResource = options
34
+ ? normalizeResourceOptions(options, resources, effectiveContextResource).resource
35
+ : undefined
36
+
37
+ const normalizedActions = actions.map((action) =>
38
+ normalizeResourceOptions(action, resources, effectiveContextResource),
39
+ )
40
+
41
+ let resource
42
+ for (const action of normalizedActions) {
43
+ const actionResource = action['resource']
44
+ if (!resource && actionResource) resource = actionResource
45
+ if (!isDeepEqual(actionResource, resource)) {
46
+ throw new Error(
47
+ `Mismatched resources found in actions. All actions must belong to the same resource. Found "${JSON.stringify(actionResource)}" but expected "${JSON.stringify(resource)}".`,
48
+ )
49
+ }
50
+ }
51
+
52
+ if (optionsResource && resource && !isDeepEqual(optionsResource, resource)) {
53
+ throw new Error(
54
+ `Mismatched resources found in actions. Found top-level resource "${JSON.stringify(optionsResource)}" but expected resource from action handles "${JSON.stringify(resource)}".`,
55
+ )
56
+ }
57
+
58
+ const effectiveResource = resource ?? optionsResource ?? effectiveContextResource
59
+ if (!effectiveResource) {
60
+ throw new Error('No resource found. Provide a resource via the action handle or context.')
61
+ }
62
+
63
+ return applyDocumentActions(instance, {
64
+ actions: normalizedActions as Action[],
65
+ resource: effectiveResource,
66
+ })
67
+ }
68
+ }
@@ -114,6 +114,47 @@ describe('useDocumentProjection', () => {
114
114
  expect(eventsUnsubscribe).toHaveBeenCalled()
115
115
  })
116
116
 
117
+ test('it re-reads the current snapshot when the element becomes visible again after an off-screen update', async () => {
118
+ // The store notifies subscribers only on changes *after* subscribe (it skips the
119
+ // value current at subscribe time), so the mocked subscribe never invokes its
120
+ // callback — matching real behavior. The hook must therefore re-read getCurrent()
121
+ // itself whenever the visibility gate (re)opens, otherwise a value that updated
122
+ // while the element was off-screen stays frozen on screen.
123
+ getCurrent.mockReturnValue({
124
+ data: {title: 'Initial Title', description: 'Initial Description'},
125
+ isPending: false,
126
+ })
127
+ const eventsUnsubscribe = vi.fn()
128
+ subscribe.mockImplementation(() => eventsUnsubscribe)
129
+
130
+ render(
131
+ <ResourceProvider fallback={<div>Loading...</div>}>
132
+ <TestComponent document={mockDocument} projection="{name, description}" />
133
+ </ResourceProvider>,
134
+ )
135
+
136
+ // Become visible, then hidden — mirrors a card being scrolled out of view.
137
+ await act(async () => {
138
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
139
+ })
140
+ await act(async () => {
141
+ intersectionObserverCallback([{isIntersecting: false} as IntersectionObserverEntry])
142
+ })
143
+
144
+ // The underlying document changes while the element is off-screen.
145
+ getCurrent.mockReturnValue({
146
+ data: {title: 'Updated Title', description: 'Updated Description'},
147
+ isPending: false,
148
+ })
149
+
150
+ // Becoming visible again must surface the value that changed while hidden.
151
+ await act(async () => {
152
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
153
+ })
154
+
155
+ expect(screen.getByText('Updated Title')).toBeInTheDocument()
156
+ })
157
+
117
158
  test('it suspends and resolves data when element becomes visible', async () => {
118
159
  // Mock the initial state to trigger suspense
119
160
  getCurrent.mockReturnValueOnce({
@@ -235,6 +235,12 @@ export function useDocumentProjection<TData extends object>({
235
235
  switchMap((isVisible) =>
236
236
  isVisible
237
237
  ? new Observable<void>((obs) => {
238
+ // `stateSource.subscribe` skips the value current at subscribe time;
239
+ // (intentionally -- we only want new events)
240
+ // but in this case the store might have updated while the element was off-screen;
241
+ // so we fire an immediate notification here to make useSyncExternalStore
242
+ // re-read getCurrent() and pick up the fresh value.
243
+ obs.next()
238
244
  return stateSource.subscribe(() => obs.next())
239
245
  })
240
246
  : EMPTY,