Skip to content

Commit c68f9b7

Browse files
authored
fix(core): add perspective to cache key to avoid collisions (#609)
1 parent f8d9369 commit c68f9b7

File tree

7 files changed

+277
-15
lines changed

7 files changed

+277
-15
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {expect, test} from '@repo/e2e'
2+
3+
test.describe('Perspectives route', () => {
4+
test('published panel does not show draft content', async ({page, getClient, getPageContext}) => {
5+
const client = getClient()
6+
7+
// Create a published author
8+
const published = await client.create({
9+
_type: 'author',
10+
name: 'Author Base Name',
11+
})
12+
13+
// Create a draft overlay for the same document id
14+
await client.createOrReplace({
15+
_id: `drafts.${published._id}`,
16+
_type: 'author',
17+
name: 'Author Draft Name',
18+
})
19+
20+
// Navigate to the perspectives demo
21+
await page.goto('./perspectives')
22+
23+
const pageContext = await getPageContext(page)
24+
25+
// Wait for both panels to render
26+
const left = pageContext.getByRole('heading', {name: 'Drafts Resource Provider'})
27+
const right = pageContext.getByRole('heading', {name: 'Published Resource Provider'})
28+
await expect(left).toBeVisible()
29+
await expect(right).toBeVisible()
30+
31+
// Panels render JSON with stable test ids
32+
const draftsPanel = pageContext.getByTestId('panel-drafts-json')
33+
const publishedPanel = pageContext.getByTestId('panel-published-json')
34+
35+
// Validate content eventually reflects correct perspectives
36+
await expect(async () => {
37+
const draftsText = await draftsPanel.textContent()
38+
const publishedText = await publishedPanel.textContent()
39+
// Drafts subtree should show the draft overlay
40+
expect(draftsText).toContain('Author Draft Name')
41+
// Published subtree should not show draft name
42+
expect(publishedText).toContain('Author Base Name')
43+
expect(publishedText).not.toContain('Author Draft Name')
44+
}).toPass({timeout: 5000})
45+
})
46+
})

apps/kitchensink-react/src/AppRoutes.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {DashboardContextRoute} from './routes/DashboardContextRoute'
1818
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
1919
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
2020
import {ProjectsRoute} from './routes/ProjectsRoute'
21+
import {PerspectivesRoute} from './routes/PerspectivesRoute'
2122
import {ReleasesRoute} from './routes/releases/ReleasesRoute'
2223
import {UserDetailRoute} from './routes/UserDetailRoute'
2324
import {UsersRoute} from './routes/UsersRoute'
@@ -109,6 +110,10 @@ export function AppRoutes(): JSX.Element {
109110
path: 'projects',
110111
element: <ProjectsRoute />,
111112
},
113+
{
114+
path: 'perspectives',
115+
element: <PerspectivesRoute />,
116+
},
112117
]}
113118
/>
114119
}
@@ -120,6 +125,7 @@ export function AppRoutes(): JSX.Element {
120125
<Route path="comlink-demo" element={<ParentApp />} />
121126
<Route path="releases" element={<ReleasesRoute />} />
122127
<Route path="projects" element={<ProjectsRoute />} />
128+
<Route path="perspectives" element={<PerspectivesRoute />} />
123129
</Route>
124130
<Route path="comlink-demo">
125131
{frameRoutes.map((route) => (
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {ResourceProvider, useQuery} from '@sanity/sdk-react'
2+
import {Box, Card, Code, Flex, Heading, Stack, Text} from '@sanity/ui'
3+
import {type JSX, Suspense} from 'react'
4+
5+
function QueryPanel({
6+
title,
7+
docId,
8+
testId,
9+
}: {
10+
title: string
11+
docId: string
12+
testId: string
13+
}): JSX.Element {
14+
const {data} = useQuery<Record<string, unknown> | null>({
15+
query: '*[_id == $id][0]',
16+
params: {id: docId},
17+
})
18+
19+
return (
20+
<Card padding={4} radius={3} shadow={1} tone="transparent" data-testid={`panel-${testId}`}>
21+
<Stack space={3}>
22+
<Heading size={2} as="h2">
23+
{title}
24+
</Heading>
25+
<Box>
26+
<Text size={1} weight="semibold">
27+
Document
28+
</Text>
29+
<Card padding={3} radius={2} tone="transparent">
30+
<Code data-testid={`panel-${testId}-json`}>
31+
{JSON.stringify(data ?? null, null, 2)}
32+
</Code>
33+
</Card>
34+
</Box>
35+
</Stack>
36+
</Card>
37+
)
38+
}
39+
40+
export function PerspectivesRoute(): JSX.Element {
41+
// Get the latest published author document id once, outside the nested providers,
42+
// so both subtrees use the same document id.
43+
const {data: latest} = useQuery<{_id: string} | null>({
44+
query: '*[_type == "author"] | order(_updatedAt desc)[0]{_id}',
45+
// may not always return a draft result, but usually does in test dataset
46+
perspective: 'published',
47+
})
48+
49+
const docId = latest?._id
50+
51+
return (
52+
<Box padding={4}>
53+
<Stack space={4}>
54+
<Heading as="h1" size={5}>
55+
Perspectives Demo (Key Collision)
56+
</Heading>
57+
<Text size={1} muted>
58+
This nests ResourceProviders with the same project/dataset but different implicit
59+
perspectives (drafts vs published). Both panels run the same useQuery for the same
60+
document id without passing a perspective option.
61+
</Text>
62+
<Text size={1} muted>
63+
Latest published author id: <Code>{docId ?? 'Loading…'}</Code>
64+
</Text>
65+
66+
{/* ResourceProvider with drafts perspective */}
67+
<ResourceProvider perspective="drafts" fallback={null}>
68+
<Flex gap={4} wrap="wrap">
69+
<Box style={{minWidth: 320, flex: 1}}>
70+
<Suspense>
71+
{docId ? (
72+
<QueryPanel title="Drafts Resource Provider" docId={docId} testId="drafts" />
73+
) : null}
74+
</Suspense>
75+
</Box>
76+
77+
{/* ResourceProvider with published perspective */}
78+
<ResourceProvider perspective="published" fallback={null}>
79+
<Box style={{minWidth: 320, flex: 1}}>
80+
<Suspense>
81+
{docId ? (
82+
<QueryPanel
83+
title="Published Resource Provider"
84+
docId={docId}
85+
testId="published"
86+
/>
87+
) : null}
88+
</Suspense>
89+
</Box>
90+
</ResourceProvider>
91+
</Flex>
92+
</ResourceProvider>
93+
</Stack>
94+
</Box>
95+
)
96+
}

apps/kitchensink-react/src/routes/releases/ReleasesRoute.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,16 @@ export function ReleasesRoute(): JSX.Element {
114114
() => ({...selectedDocument, perspective: selectedPerspective.perspective}),
115115
[selectedDocument, selectedPerspective],
116116
)
117-
117+
const documentProjectionOptions = useMemo(
118+
() => ({
119+
...documentOptions,
120+
projection: `{name, "bestFriend": bestFriend->name}` as `{${string}}`,
121+
}),
122+
[documentOptions],
123+
)
118124
const documentResult = useDocument(documentOptions)
119125
const previewResult = useDocumentPreview(documentOptions)
120-
const projectionResult = useDocumentProjection({
121-
...documentOptions,
122-
projection: `{
123-
name,
124-
"bestFriend": bestFriend->name
125-
}`,
126-
})
126+
const projectionResult = useDocumentProjection(documentProjectionOptions)
127127

128128
return (
129129
<Box padding={4}>

packages/core/src/query/queryStore.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,4 +342,90 @@ describe('queryStore', () => {
342342
])
343343
unsubscribe2()
344344
})
345+
346+
it('separates cache entries by implicit perspective (instance.config)', async () => {
347+
// Mock fetch to return different results based on perspective option
348+
vi.mocked(fetch).mockImplementation(((_q, _p, options) => {
349+
const perspective = (options as {perspective?: unknown})?.perspective
350+
const result = perspective === 'published' ? [{_id: 'pub'}] : [{_id: 'drafts'}]
351+
return of({result, syncTags: []}).pipe(delay(0)) as unknown as ReturnType<
352+
SanityClient['observable']['fetch']
353+
>
354+
}) as SanityClient['observable']['fetch'])
355+
356+
const draftsInstance = createSanityInstance({
357+
projectId: 'test',
358+
dataset: 'test',
359+
perspective: 'drafts',
360+
})
361+
const publishedInstance = createSanityInstance({
362+
projectId: 'test',
363+
dataset: 'test',
364+
perspective: 'published',
365+
})
366+
367+
// Same query/options, different implicit perspectives via instance.config
368+
const sDrafts = getQueryState<{_id: string}[]>(draftsInstance, {query: '*[_type == "movie"]'})
369+
const sPublished = getQueryState<{_id: string}[]>(publishedInstance, {
370+
query: '*[_type == "movie"]',
371+
})
372+
373+
const unsubDrafts = sDrafts.subscribe()
374+
const unsubPublished = sPublished.subscribe()
375+
376+
const draftsResult = await firstValueFrom(
377+
sDrafts.observable.pipe(filter((i) => i !== undefined)),
378+
)
379+
const publishedResult = await firstValueFrom(
380+
sPublished.observable.pipe(filter((i) => i !== undefined)),
381+
)
382+
383+
expect(draftsResult).toEqual([{_id: 'drafts'}])
384+
expect(publishedResult).toEqual([{_id: 'pub'}])
385+
386+
unsubDrafts()
387+
unsubPublished()
388+
389+
draftsInstance.dispose()
390+
publishedInstance.dispose()
391+
})
392+
393+
it('separates cache entries by explicit perspective in options', async () => {
394+
vi.mocked(fetch).mockImplementation(((_q, _p, options) => {
395+
const perspective = (options as {perspective?: unknown})?.perspective
396+
const result = perspective === 'published' ? [{_id: 'pub'}] : [{_id: 'drafts'}]
397+
return of({result, syncTags: []}).pipe(delay(0)) as unknown as ReturnType<
398+
SanityClient['observable']['fetch']
399+
>
400+
}) as SanityClient['observable']['fetch'])
401+
402+
const base = createSanityInstance({projectId: 'test', dataset: 'test'})
403+
404+
const sDrafts = getQueryState<{_id: string}[]>(base, {
405+
query: '*[_type == "movie"]',
406+
perspective: 'drafts',
407+
})
408+
const sPublished = getQueryState<{_id: string}[]>(base, {
409+
query: '*[_type == "movie"]',
410+
perspective: 'published',
411+
})
412+
413+
const unsubDrafts = sDrafts.subscribe()
414+
const unsubPublished = sPublished.subscribe()
415+
416+
const draftsResult = await firstValueFrom(
417+
sDrafts.observable.pipe(filter((i) => i !== undefined)),
418+
)
419+
const publishedResult = await firstValueFrom(
420+
sPublished.observable.pipe(filter((i) => i !== undefined)),
421+
)
422+
423+
expect(draftsResult).toEqual([{_id: 'drafts'}])
424+
expect(publishedResult).toEqual([{_id: 'pub'}])
425+
426+
unsubDrafts()
427+
unsubPublished()
428+
429+
base.dispose()
430+
})
345431
})

packages/core/src/query/queryStore.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ import {
3434
import {type StoreState} from '../store/createStoreState'
3535
import {defineStore, type StoreContext} from '../store/defineStore'
3636
import {insecureRandomId} from '../utils/ids'
37-
import {QUERY_STATE_CLEAR_DELAY, QUERY_STORE_API_VERSION} from './queryStoreConstants'
37+
import {
38+
QUERY_STATE_CLEAR_DELAY,
39+
QUERY_STORE_API_VERSION,
40+
QUERY_STORE_DEFAULT_PERSPECTIVE,
41+
} from './queryStoreConstants'
3842
import {
3943
addSubscriber,
4044
cancelQuery,
@@ -77,6 +81,28 @@ export const getQueryKey = (options: QueryOptions): string => JSON.stringify(opt
7781
/** @beta */
7882
export const parseQueryKey = (key: string): QueryOptions => JSON.parse(key)
7983

84+
/**
85+
* Ensures the query key includes an effective perspective so that
86+
* implicit differences (e.g. different instance.config.perspective)
87+
* don't collide in the dataset-scoped store.
88+
*
89+
* Since perspectives are unique, we can depend on the release stacks
90+
* to be correct when we retrieve the results.
91+
*
92+
*/
93+
function normalizeOptionsWithPerspective(
94+
instance: SanityInstance,
95+
options: QueryOptions,
96+
): QueryOptions {
97+
if (options.perspective !== undefined) return options
98+
const instancePerspective = instance.config.perspective
99+
return {
100+
...options,
101+
perspective:
102+
instancePerspective !== undefined ? instancePerspective : QUERY_STORE_DEFAULT_PERSPECTIVE,
103+
}
104+
}
105+
80106
const queryStore = defineStore<QueryStoreState>({
81107
name: 'QueryStore',
82108
getInitialState: () => ({queries: {}}),
@@ -255,16 +281,16 @@ export function getQueryState(
255281
const _getQueryState = bindActionByDataset(
256282
queryStore,
257283
createStateSourceAction({
258-
selector: ({state}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
284+
selector: ({state, instance}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
259285
if (state.error) throw state.error
260-
const key = getQueryKey(options)
286+
const key = getQueryKey(normalizeOptionsWithPerspective(instance, options))
261287
const queryState = state.queries[key]
262288
if (queryState?.error) throw queryState.error
263289
return queryState?.result
264290
},
265-
onSubscribe: ({state}, options: QueryOptions) => {
291+
onSubscribe: ({state, instance}, options: QueryOptions) => {
266292
const subscriptionId = insecureRandomId()
267-
const key = getQueryKey(options)
293+
const key = getQueryKey(normalizeOptionsWithPerspective(instance, options))
268294

269295
state.set('addSubscriber', addSubscriber(key, subscriptionId))
270296

@@ -314,8 +340,9 @@ export function resolveQuery(...args: Parameters<typeof _resolveQuery>): Promise
314340
const _resolveQuery = bindActionByDataset(
315341
queryStore,
316342
({state, instance}, {signal, ...options}: ResolveQueryOptions) => {
317-
const {getCurrent} = getQueryState(instance, options)
318-
const key = getQueryKey(options)
343+
const normalized = normalizeOptionsWithPerspective(instance, options)
344+
const {getCurrent} = getQueryState(instance, normalized)
345+
const key = getQueryKey(normalized)
319346

320347
const aborted$ = signal
321348
? new Observable<void>((observer) => {

packages/core/src/query/queryStoreConstants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
*/
88
export const QUERY_STATE_CLEAR_DELAY = 1000
99
export const QUERY_STORE_API_VERSION = 'v2025-05-06'
10+
export const QUERY_STORE_DEFAULT_PERSPECTIVE = 'drafts'

0 commit comments

Comments
 (0)