Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/core/src/_exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export {
createProjectHandle,
} from '../config/handles'
export {
childSourceFor,
type DatasetHandle,
type DocumentHandle,
type DocumentSource,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/config/sanityConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,20 @@ export const __sourceData = Symbol('Sanity.DocumentSource')
export function sourceFor(data: {projectId: string; dataset: string}): DocumentSource {
return {[__sourceData]: data}
}

/**
* @public
*/
export function childSourceFor(
parent: DocumentSource,
data: {projectId?: string; dataset?: string},
): DocumentSource {
const parentData = parent[__sourceData]

return {
[__sourceData]: {
projectId: data.projectId ?? parentData.projectId,
dataset: data.dataset ?? parentData.dataset,
},
}
}
57 changes: 0 additions & 57 deletions packages/core/src/document/applyDocumentActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,61 +149,4 @@ describe('applyDocumentActions', () => {

await expect(applyPromise).rejects.toThrow('Simulated error')
})

it('matches parent instance via child when action projectId and dataset do not match child config', async () => {
// Create a parent instance
const parentInstance = createSanityInstance()
// Create a child instance with different config
const childInstance = parentInstance.createChild({projectId: 'child-p', dataset: 'child-d'})
// Use the child instance in context
// Create an action that refers to the parent's configuration
const action: DocumentAction = {
type: 'document.edit',
documentId: 'doc1',
documentType: 'example',
patches: [{set: {foo: 'childTest'}}],
projectId: 'p',
dataset: 'd',
}
// Call applyDocumentActions with the context using childInstance, but with action requiring parent's config
const applyPromise = applyDocumentActions(childInstance, {
actions: [action],
transactionId: 'txn-child-match',
source,
})

// Simulate an applied transaction on the parent's instance
const appliedTx: AppliedTransaction = {
transactionId: 'txn-child-match',
actions: [action],
disableBatching: false,
outgoingActions: [],
outgoingMutations: [],
base: {doc1: {...exampleDoc, _id: 'doc1', foo: 'old', _rev: 'rev-old'}},
working: {doc1: {...exampleDoc, _id: 'doc1', foo: 'childTest', _rev: 'rev-new'}},
previous: {doc1: {...exampleDoc, _id: 'doc1', foo: 'old', _rev: 'rev-old'}},
previousRevs: {doc1: 'rev-old'},
timestamp: new Date().toISOString(),
}
state.set('simulateApplied', {applied: [appliedTx]})

const result = await applyPromise
expect(result.transactionId).toEqual('txn-child-match')
expect(result.documents).toEqual(appliedTx.working)
expect(result.previous).toEqual(appliedTx.previous)
expect(result.previousRevs).toEqual(appliedTx.previousRevs)

const acceptedResult = {transactionId: 'accepted-child'}
const acceptedEvent: DocumentEvent = {
type: 'accepted',
outgoing: {batchedTransactionIds: ['txn-child-match']} as OutgoingTransaction,
result: acceptedResult,
}
eventsSubject.next(acceptedEvent)
const submittedResult = await result.submitted()
expect(submittedResult).toEqual(acceptedResult)

childInstance.dispose()
parentInstance.dispose()
})
})
54 changes: 0 additions & 54 deletions packages/core/src/store/createSanityInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,58 +27,4 @@ describe('createSanityInstance', () => {
instance.dispose()
expect(callback).toHaveBeenCalledTimes(1)
})

it('should create a child instance with merged config and correct parent', () => {
const parent = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
const child = parent.createChild({dataset: 'ds2'})
expect(child.config).toEqual({projectId: 'proj1', dataset: 'ds2'})
expect(child.getParent()).toBe(parent)
})

