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
46 changes: 46 additions & 0 deletions apps/kitchensink-react/e2e/perspectives.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {expect, test} from '@repo/e2e'

test.describe('Perspectives route', () => {
test('published panel does not show draft content', async ({page, getClient, getPageContext}) => {
const client = getClient()

// Create a published author
const published = await client.create({
_type: 'author',
name: 'Author Base Name',
})

// Create a draft overlay for the same document id
await client.createOrReplace({
_id: `drafts.${published._id}`,
_type: 'author',
name: 'Author Draft Name',
})

// Navigate to the perspectives demo
await page.goto('./perspectives')

const pageContext = await getPageContext(page)

// Wait for both panels to render
const left = pageContext.getByRole('heading', {name: 'Drafts Resource Provider'})
const right = pageContext.getByRole('heading', {name: 'Published Resource Provider'})
await expect(left).toBeVisible()
await expect(right).toBeVisible()

// Panels render JSON with stable test ids
const draftsPanel = pageContext.getByTestId('panel-drafts-json')
const publishedPanel = pageContext.getByTestId('panel-published-json')

// Validate content eventually reflects correct perspectives
await expect(async () => {
const draftsText = await draftsPanel.textContent()
const publishedText = await publishedPanel.textContent()
// Drafts subtree should show the draft overlay
expect(draftsText).toContain('Author Draft Name')
// Published subtree should not show draft name
expect(publishedText).toContain('Author Base Name')
expect(publishedText).not.toContain('Author Draft Name')
}).toPass({timeout: 5000})
})
})
6 changes: 6 additions & 0 deletions apps/kitchensink-react/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {DashboardContextRoute} from './routes/DashboardContextRoute'
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
import {ProjectsRoute} from './routes/ProjectsRoute'
import {PerspectivesRoute} from './routes/PerspectivesRoute'
import {ReleasesRoute} from './routes/releases/ReleasesRoute'
import {UserDetailRoute} from './routes/UserDetailRoute'
import {UsersRoute} from './routes/UsersRoute'
Expand Down Expand Up @@ -109,6 +110,10 @@ export function AppRoutes(): JSX.Element {
path: 'projects',
element: <ProjectsRoute />,
},
{
path: 'perspectives',
element: <PerspectivesRoute />,
},
]}
/>
}
Expand All @@ -120,6 +125,7 @@ export function AppRoutes(): JSX.Element {
<Route path="comlink-demo" element={<ParentApp />} />
<Route path="releases" element={<ReleasesRoute />} />
<Route path="projects" element={<ProjectsRoute />} />
<Route path="perspectives" element={<PerspectivesRoute />} />
</Route>
<Route path="comlink-demo">
{frameRoutes.map((route) => (
Expand Down
96 changes: 96 additions & 0 deletions apps/kitchensink-react/src/routes/PerspectivesRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {ResourceProvider, useQuery} from '@sanity/sdk-react'
import {Box, Card, Code, Flex, Heading, Stack, Text} from '@sanity/ui'
import {type JSX, Suspense} from 'react'

function QueryPanel({
title,
docId,
testId,
}: {
title: string
docId: string
testId: string
}): JSX.Element {
const {data} = useQuery<Record<string, unknown> | null>({
query: '*[_id == $id][0]',
params: {id: docId},
})

return (
<Card padding={4} radius={3} shadow={1} tone="transparent" data-testid={`panel-${testId}`}>
<Stack space={3}>
<Heading size={2} as="h2">
{title}
</Heading>
<Box>
<Text size={1} weight="semibold">
Document
</Text>
<Card padding={3} radius={2} tone="transparent">
<Code data-testid={`panel-${testId}-json`}>
{JSON.stringify(data ?? null, null, 2)}
</Code>
</Card>
</Box>
</Stack>
</Card>
)
}

export function PerspectivesRoute(): JSX.Element {
// Get the latest published author document id once, outside the nested providers,
// so both subtrees use the same document id.
const {data: latest} = useQuery<{_id: string} | null>({
query: '*[_type == "author"] | order(_updatedAt desc)[0]{_id}',
// may not always return a draft result, but usually does in test dataset
perspective: 'published',
})

const docId = latest?._id

return (
<Box padding={4}>
<Stack space={4}>
<Heading as="h1" size={5}>
Perspectives Demo (Key Collision)
</Heading>
<Text size={1} muted>
This nests ResourceProviders with the same project/dataset but different implicit
perspectives (drafts vs published). Both panels run the same useQuery for the same
document id without passing a perspective option.
</Text>
<Text size={1} muted>
Latest published author id: <Code>{docId ?? 'Loading…'}</Code>
</Text>

{/* ResourceProvider with drafts perspective */}
<ResourceProvider perspective="drafts" fallback={null}>
<Flex gap={4} wrap="wrap">
<Box style={{minWidth: 320, flex: 1}}>
<Suspense>
{docId ? (
<QueryPanel title="Drafts Resource Provider" docId={docId} testId="drafts" />
) : null}
</Suspense>
</Box>

{/* ResourceProvider with published perspective */}
<ResourceProvider perspective="published" fallback={null}>
<Box style={{minWidth: 320, flex: 1}}>
<Suspense>
{docId ? (
<QueryPanel
title="Published Resource Provider"
docId={docId}
testId="published"
/>
) : null}
</Suspense>
</Box>
</ResourceProvider>
</Flex>
</ResourceProvider>
</Stack>
</Box>
)
}
16 changes: 8 additions & 8 deletions apps/kitchensink-react/src/routes/releases/ReleasesRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,16 @@ export function ReleasesRoute(): JSX.Element {
() => ({...selectedDocument, perspective: selectedPerspective.perspective}),
[selectedDocument, selectedPerspective],
)

const documentProjectionOptions = useMemo(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related, but the old implementation caused infinite loading due to recalculating the params passed to documentProjection. That might be something we want to resolve in the hook itself at a later date, but it was annoying me 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm I thought kitchensink was using react 19 and compiler, maybe we removed it

() => ({
...documentOptions,
projection: `{name, "bestFriend": bestFriend->name}` as `{${string}}`,
}),
[documentOptions],
)
const documentResult = useDocument(documentOptions)
const previewResult = useDocumentPreview(documentOptions)
const projectionResult = useDocumentProjection({
...documentOptions,
projection: `{
name,
"bestFriend": bestFriend->name
}`,
})
const projectionResult = useDocumentProjection(documentProjectionOptions)

return (
<Box padding={4}>
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/query/queryStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,4 +342,90 @@ describe('queryStore', () => {
])
unsubscribe2()
})

it('separates cache entries by implicit perspective (instance.config)', async () => {
// Mock fetch to return different results based on perspective option
vi.mocked(fetch).mockImplementation(((_q, _p, options) => {
const perspective = (options as {perspective?: unknown})?.perspective
const result = perspective === 'published' ? [{_id: 'pub'}] : [{_id: 'drafts'}]
return of({result, syncTags: []}).pipe(delay(0)) as unknown as ReturnType<
SanityClient['observable']['fetch']
>
}) as SanityClient['observable']['fetch'])

const draftsInstance = createSanityInstance({
projectId: 'test',
dataset: 'test',
perspective: 'drafts',
})
const publishedInstance = createSanityInstance({
projectId: 'test',
dataset: 'test',
perspective: 'published',
})

// Same query/options, different implicit perspectives via instance.config
const sDrafts = getQueryState<{_id: string}[]>(draftsInstance, {query: '*[_type == "movie"]'})
const sPublished = getQueryState<{_id: string}[]>(publishedInstance, {
query: '*[_type == "movie"]',
})

const unsubDrafts = sDrafts.subscribe()
const unsubPublished = sPublished.subscribe()

const draftsResult = await firstValueFrom(
sDrafts.observable.pipe(filter((i) => i !== undefined)),
)
const publishedResult = await firstValueFrom(
sPublished.observable.pipe(filter((i) => i !== undefined)),
)

expect(draftsResult).toEqual([{_id: 'drafts'}])
expect(publishedResult).toEqual([{_id: 'pub'}])

unsubDrafts()
unsubPublished()

draftsInstance.dispose()
publishedInstance.dispose()
})

it('separates cache entries by explicit perspective in options', async () => {
vi.mocked(fetch).mockImplementation(((_q, _p, options) => {
const perspective = (options as {perspective?: unknown})?.perspective
const result = perspective === 'published' ? [{_id: 'pub'}] : [{_id: 'drafts'}]
return of({result, syncTags: []}).pipe(delay(0)) as unknown as ReturnType<
SanityClient['observable']['fetch']
>
}) as SanityClient['observable']['fetch'])

const base = createSanityInstance({projectId: 'test', dataset: 'test'})

const sDrafts = getQueryState<{_id: string}[]>(base, {
query: '*[_type == "movie"]',
perspective: 'drafts',
})
const sPublished = getQueryState<{_id: string}[]>(base, {
query: '*[_type == "movie"]',
perspective: 'published',
})

const unsubDrafts = sDrafts.subscribe()
const unsubPublished = sPublished.subscribe()

const draftsResult = await firstValueFrom(
sDrafts.observable.pipe(filter((i) => i !== undefined)),
)
const publishedResult = await firstValueFrom(
sPublished.observable.pipe(filter((i) => i !== undefined)),
)

expect(draftsResult).toEqual([{_id: 'drafts'}])
expect(publishedResult).toEqual([{_id: 'pub'}])

unsubDrafts()
unsubPublished()

base.dispose()
})
})
41 changes: 34 additions & 7 deletions packages/core/src/query/queryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ import {
import {type StoreState} from '../store/createStoreState'
import {defineStore, type StoreContext} from '../store/defineStore'
import {insecureRandomId} from '../utils/ids'
import {QUERY_STATE_CLEAR_DELAY, QUERY_STORE_API_VERSION} from './queryStoreConstants'
import {
QUERY_STATE_CLEAR_DELAY,
QUERY_STORE_API_VERSION,
QUERY_STORE_DEFAULT_PERSPECTIVE,
} from './queryStoreConstants'
import {
addSubscriber,
cancelQuery,
Expand Down Expand Up @@ -77,6 +81,28 @@ export const getQueryKey = (options: QueryOptions): string => JSON.stringify(opt
/** @beta */
export const parseQueryKey = (key: string): QueryOptions => JSON.parse(key)

/**
* Ensures the query key includes an effective perspective so that
* implicit differences (e.g. different instance.config.perspective)
* don't collide in the dataset-scoped store.
*
* Since perspectives are unique, we can depend on the release stacks
* to be correct when we retrieve the results.
*
*/
function normalizeOptionsWithPerspective(
instance: SanityInstance,
options: QueryOptions,
): QueryOptions {
if (options.perspective !== undefined) return options
const instancePerspective = instance.config.perspective
return {
...options,
perspective:
instancePerspective !== undefined ? instancePerspective : QUERY_STORE_DEFAULT_PERSPECTIVE,
}
}

const queryStore = defineStore<QueryStoreState>({
name: 'QueryStore',
getInitialState: () => ({queries: {}}),
Expand Down Expand Up @@ -255,16 +281,16 @@ export function getQueryState(
const _getQueryState = bindActionByDataset(
queryStore,
createStateSourceAction({
selector: ({state}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
selector: ({state, instance}: SelectorContext<QueryStoreState>, options: QueryOptions) => {
if (state.error) throw state.error
const key = getQueryKey(options)
const key = getQueryKey(normalizeOptionsWithPerspective(instance, options))
const queryState = state.queries[key]
if (queryState?.error) throw queryState.error
return queryState?.result
},
onSubscribe: ({state}, options: QueryOptions) => {
onSubscribe: ({state, instance}, options: QueryOptions) => {
const subscriptionId = insecureRandomId()
const key = getQueryKey(options)
const key = getQueryKey(normalizeOptionsWithPerspective(instance, options))

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

Expand Down Expand Up @@ -314,8 +340,9 @@ export function resolveQuery(...args: Parameters<typeof _resolveQuery>): Promise
const _resolveQuery = bindActionByDataset(
queryStore,
({state, instance}, {signal, ...options}: ResolveQueryOptions) => {
const {getCurrent} = getQueryState(instance, options)
const key = getQueryKey(options)
const normalized = normalizeOptionsWithPerspective(instance, options)
const {getCurrent} = getQueryState(instance, normalized)
const key = getQueryKey(normalized)

const aborted$ = signal
? new Observable<void>((observer) => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/query/queryStoreConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
*/
export const QUERY_STATE_CLEAR_DELAY = 1000
export const QUERY_STORE_API_VERSION = 'v2025-05-06'
export const QUERY_STORE_DEFAULT_PERSPECTIVE = 'drafts'
Loading