@sanity/sdk 0.0.0-chore-react-18-compat.1 → 0.0.0-chore-react-18-compat.3

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.
Files changed (134) hide show
  1. package/dist/index.d.ts +441 -322
  2. package/dist/index.js +1685 -1481
  3. package/dist/index.js.map +1 -1
  4. package/package.json +13 -15
  5. package/src/_exports/index.ts +32 -30
  6. package/src/auth/authStore.test.ts +149 -104
  7. package/src/auth/authStore.ts +51 -100
  8. package/src/auth/handleAuthCallback.test.ts +67 -34
  9. package/src/auth/handleAuthCallback.ts +8 -7
  10. package/src/auth/logout.test.ts +61 -29
  11. package/src/auth/logout.ts +26 -28
  12. package/src/auth/refreshStampedToken.test.ts +197 -91
  13. package/src/auth/refreshStampedToken.ts +170 -59
  14. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +5 -5
  15. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +45 -47
  16. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +4 -5
  17. package/src/auth/subscribeToStorageEventsAndSetToken.ts +22 -24
  18. package/src/client/clientStore.test.ts +131 -67
  19. package/src/client/clientStore.ts +117 -116
  20. package/src/comlink/controller/actions/destroyController.test.ts +38 -13
  21. package/src/comlink/controller/actions/destroyController.ts +11 -15
  22. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +56 -27
  23. package/src/comlink/controller/actions/getOrCreateChannel.ts +37 -35
  24. package/src/comlink/controller/actions/getOrCreateController.test.ts +27 -16
  25. package/src/comlink/controller/actions/getOrCreateController.ts +23 -22
  26. package/src/comlink/controller/actions/releaseChannel.test.ts +37 -13
  27. package/src/comlink/controller/actions/releaseChannel.ts +22 -21
  28. package/src/comlink/controller/comlinkControllerStore.test.ts +65 -36
  29. package/src/comlink/controller/comlinkControllerStore.ts +44 -5
  30. package/src/comlink/node/actions/getOrCreateNode.test.ts +31 -15
  31. package/src/comlink/node/actions/getOrCreateNode.ts +30 -29
  32. package/src/comlink/node/actions/releaseNode.test.ts +75 -55
  33. package/src/comlink/node/actions/releaseNode.ts +19 -21
  34. package/src/comlink/node/comlinkNodeStore.test.ts +6 -11
  35. package/src/comlink/node/comlinkNodeStore.ts +22 -5
  36. package/src/config/authConfig.ts +79 -0
  37. package/src/config/sanityConfig.ts +48 -0
  38. package/src/datasets/datasets.test.ts +2 -2
  39. package/src/datasets/datasets.ts +18 -5
  40. package/src/document/actions.test.ts +22 -10
  41. package/src/document/actions.ts +44 -56
  42. package/src/document/applyDocumentActions.test.ts +96 -36
  43. package/src/document/applyDocumentActions.ts +140 -99
  44. package/src/document/documentStore.test.ts +103 -155
  45. package/src/document/documentStore.ts +247 -238
  46. package/src/document/listen.ts +56 -55
  47. package/src/document/patchOperations.ts +0 -43
  48. package/src/document/permissions.test.ts +25 -12
  49. package/src/document/permissions.ts +11 -4
  50. package/src/document/processActions.test.ts +41 -8
  51. package/src/document/reducers.test.ts +87 -16
  52. package/src/document/reducers.ts +2 -2
  53. package/src/document/sharedListener.test.ts +34 -16
  54. package/src/document/sharedListener.ts +33 -11
  55. package/src/preview/getPreviewState.test.ts +40 -39
  56. package/src/preview/getPreviewState.ts +68 -56
  57. package/src/preview/previewConstants.ts +43 -0
  58. package/src/preview/previewQuery.test.ts +1 -1
  59. package/src/preview/previewQuery.ts +4 -5
  60. package/src/preview/previewStore.test.ts +13 -58
  61. package/src/preview/previewStore.ts +7 -21
  62. package/src/preview/resolvePreview.test.ts +33 -104
  63. package/src/preview/resolvePreview.ts +11 -21
  64. package/src/preview/subscribeToStateAndFetchBatches.test.ts +96 -97
  65. package/src/preview/subscribeToStateAndFetchBatches.ts +85 -81
  66. package/src/preview/util.ts +1 -0
  67. package/src/project/project.test.ts +3 -3
  68. package/src/project/project.ts +28 -5
  69. package/src/projection/getProjectionState.test.ts +188 -72
  70. package/src/projection/getProjectionState.ts +92 -62
  71. package/src/projection/projectionQuery.test.ts +114 -12
  72. package/src/projection/projectionQuery.ts +75 -32
  73. package/src/projection/projectionStore.test.ts +13 -51
  74. package/src/projection/projectionStore.ts +6 -43
  75. package/src/projection/resolveProjection.test.ts +32 -127
  76. package/src/projection/resolveProjection.ts +16 -28
  77. package/src/projection/subscribeToStateAndFetchBatches.test.ts +203 -116
  78. package/src/projection/subscribeToStateAndFetchBatches.ts +140 -85
  79. package/src/projection/types.ts +50 -0
  80. package/src/projection/util.ts +3 -1
  81. package/src/projects/projects.test.ts +13 -4
  82. package/src/projects/projects.ts +6 -1
  83. package/src/query/queryStore.test.ts +10 -47
  84. package/src/query/queryStore.ts +151 -133
  85. package/src/query/queryStoreConstants.ts +2 -0
  86. package/src/store/createActionBinder.test.ts +153 -0
  87. package/src/store/createActionBinder.ts +176 -0
  88. package/src/store/createSanityInstance.test.ts +84 -0
  89. package/src/store/createSanityInstance.ts +124 -0
  90. package/src/store/createStateSourceAction.test.ts +196 -0
  91. package/src/store/createStateSourceAction.ts +260 -0
  92. package/src/store/createStoreInstance.test.ts +81 -0
  93. package/src/store/createStoreInstance.ts +80 -0
  94. package/src/store/createStoreState.test.ts +85 -0
  95. package/src/store/createStoreState.ts +92 -0
  96. package/src/store/defineStore.test.ts +18 -0
  97. package/src/store/defineStore.ts +81 -0
  98. package/src/users/reducers.test.ts +318 -0
  99. package/src/users/reducers.ts +88 -0
  100. package/src/users/types.ts +46 -4
  101. package/src/users/usersConstants.ts +4 -0
  102. package/src/users/usersStore.test.ts +350 -223
  103. package/src/users/usersStore.ts +285 -149
  104. package/src/utils/createFetcherStore.test.ts +6 -7
  105. package/src/utils/createFetcherStore.ts +150 -153
  106. package/src/utils/createGroqSearchFilter.test.ts +75 -0
  107. package/src/utils/createGroqSearchFilter.ts +85 -0
  108. package/src/{common/util.test.ts → utils/hashString.test.ts} +1 -1
  109. package/dist/index.cjs +0 -4888
  110. package/dist/index.cjs.map +0 -1
  111. package/dist/index.d.cts +0 -2121
  112. package/src/auth/fetchLoginUrls.test.ts +0 -163
  113. package/src/auth/fetchLoginUrls.ts +0 -74
  114. package/src/common/createLiveEventSubscriber.test.ts +0 -121
  115. package/src/common/createLiveEventSubscriber.ts +0 -55
  116. package/src/common/types.ts +0 -4
  117. package/src/instance/identity.test.ts +0 -46
  118. package/src/instance/identity.ts +0 -29
  119. package/src/instance/sanityInstance.test.ts +0 -77
  120. package/src/instance/sanityInstance.ts +0 -57
  121. package/src/instance/types.ts +0 -37
  122. package/src/preview/getPreviewProjection.ts +0 -45
  123. package/src/resources/README.md +0 -370
  124. package/src/resources/createAction.test.ts +0 -101
  125. package/src/resources/createAction.ts +0 -44
  126. package/src/resources/createResource.test.ts +0 -112
  127. package/src/resources/createResource.ts +0 -102
  128. package/src/resources/createStateSourceAction.test.ts +0 -114
  129. package/src/resources/createStateSourceAction.ts +0 -83
  130. package/src/resources/createStore.test.ts +0 -67
  131. package/src/resources/createStore.ts +0 -46
  132. package/src/store/createStore.test.ts +0 -108
  133. package/src/store/createStore.ts +0 -106
  134. /package/src/{common/util.ts → utils/hashString.ts} +0 -0
