From f9a0ee60879f47a836d74668d1187b20a3937ae8 Mon Sep 17 00:00:00 2001 From: SimeonGriggs Date: Wed, 27 Aug 2025 15:53:00 +0100 Subject: [PATCH 1/2] fix: support params in use document projection --- .../core/src/projection/getProjectionState.ts | 17 +++++++-- .../core/src/projection/projectionQuery.ts | 35 ++++++++++++++++--- .../core/src/projection/projectionStore.ts | 1 + .../subscribeToStateAndFetchBatches.ts | 6 +++- packages/core/src/projection/types.ts | 11 ++++++ .../hooks/projection/useDocumentProjection.ts | 13 +++++-- 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/core/src/projection/getProjectionState.ts b/packages/core/src/projection/getProjectionState.ts index 578e180e4..7b971632b 100644 --- a/packages/core/src/projection/getProjectionState.ts +++ b/packages/core/src/projection/getProjectionState.ts @@ -22,6 +22,7 @@ export interface ProjectionOptions< TProjectId extends string = string, > extends DocumentHandle { projection: TProjection + params?: Record } /** @@ -82,11 +83,12 @@ export const _getProjectionState = bindActionByDataset( return state.values[documentId]?.[projectionHash] ?? STABLE_EMPTY_PROJECTION }, onSubscribe: ({state}, options: ProjectionOptions) => { - const {projection, ...docHandle} = options + const {projection, params, ...docHandle} = options const subscriptionId = insecureRandomId() const documentId = getPublishedId(docHandle.documentId) const validProjection = validateProjection(projection) - const projectionHash = hashString(validProjection) + const paramsHash = params ? `:${hashString(JSON.stringify(params))}` : '' + const projectionHash = hashString(`${validProjection}${paramsHash}`) state.set('addSubscription', (prev) => ({ documentProjections: { @@ -96,6 +98,13 @@ export const _getProjectionState = bindActionByDataset( [projectionHash]: validProjection, }, }, + projectionParams: { + ...prev.projectionParams, + [documentId]: { + ...prev.projectionParams[documentId], + [projectionHash]: params, + }, + }, subscriptions: { ...prev.subscriptions, [documentId]: { @@ -119,12 +128,14 @@ export const _getProjectionState = bindActionByDataset( const nextSubscriptions = {...prev.subscriptions} const nextDocumentProjections = {...prev.documentProjections} + const nextProjectionParams = {...prev.projectionParams} const nextValues = {...prev.values} // clean up the subscription and documentProjection if there are no subscribers if (!hasSubscribersForProjection) { delete nextSubscriptions[documentId]![projectionHash] delete nextDocumentProjections[documentId]![projectionHash] + delete nextProjectionParams[documentId]![projectionHash] const currentProjectionValue = prev.values[documentId]?.[projectionHash] if (currentProjectionValue && nextValues[documentId]) { @@ -146,12 +157,14 @@ export const _getProjectionState = bindActionByDataset( if (!hasAnySubscribersForDocument) { delete nextSubscriptions[documentId] delete nextDocumentProjections[documentId] + delete nextProjectionParams[documentId] // Keep nextValues[documentId] as cache } return { subscriptions: nextSubscriptions, documentProjections: nextDocumentProjections, + projectionParams: nextProjectionParams, values: nextValues, } }) diff --git a/packages/core/src/projection/projectionQuery.ts b/packages/core/src/projection/projectionQuery.ts index 8607050ed..07ca13971 100644 --- a/packages/core/src/projection/projectionQuery.ts +++ b/packages/core/src/projection/projectionQuery.ts @@ -18,11 +18,17 @@ interface CreateProjectionQueryResult { params: Record } -type ProjectionMap = Record}> +type ProjectionMap = Record< + string, + {projection: ValidProjection; params?: Record; documentIds: Set} +> export function createProjectionQuery( documentIds: Set, documentProjections: {[TDocumentId in string]?: DocumentProjections}, + projectionParams?: { + [TDocumentId in string]?: {[hash: string]: Record | undefined} + }, ): CreateProjectionQueryResult { const projections = Array.from(documentIds) .flatMap((id) => { @@ -32,11 +38,12 @@ export function createProjectionQuery( return Object.entries(projectionsForDoc).map(([projectionHash, projection]) => ({ documentId: id, projection, + params: projectionParams?.[id]?.[projectionHash], projectionHash, })) }) - .reduce((acc, {documentId, projection, projectionHash}) => { - const obj = acc[projectionHash] ?? {documentIds: new Set(), projection} + .reduce((acc, {documentId, projection, params, projectionHash}) => { + const obj = acc[projectionHash] ?? {documentIds: new Set(), projection, params} obj.documentIds.add(documentId) acc[projectionHash] = obj @@ -45,11 +52,20 @@ export function createProjectionQuery( const query = `[${Object.entries(projections) .map(([projectionHash, {projection}]) => { - return `...*[_id in $__ids_${projectionHash}]{_id,_type,_updatedAt,"__projectionHash":"${projectionHash}","result":{...${projection}}}` + // Rename parameter references inside the projection to avoid collisions between batches + const rewrittenProjection = (projection as ValidProjection).replace( + /\$[A-Za-z_][A-Za-z0-9_]*/g, + (match) => { + const paramName = match.slice(1) + return `$__p_${projectionHash}_${paramName}` + }, + ) + + return `...*[_id in $__ids_${projectionHash}]{_id,_type,_updatedAt,"__projectionHash":"${projectionHash}","result":{...${rewrittenProjection}}}` }) .join(',')}]` - const params = Object.fromEntries( + const idsParams = Object.fromEntries( Object.entries(projections).map(([projectionHash, value]) => { const idsInProjection = Array.from(value.documentIds).flatMap((id) => [ getPublishedId(id), @@ -60,6 +76,15 @@ export function createProjectionQuery( }), ) + // Merge all custom params across unique projection hashes. Since hashes include params hash, keys won't collide between different param sets. + const customParams = Object.fromEntries( + Object.entries(projections).flatMap(([projectionHash, {params}]) => + Object.entries(params ?? {}).map(([k, v]) => [`__p_${projectionHash}_${k}`, v]), + ), + ) + + const params = {...idsParams, ...customParams} + return {query, params} } diff --git a/packages/core/src/projection/projectionStore.ts b/packages/core/src/projection/projectionStore.ts index a1dec5cc8..0e1498cae 100644 --- a/packages/core/src/projection/projectionStore.ts +++ b/packages/core/src/projection/projectionStore.ts @@ -8,6 +8,7 @@ export const projectionStore = defineStore({ return { values: {}, documentProjections: {}, + projectionParams: {}, subscriptions: {}, } }, diff --git a/packages/core/src/projection/subscribeToStateAndFetchBatches.ts b/packages/core/src/projection/subscribeToStateAndFetchBatches.ts index f0e98b40b..a6ce2567a 100644 --- a/packages/core/src/projection/subscribeToStateAndFetchBatches.ts +++ b/packages/core/src/projection/subscribeToStateAndFetchBatches.ts @@ -87,7 +87,11 @@ export const subscribeToStateAndFetchBatches = ({ .pipe( switchMap(([ids, documentProjections]) => { if (!ids.size) return EMPTY - const {query, params} = createProjectionQuery(ids, documentProjections) + const {query, params} = createProjectionQuery( + ids, + documentProjections, + state.get().projectionParams, + ) const controller = new AbortController() return new Observable((observer) => { diff --git a/packages/core/src/projection/types.ts b/packages/core/src/projection/types.ts index 41b9ee030..39145ae8f 100644 --- a/packages/core/src/projection/types.ts +++ b/packages/core/src/projection/types.ts @@ -20,6 +20,10 @@ export interface DocumentProjections { [projectionHash: string]: ValidProjection } +export interface DocumentProjectionParams { + [projectionHash: string]: Record | undefined +} + interface DocumentProjectionSubscriptions { [projectionHash: string]: { [subscriptionId: string]: true @@ -41,6 +45,13 @@ export interface ProjectionStoreState { [documentId: string]: DocumentProjections } + /** + * A map of document IDs to their projection params, organized by projection hash + */ + projectionParams: { + [documentId: string]: DocumentProjectionParams + } + /** * A map of document IDs to their subscriptions, organized by projection hash */ diff --git a/packages/react/src/hooks/projection/useDocumentProjection.ts b/packages/react/src/hooks/projection/useDocumentProjection.ts index 1d2b67951..4468cb8b0 100644 --- a/packages/react/src/hooks/projection/useDocumentProjection.ts +++ b/packages/react/src/hooks/projection/useDocumentProjection.ts @@ -179,13 +179,22 @@ export function useDocumentProjection( export function useDocumentProjection({ ref, projection, + params, ...docHandle }: useDocumentProjectionOptions): useDocumentProjectionResults { const instance = useSanityInstance(docHandle) - const stateSource = getProjectionState(instance, {...docHandle, projection}) + const stateSource = getProjectionState(instance, { + ...docHandle, + projection, + ...(params ? {params} : {}), + }) if (stateSource.getCurrent()?.data === null) { - throw resolveProjection(instance, {...docHandle, projection}) + throw resolveProjection(instance, { + ...docHandle, + projection, + ...(params ? {params} : {}), + }) } // Create subscribe function for useSyncExternalStore From 9b628edf1697719842df3c4315b433a66b5806b0 Mon Sep 17 00:00:00 2001 From: SimeonGriggs Date: Wed, 27 Aug 2025 16:00:00 +0100 Subject: [PATCH 2/2] chore: add tests --- .../projection/useDocumentProjection.test.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/react/src/hooks/projection/useDocumentProjection.test.tsx b/packages/react/src/hooks/projection/useDocumentProjection.test.tsx index f25c652d6..059cfb176 100644 --- a/packages/react/src/hooks/projection/useDocumentProjection.test.tsx +++ b/packages/react/src/hooks/projection/useDocumentProjection.test.tsx @@ -280,4 +280,91 @@ describe('useDocumentProjection', () => { expect(subscribe).toHaveBeenCalled() expect(screen.getByText('Title')).toBeInTheDocument() }) + + test('it forwards params to core when provided', async () => { + const params = {q: 'Star'} + + // Trigger suspense first + getCurrent.mockReturnValueOnce({ + data: null, + isPending: true, + }) + + const resolvedData = { + data: {title: 'Resolved Title', description: 'Resolved Description'}, + isPending: false, + } + + ;(resolveProjection as Mock).mockReturnValueOnce(Promise.resolve(resolvedData)) + getCurrent.mockReturnValue(resolvedData) + subscribe.mockReturnValue(() => {}) + + function WithParamsComponent({ + projection, + ...docHandle + }: DocumentHandle & {projection: ValidProjection}) { + const ref = useRef(null) + const {data} = useDocumentProjection({ + ...docHandle, + projection, + params, + ref, + }) + return ( +
+

{data.title}

+
+ ) + } + + render( + Loading...}> + + , + ) + + await act(async () => { + intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry]) + await Promise.resolve() + }) + + const getProjectionCalls = (getProjectionState as unknown as Mock).mock.calls + const getProjectionCall = getProjectionCalls[getProjectionCalls.length - 1] + const resolveProjectionCalls = (resolveProjection as Mock).mock.calls + const resolveProjectionCall = resolveProjectionCalls[resolveProjectionCalls.length - 1] + + expect(getProjectionCall[1].params).toEqual(params) + expect(resolveProjectionCall[1].params).toEqual(params) + expect(screen.getByText('Resolved Title')).toBeInTheDocument() + }) + + test('it works without params and does not set params field', async () => { + getCurrent.mockReturnValue({ + data: {title: 'No Params', description: 'Desc'}, + isPending: false, + }) + subscribe.mockReturnValue(() => {}) + + function NoParamsComponent({ + projection, + ...docHandle + }: DocumentHandle & {projection: ValidProjection}) { + const {data} = useDocumentProjection({ + ...docHandle, + projection, + }) + return

{data.title}

+ } + + render( + Loading...}> + + , + ) + + const getProjectionCalls = (getProjectionState as unknown as Mock).mock.calls + const getProjectionCall = getProjectionCalls[getProjectionCalls.length - 1] + expect('params' in getProjectionCall[1]).toBe(false) + expect(screen.getByText('No Params')).toBeInTheDocument() + }) })