it('should match an instance in the hierarchy using match', () => {
// three-level hierarchy
const grandparent = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
const parent = grandparent.createChild({projectId: 'proj2'})
const child = parent.createChild({dataset: 'ds2'})

expect(child.config).toEqual({projectId: 'proj2', dataset: 'ds2'})
expect(parent.config).toEqual({projectId: 'proj2', dataset: 'ds1'})

expect(child.match({dataset: 'ds2'})).toBe(child)
expect(child.match({projectId: 'proj2'})).toBe(child)
expect(child.match({projectId: 'proj1'})).toBe(grandparent)
expect(parent.match({projectId: 'proj1'})).toBe(grandparent)
expect(grandparent.match({projectId: 'proj1'})).toBe(grandparent)
})

it('should match `undefined` when the desired resource ID should not be set on an instance', () => {
const noProjectOrDataset = createSanityInstance()
const noDataset = noProjectOrDataset.createChild({projectId: 'proj1'})
const leaf = noDataset.createChild({dataset: 'ds1'})

// no keys means anything (in this case, self) will match
expect(leaf.match({})).toBe(leaf)

// `[resourceId]: undefined` means match an instance with no dataset set
expect(leaf.match({dataset: undefined})).toBe(noDataset)
expect(noDataset.match({dataset: undefined})).toBe(noDataset)
expect(leaf.match({projectId: undefined})).toBe(noProjectOrDataset)
expect(noDataset.match({projectId: undefined})).toBe(noProjectOrDataset)
expect(noProjectOrDataset.match({projectId: undefined})).toBe(noProjectOrDataset)
})

it('should return undefined when no match is found', () => {
const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
expect(instance.match({dataset: 'non-existent'})).toBeUndefined()
})

it('should inherit and merge auth config', () => {
const parent = createSanityInstance({
projectId: 'proj1',
dataset: 'ds1',
auth: {apiHost: 'api.sanity.work'},
})
const child = parent.createChild({auth: {token: 'my-token'}})
expect(child.config.auth).toEqual({apiHost: 'api.sanity.work', token: 'my-token'})
})
})
48 changes: 0 additions & 48 deletions packages/core/src/store/createSanityInstance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import {pick} from 'lodash-es'

import {type SanityConfig} from '../config/sanityConfig'
import {insecureRandomId} from '../utils/ids'

Expand Down Expand Up @@ -40,27 +38,6 @@ export interface SanityInstance {
* @returns Function to unsubscribe the callback
*/
onDispose(cb: () => void): () => void

/**
* Gets the parent instance in the hierarchy
* @returns Parent instance or undefined if this is the root
*/
getParent(): SanityInstance | undefined

/**
* Creates a child instance with merged configuration
* @param config - Configuration to merge with parent values
* @remarks Child instances inherit parent configuration but can override values
*/
createChild(config: SanityConfig): SanityInstance

/**
* Traverses the instance hierarchy to find the first instance whose configuration
* matches the given target config using a shallow comparison.
* @param targetConfig - A partial configuration object containing key-value pairs to match.
* @returns The first matching instance or undefined if no match is found.
*/
match(targetConfig: Partial<SanityConfig>): SanityInstance | undefined
}