@@ -0,0 +1,176 @@
1
+ import {type SanityConfig} from '../config/sanityConfig'
2
+ import {type SanityInstance} from './createSanityInstance'
3
+ import {createStoreInstance, type StoreInstance} from './createStoreInstance'
4
+ import {type StoreState} from './createStoreState'
5
+ import {type StoreContext, type StoreDefinition} from './defineStore'
6
+
7
+ /**
8
+ * Defines a store action that operates on a specific state type
9
+ */
10
+ export type StoreAction<TState, TParams extends unknown[], TReturn> = (
11
+ context: StoreContext<TState>,
12
+ ...params: TParams
13
+ ) => TReturn
14
+
15
+ /**
16
+ * Represents a store action that has been bound to a specific store instance
17
+ */
18
+ export type BoundStoreAction<_TState, TParams extends unknown[], TReturn> = (
19
+ instance: SanityInstance,
20
+ ...params: TParams
21
+ ) => TReturn
22
+
23
+ /**
24
+ * Creates an action binder function that uses the provided key function
25
+ * to determine how store instances are shared between Sanity instances
26
+ *
27
+ * @param keyFn - Function that generates a key from a Sanity config
28
+ * @returns A function that binds store actions to Sanity instances
29
+ *
30
+ * @remarks
31
+ * Action binders determine how store instances are shared across multiple
32
+ * Sanity instances. The key function determines which instances share state.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * // Create a custom binder that uses a tenant ID for isolation
37
+ * const bindActionByTenant = createActionBinder(config => config.tenantId || 'default')
38
+ *
39
+ * // Use the custom binder with a store definition
40
+ * const getTenantUsers = bindActionByTenant(
41
+ * userStore,
42
+ * ({state}) => state.get().users
43
+ * )
44
+ * ```
45
+ */
46
+ export function createActionBinder(keyFn: (config: SanityConfig) => string) {
47
+ const instanceRegistry = new Map<string, Set<string>>()
48
+ const storeRegistry = new Map<string, StoreInstance<unknown>>()
49
+
50
+ /**
51
+ * Binds a store action to a store definition
52
+ *
53
+ * @param storeDefinition - The store definition
54
+ * @param action - The action to bind
55
+ * @returns A function that executes the action with a Sanity instance
56
+ */
57
+ return function bindAction<TState, TParams extends unknown[], TReturn>(
58
+ storeDefinition: StoreDefinition<TState>,
59
+ action: StoreAction<TState, TParams, TReturn>,
60
+ ): BoundStoreAction<TState, TParams, TReturn> {
61
+ return function boundAction(instance: SanityInstance, ...params: TParams) {
62
+ const keySuffix = keyFn(instance.config)
63
+ const compositeKey = storeDefinition.name + (keySuffix ? `:${keySuffix}` : '')
64
+
65
+ // Get or create instance set for this composite key
66
+ let instances = instanceRegistry.get(compositeKey)
67
+ if (!instances) {
68
+ instances = new Set<string>()
69
+ instanceRegistry.set(compositeKey, instances)
70
+ }
71
+
72
+ // Register instance for disposal tracking
73
+ if (!instances.has(instance.instanceId)) {
74
+ instances.add(instance.instanceId)
75
+ instance.onDispose(() => {
76
+ instances.delete(instance.instanceId)
77
+
78
+ // Clean up when last instance is disposed
79
+ if (instances.size === 0) {
80
+ storeRegistry.get(compositeKey)?.dispose()
81
+ storeRegistry.delete(compositeKey)
82
+ instanceRegistry.delete(compositeKey)
83
+ }
84
+ })
85
+ }
86
+
87
+ // Get or create store instance
88
+ let storeInstance = storeRegistry.get(compositeKey)
89
+ if (!storeInstance) {
90
+ storeInstance = createStoreInstance(instance, storeDefinition)
91
+ storeRegistry.set(compositeKey, storeInstance)
92
+ }
93
+
94
+ // Execute action with store context
95
+ return action({instance, state: storeInstance.state as StoreState<TState>}, ...params)
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Binds an action to a store that's scoped to a specific project and dataset
102
+ *
103
+ * @remarks
104
+ * This creates actions that operate on state isolated to a specific projectId and dataset.
105
+ * Different project/dataset combinations will have separate states.
106
+ *
107
+ * @throws Error if projectId or dataset is missing from the Sanity instance config
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * // Define a store
112
+ * const documentStore = defineStore<DocumentState>({
113
+ * name: 'Document',
114
+ * getInitialState: () => ({ documents: {} }),
115
+ * // ...
116
+ * })
117
+ *
118
+ * // Create dataset-specific actions
119
+ * export const fetchDocument = bindActionByDataset(
120
+ * documentStore,
121
+ * ({instance, state}, documentId) => {
122
+ * // This state is isolated to the specific project/dataset
123
+ * // ...fetch logic...
124
+ * }
125
+ * )
126
+ *
127
+ * // Usage
128
+ * fetchDocument(sanityInstance, 'doc123')
129
+ * ```
130
+ */
131
+ export const bindActionByDataset = createActionBinder(({projectId, dataset}) => {
132
+ if (!projectId || !dataset) {
133
+ throw new Error('This API requires a project ID and dataset configured.')
134
+ }
135
+ return `${projectId}.${dataset}`
136
+ })
137
+
138
+ /**
139
+ * Binds an action to a global store that's shared across all Sanity instances
140
+ *
141
+ * @remarks
142
+ * This creates actions that operate on state shared globally across all Sanity instances.
143
+ * Use this for features like authentication where the state should be the same
144
+ * regardless of which project or dataset is being used.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * // Define a store
149
+ * const authStore = defineStore<AuthState>({
150
+ * name: 'Auth',
151
+ * getInitialState: () => ({
152
+ * user: null,
153
+ * isAuthenticated: false
154
+ * }),
155
+ * // ...
156
+ * })
157
+ *
158
+ * // Create global actions
159
+ * export const getCurrentUser = bindActionGlobally(
160
+ * authStore,
161
+ * ({state}) => state.get().user
162
+ * )
163
+ *
164
+ * export const login = bindActionGlobally(
165
+ * authStore,
166
+ * ({state, instance}, credentials) => {
167
+ * // Login logic that affects global state
168
+ * // ...
169
+ * }
170
+ * )
171
+ *
172
+ * // Usage with any instance
173
+ * getCurrentUser(sanityInstance)
174
+ * ```
175
+ */
176
+ export const bindActionGlobally = createActionBinder(() => 'global')
@@ -0,0 +1,84 @@
1
+ import {describe, expect, it, vi} from 'vitest'
2
+
3
+ import {createSanityInstance} from './createSanityInstance'
4
+
5
+ describe('createSanityInstance', () => {
6
+ it('should create an instance with a unique instanceId and given config', () => {
7
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
8
+ expect(typeof instance.instanceId).toBe('string')
9
+ expect(instance.config).toEqual({projectId: 'proj1', dataset: 'ds1'})
10
+ expect(instance.isDisposed()).toBe(false)
11
+ })
12
+
13
+ it('should dispose an instance and call onDispose callbacks', () => {
14
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
15
+ const callback = vi.fn()
16
+ instance.onDispose(callback)
17
+ instance.dispose()
18
+ expect(instance.isDisposed()).toBe(true)
19
+ expect(callback).toHaveBeenCalled()
20
+ })
21
+
22
+ it('should not call onDispose callbacks more than once when disposed multiple times', () => {
23
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
24
+ const callback = vi.fn()
25
+ instance.onDispose(callback)
26
+ instance.dispose()
27
+ instance.dispose()
28
+ expect(callback).toHaveBeenCalledTimes(1)
29
+ })
30
+
31
+ it('should create a child instance with merged config and correct parent', () => {
32
+ const parent = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
33
+ const child = parent.createChild({dataset: 'ds2'})
34
+ expect(child.config).toEqual({projectId: 'proj1', dataset: 'ds2'})
35
+ expect(child.getParent()).toBe(parent)
36
+ })
37
+
38
+ it('should match an instance in the hierarchy using match', () => {
39
+ // three-level hierarchy
40
+ const grandparent = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
41
+ const parent = grandparent.createChild({projectId: 'proj2'})
42
+ const child = parent.createChild({dataset: 'ds2'})
43
+
44
+ expect(child.config).toEqual({projectId: 'proj2', dataset: 'ds2'})
45
+ expect(parent.config).toEqual({projectId: 'proj2', dataset: 'ds1'})
46
+
47
+ expect(child.match({dataset: 'ds2'})).toBe(child)
48
+ expect(child.match({projectId: 'proj2'})).toBe(child)
49
+ expect(child.match({projectId: 'proj1'})).toBe(grandparent)
50
+ expect(parent.match({projectId: 'proj1'})).toBe(grandparent)
51
+ expect(grandparent.match({projectId: 'proj1'})).toBe(grandparent)
52
+ })
53
+
54
+ it('should match `undefined` when the desired resource ID should not be set on an instance', () => {
55
+ const noProjectOrDataset = createSanityInstance()
56
+ const noDataset = noProjectOrDataset.createChild({projectId: 'proj1'})
57
+ const leaf = noDataset.createChild({dataset: 'ds1'})
58
+
59
+ // no keys means anything (in this case, self) will match
60
+ expect(leaf.match({})).toBe(leaf)
61
+
62
+ // `[resourceId]: undefined` means match an instance with no dataset set
63
+ expect(leaf.match({dataset: undefined})).toBe(noDataset)
64
+ expect(noDataset.match({dataset: undefined})).toBe(noDataset)
65
+ expect(leaf.match({projectId: undefined})).toBe(noProjectOrDataset)
66
+ expect(noDataset.match({projectId: undefined})).toBe(noProjectOrDataset)
67
+ expect(noProjectOrDataset.match({projectId: undefined})).toBe(noProjectOrDataset)
68
+ })
69
+
70
+ it('should return undefined when no match is found', () => {
71
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
72
+ expect(instance.match({dataset: 'non-existent'})).toBeUndefined()
73
+ })
74
+
75
+ it('should inherit and merge auth config', () => {
76
+ const parent = createSanityInstance({
77
+ projectId: 'proj1',
78
+ dataset: 'ds1',
79
+ auth: {apiHost: 'api.sanity.work'},
80
+ })
81
+ const child = parent.createChild({auth: {token: 'my-token'}})
82
+ expect(child.config.auth).toEqual({apiHost: 'api.sanity.work', token: 'my-token'})
83
+ })
84
+ })
@@ -0,0 +1,124 @@
1
+ import {pick} from 'lodash-es'
2
+
3
+ import {type SanityConfig} from '../config/sanityConfig'
4
+ import {insecureRandomId} from '../utils/ids'
5
+
6
+ /**
7
+ * Represents a Sanity.io resource instance with its own configuration and lifecycle
8
+ * @remarks Instances form a hierarchy through parent/child relationships
9
+ *
10
+ * @public
11
+ */
12
+ export interface SanityInstance {
13
+ /**
14
+ * Unique identifier for this instance
15
+ * @remarks Generated using crypto.randomUUID()
16
+ */
17
+ readonly instanceId: string
18
+
19
+ /**
20
+ * Resolved configuration for this instance
21
+ * @remarks Merges values from parent instances where appropriate
22
+ */
23
+ readonly config: SanityConfig
24
+
25
+ /**
26
+ * Checks if the instance has been disposed
27
+ * @returns true if dispose() has been called
28
+ */
29
+ isDisposed(): boolean
30
+
31
+ /**
32
+ * Disposes the instance and cleans up associated resources
33
+ * @remarks Triggers all registered onDispose callbacks
34
+ */
35
+ dispose(): void
36
+
37
+ /**
38
+ * Registers a callback to be invoked when the instance is disposed
39
+ * @param cb - Callback to execute on disposal
40
+ * @returns Function to unsubscribe the callback
41
+ */
42
+ onDispose(cb: () => void): () => void
43
+
44
+ /**
45
+ * Gets the parent instance in the hierarchy
46
+ * @returns Parent instance or undefined if this is the root
47
+ */
48
+ getParent(): SanityInstance | undefined
49
+
50
+ /**
51
+ * Creates a child instance with merged configuration
52
+ * @param config - Configuration to merge with parent values
53
+ * @remarks Child instances inherit parent configuration but can override values
54
+ */
55
+ createChild(config: SanityConfig): SanityInstance
56
+
57
+ /**
58
+ * Traverses the instance hierarchy to find the first instance whose configuration
59
+ * matches the given target config using a shallow comparison.
60
+ * @param targetConfig - A partial configuration object containing key-value pairs to match.
61
+ * @returns The first matching instance or undefined if no match is found.
62
+ */
63
+ match(targetConfig: Partial<SanityConfig>): SanityInstance | undefined
64
+ }
65
+
66
+ /**
67
+ * Creates a new Sanity resource instance
68
+ * @param config - Configuration for the instance (optional)
69
+ * @returns A configured SanityInstance
70
+ * @remarks When creating child instances, configurations are merged with parent values
71
+ *
72
+ * @public
73
+ */
74
+ export function createSanityInstance(config: SanityConfig = {}): SanityInstance {
75
+ const instanceId = crypto.randomUUID()
76
+ const disposeListeners = new Map<string, () => void>()
77
+ const disposed = {current: false}
78
+
79
+ const instance: SanityInstance = {
80
+ instanceId,
81
+ config,
82
+ isDisposed: () => disposed.current,
83
+ dispose: () => {
84
+ if (disposed.current) return
85
+ disposed.current = true
86
+ disposeListeners.forEach((listener) => listener())
87
+ disposeListeners.clear()
88
+ },
89
+ onDispose: (cb) => {
90
+ const listenerId = insecureRandomId()
91
+ disposeListeners.set(listenerId, cb)
92
+ return () => {
93
+ disposeListeners.delete(listenerId)
94
+ }
95
+ },
96
+ getParent: () => undefined,
97
+ createChild: (next) =>
98
+ Object.assign(
99
+ createSanityInstance({
100
+ ...config,
101
+ ...next,
102
+ ...(config.auth === next.auth
103
+ ? config.auth
104
+ : config.auth && next.auth && {auth: {...config.auth, ...next.auth}}),
105
+ }),
106
+ {getParent: () => instance},
107
+ ),
108
+ match: (targetConfig) => {
109
+ if (
110
+ Object.entries(pick(targetConfig, 'auth', 'projectId', 'dataset')).every(
111
+ ([key, value]) => config[key as keyof SanityConfig] === value,
112
+ )
113
+ ) {
114
+ return instance
115
+ }
116
+
117
+ const parent = instance.getParent()
118
+ if (parent) return parent.match(targetConfig)
119
+ return undefined
120
+ },
121
+ }
122
+
123
+ return instance
124
+ }
@@ -0,0 +1,196 @@
1
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {createSanityInstance, type SanityInstance} from './createSanityInstance'
4
+ import {createStateSourceAction, type SelectorContext} from './createStateSourceAction'
5
+ import {createStoreState, type StoreState} from './createStoreState'
6
+
7
+ interface CountStoreState {
8
+ count: number
9
+ items: string[]
10
+ }
11
+
12
+ describe('createStateSourceAction', () => {
13
+ let state: StoreState<CountStoreState>
14
+ let instance: SanityInstance
15
+
16
+ beforeEach(() => {
17
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
18
+ state = createStoreState({count: 0, items: [] as string[]}, {name: 'test-store'})
19
+ })
20
+
21
+ it('should create a source that provides current state through getCurrent', () => {
22
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
23
+ const action = createStateSourceAction(selector)
24
+ const source = action({state, instance})
25
+
26
+ expect(source.getCurrent()).toBe(0)
27
+ state.set('test', {count: 5})
28
+ expect(source.getCurrent()).toBe(5)
29
+ })
30
+
31
+ it('should call onStoreChanged when state changes', () => {
32
+ const onStoreChanged = vi.fn()
33
+ const source = createStateSourceAction({
34
+ selector: ({state: s}: SelectorContext<CountStoreState>) => s.count,
35
+ isEqual: (a, b) => a === b,
36
+ })({state, instance})
37
+
38
+ const unsubscribe = source.subscribe(onStoreChanged)
39
+
40
+ state.set('inc', (s) => ({count: s.count + 1}))
41
+ expect(onStoreChanged).toHaveBeenCalledTimes(1)
42
+
43
+ state.set('noop', (s) => s)
44
+ expect(onStoreChanged).toHaveBeenCalledTimes(1) // No change
45
+
46
+ unsubscribe()
47
+ state.set('inc2', (s) => ({count: s.count + 1}))
48
+ expect(onStoreChanged).toHaveBeenCalledTimes(1)
49
+ })
50
+
51
+ it('should call onSubscribe handler when subscription starts', () => {
52
+ const onSubscribe = vi.fn(() => () => {})
53
+ const source = createStateSourceAction({
54
+ selector: ({state: s}: SelectorContext<CountStoreState>) => s.items,
55
+ onSubscribe,
56
+ })({state, instance})
57
+
58
+ const unsubscribe = source.subscribe()
59
+ expect(onSubscribe).toHaveBeenCalledWith(
60
+ expect.objectContaining({state, instance}),
61
+ // No params in this case
62
+ )
63
+
64
+ unsubscribe()
65
+ })
66
+
67
+ it('should support parameterized selectors', () => {
68
+ const action = createStateSourceAction({
69
+ selector: ({state: s}: SelectorContext<CountStoreState>, index: number) => s.items[index],
70
+ })
71
+ const source = action({state, instance}, 0)
72
+
73
+ state.set('add', {items: ['first']})
74
+ expect(source.getCurrent()).toBe('first')
75
+ })
76
+
77
+ it('should handle selector errors in observable', () => {
78
+ const error = new Error('Selector failed')
79
+ const source = createStateSourceAction({
80
+ selector: () => {
81
+ throw error
82
+ },
83
+ })({state, instance})
84
+
85
+ const errorHandler = vi.fn()
86
+ source.observable.subscribe({error: errorHandler})
87
+
88
+ state.set('trigger', {count: 1})
89
+ expect(errorHandler).toHaveBeenCalledWith(error)
90
+ })
91
+
92
+ it('should use custom equality check', () => {
93
+ const isEqual = vi.fn((a: number[], b: number[]) => a.length === b.length)
94
+ const source = createStateSourceAction({
95
+ selector: ({state: s}: SelectorContext<CountStoreState>) => s.items.map((i) => i.length),
96
+ isEqual,
97
+ })({state, instance})
98
+
99
+ const onChange = vi.fn()
100
+ source.subscribe(onChange)
101
+
102
+ // Same length, different contents
103
+ state.set('add1', {items: ['a']})
104
+ state.set('add2', {items: ['b']})
105
+
106
+ expect(isEqual).toHaveBeenCalledTimes(2)
107
+ expect(onChange).toHaveBeenCalledTimes(1) // Only first change
108
+ })
109
+
110
+ it('should cleanup onSubscribe when unsubscribed', () => {
111
+ const cleanup = vi.fn()
112
+ const source = createStateSourceAction({
113
+ selector: ({state: s}: SelectorContext<CountStoreState>) => s.count,
114
+ onSubscribe: () => cleanup,
115
+ })({state, instance})
116
+
117
+ const unsubscribe = source.subscribe()
118
+ unsubscribe()
119
+ expect(cleanup).toHaveBeenCalledTimes(1)
120
+ })
121
+
122
+ it('should share observable between subscribers', () => {
123
+ const source = createStateSourceAction(
124
+ ({state: s}: SelectorContext<CountStoreState>) => s.count,
125
+ )({
126
+ state,
127
+ instance,
128
+ })
129
+
130
+ const subscriber1 = vi.fn()
131
+ const subscriber2 = vi.fn()
132
+
133
+ const subscription1 = source.observable.subscribe(subscriber1)
134
+ const subscription2 = source.observable.subscribe(subscriber2)
135
+
136
+ state.set('inc', {count: 1})
137
+
138
+ expect(subscriber1).toHaveBeenCalledWith(1)
139
+ expect(subscriber2).toHaveBeenCalledWith(1)
140
+
141
+ subscription1.unsubscribe()
142
+ subscription2.unsubscribe()
143
+ })
144
+
145
+ it('should cache selector context per state object', () => {
146
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
147
+ const source = createStateSourceAction(selector)({state, instance})
148
+
149
+ // Initial call creates context
150
+ expect(source.getCurrent()).toBe(0)
151
+ expect(selector).toHaveBeenCalledTimes(1)
152
+ const firstContext = selector.mock.calls[0][0]
153
+
154
+ // Subsequent call with same state reuses context
155
+ expect(source.getCurrent()).toBe(0)
156
+ expect(selector).toHaveBeenCalledTimes(2)
157
+ expect(selector.mock.calls[1][0]).toBe(firstContext)
158
+
159
+ // After state change, new context is created
160
+ state.set('update1', {count: 1})
161
+ expect(source.getCurrent()).toBe(1)
162
+ expect(selector).toHaveBeenCalledTimes(3)
163
+ const secondContext = selector.mock.calls[2][0]
164
+ expect(secondContext).not.toBe(firstContext)
165
+
166
+ // Another call with same state reuses new context
167
+ expect(source.getCurrent()).toBe(1)
168
+ expect(selector).toHaveBeenCalledTimes(4)
169
+ expect(selector.mock.calls[3][0]).toBe(secondContext)
170
+
171
+ // State change again, new context
172
+ state.set('update2', {count: 2})
173
+ expect(source.getCurrent()).toBe(2)
174
+ expect(selector).toHaveBeenCalledTimes(5)
175
+ const thirdContext = selector.mock.calls[4][0]
176
+ expect(thirdContext).not.toBe(secondContext)
177
+ })
178
+
179
+ // New test: distinct contexts for same state with different instance
180
+ it('should create distinct contexts for same state with different instance', () => {
181
+ const secondInstance = createSanityInstance({projectId: 'test2', dataset: 'test2'})
182
+ const selector = vi.fn(({state: s}: SelectorContext<CountStoreState>) => s.count)
183
+
184
+ const source1 = createStateSourceAction(selector)({state, instance})
185
+ source1.getCurrent()
186
+
187
+ const source2 = createStateSourceAction(selector)({state, instance: secondInstance})
188
+ source2.getCurrent()
189
+
190
+ const context1 = selector.mock.calls[0][0]
191
+ const context2 = selector.mock.calls[1][0]
192
+ expect(context1).not.toBe(context2)
193
+ expect(context1.instance).toBe(instance)
194
+ expect(context2.instance).toBe(secondInstance)
195
+ })
196
+ })