/**
Expand Down Expand Up @@ -93,31 +70,6 @@ export function createSanityInstance(config: SanityConfig = {}): SanityInstance
disposeListeners.delete(listenerId)
}
},
getParent: () => undefined,
createChild: (next) =>
Object.assign(
createSanityInstance({
...config,
...next,
...(config.auth === next.auth
? config.auth
: config.auth && next.auth && {auth: {...config.auth, ...next.auth}}),
}),
{getParent: () => instance},
),
match: (targetConfig) => {
if (
Object.entries(pick(targetConfig, 'auth', 'projectId', 'dataset')).every(
([key, value]) => config[key as keyof SanityConfig] === value,
)
) {
return instance
}

const parent = instance.getParent()
if (parent) return parent.match(targetConfig)
return undefined
},
}

return instance
Expand Down
3 changes: 0 additions & 3 deletions packages/core/src/users/reducers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ describe('Users Reducers', () => {
isDisposed: () => false,
dispose: () => {},
onDispose: () => () => {},
getParent: () => undefined,
createChild: (_config) => mockInstance,
match: () => undefined,
}

const sampleOptions: GetUsersOptions = {
Expand Down
84 changes: 63 additions & 21 deletions packages/react/src/components/SDKProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {type SanityConfig} from '@sanity/sdk'
import {type ReactElement, type ReactNode} from 'react'
import {createSanityInstance, type SanityConfig, type SanityInstance, sourceFor} from '@sanity/sdk'
import {type ReactElement, type ReactNode, Suspense, useEffect, useMemo, useRef} from 'react'

import {ResourceProvider} from '../context/ResourceProvider'
import {DefaultSourceContext} from '../context/DefaultSourceContext'
import {PerspectiveContext} from '../context/PerspectiveContext'
import {SanityInstanceContext} from '../context/SanityInstanceContext'
import {AuthBoundary, type AuthBoundaryProps} from './auth/AuthBoundary'

/**
Expand All @@ -26,27 +28,67 @@ export function SDKProvider({
fallback,
...props
}: SDKProviderProps): ReactElement {
// reverse because we want the first config to be the default, but the
// ResourceProvider nesting makes the last one the default
const configs = (Array.isArray(config) ? config : [config]).slice().reverse()
const projectIds = configs.map((c) => c.projectId).filter((id): id is string => !!id)

// Create a nested structure of ResourceProviders for each config
const createNestedProviders = (index: number): ReactElement => {
if (index >= configs.length) {
return (
<AuthBoundary {...props} projectIds={projectIds}>
{children}
</AuthBoundary>
)
if (Array.isArray(config)) {
// eslint-disable-next-line no-console
console.warn(
'<SDKProvider>: Multiple configs are no longer supported. Only the first one will be used.',
)
}

const {projectId, dataset, perspective, ...mainConfig} = Array.isArray(config)
? config[0] || {}
: config

const instance = useMemo(() => createSanityInstance(mainConfig), [mainConfig])

// Ref to hold the scheduled disposal timer.
const disposal = useRef<{
instance: SanityInstance
timeoutId: ReturnType<typeof setTimeout>
} | null>(null)

useEffect(() => {
// If the component remounts quickly (as in Strict Mode), cancel any pending disposal.
if (disposal.current !== null && instance === disposal.current.instance) {
clearTimeout(disposal.current.timeoutId)
disposal.current = null
}

return () => {
disposal.current = {
instance,
timeoutId: setTimeout(() => {
if (!instance.isDisposed()) {
instance.dispose()
}
}, 0),
}
}
}, [instance])

let result = (
<SanityInstanceContext.Provider value={instance}>
<Suspense fallback={fallback}>
<AuthBoundary {...props}>{children}</AuthBoundary>
</Suspense>
</SanityInstanceContext.Provider>
)

if (perspective) {
result = <PerspectiveContext.Provider value={perspective}>{result}</PerspectiveContext.Provider>
}

if (projectId || dataset) {
if (!(projectId && dataset)) {
throw new Error('SDKProvider requires either both of projectId/dataset or none.')
}

return (
<ResourceProvider {...configs[index]} fallback={fallback}>
{createNestedProviders(index + 1)}
</ResourceProvider>
result = (
<DefaultSourceContext.Provider value={sourceFor({projectId, dataset})}>
{result}
</DefaultSourceContext.Provider>
)
}

return createNestedProviders(0)
return result
}
4 changes: 4 additions & 0 deletions packages/react/src/context/DefaultSourceContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {type DocumentSource} from '@sanity/sdk'
import {createContext} from 'react'

export const DefaultSourceContext = createContext<DocumentSource | null>(null)
4 changes: 4 additions & 0 deletions packages/react/src/context/PerspectiveContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {type PerspectiveHandle} from '@sanity/sdk'
import {createContext} from 'react'

export const PerspectiveContext = createContext<PerspectiveHandle['perspective']>(undefined)
Loading
Loading