on-zero 0.4.36 → 0.4.37

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 (83) hide show
  1. package/dist/cjs/combineZeroClients.native.js.map +1 -1
  2. package/dist/cjs/createUseQuery.cjs +1 -96
  3. package/dist/cjs/createUseQuery.native.js +1 -132
  4. package/dist/cjs/createUseQuery.native.js.map +1 -1
  5. package/dist/cjs/createUseQueryDirect.cjs +228 -0
  6. package/dist/cjs/createUseQueryDirect.native.js +334 -0
  7. package/dist/cjs/createUseQueryDirect.native.js.map +1 -0
  8. package/dist/cjs/createZeroClient.cjs +18 -7
  9. package/dist/cjs/createZeroClient.native.js +18 -7
  10. package/dist/cjs/createZeroClient.native.js.map +1 -1
  11. package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
  12. package/dist/cjs/index.cjs +2 -2
  13. package/dist/cjs/index.native.js +2 -2
  14. package/dist/cjs/index.native.js.map +1 -1
  15. package/dist/cjs/multi.cjs +38 -0
  16. package/dist/cjs/multi.native.js +41 -0
  17. package/dist/cjs/multi.native.js.map +1 -0
  18. package/dist/cjs/multiInstanceNested.test.cjs +92 -12
  19. package/dist/cjs/multiInstanceNested.test.native.js +108 -12
  20. package/dist/cjs/multiInstanceNested.test.native.js.map +1 -1
  21. package/dist/cjs/testSetup.cjs +26 -0
  22. package/dist/cjs/testSetup.native.js +32 -0
  23. package/dist/cjs/testSetup.native.js.map +1 -0
  24. package/dist/esm/combineZeroClients.mjs.map +1 -1
  25. package/dist/esm/combineZeroClients.native.js.map +1 -1
  26. package/dist/esm/createUseQuery.mjs +2 -97
  27. package/dist/esm/createUseQuery.mjs.map +1 -1
  28. package/dist/esm/createUseQuery.native.js +2 -133
  29. package/dist/esm/createUseQuery.native.js.map +1 -1
  30. package/dist/esm/createUseQueryDirect.mjs +203 -0
  31. package/dist/esm/createUseQueryDirect.mjs.map +1 -0
  32. package/dist/esm/createUseQueryDirect.native.js +306 -0
  33. package/dist/esm/createUseQueryDirect.native.js.map +1 -0
  34. package/dist/esm/createZeroClient.mjs +18 -8
  35. package/dist/esm/createZeroClient.mjs.map +1 -1
  36. package/dist/esm/createZeroClient.native.js +18 -8
  37. package/dist/esm/createZeroClient.native.js.map +1 -1
  38. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  39. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  40. package/dist/esm/index.js +2 -3
  41. package/dist/esm/index.js.map +1 -1
  42. package/dist/esm/index.mjs +2 -3
  43. package/dist/esm/index.mjs.map +1 -1
  44. package/dist/esm/index.native.js +2 -3
  45. package/dist/esm/index.native.js.map +1 -1
  46. package/dist/esm/multi.mjs +12 -0
  47. package/dist/esm/multi.mjs.map +1 -0
  48. package/dist/esm/multi.native.js +12 -0
  49. package/dist/esm/multi.native.js.map +1 -0
  50. package/dist/esm/multiInstanceNested.test.mjs +85 -5
  51. package/dist/esm/multiInstanceNested.test.mjs.map +1 -1
  52. package/dist/esm/multiInstanceNested.test.native.js +101 -5
  53. package/dist/esm/multiInstanceNested.test.native.js.map +1 -1
  54. package/dist/esm/testSetup.mjs +27 -0
  55. package/dist/esm/testSetup.mjs.map +1 -0
  56. package/dist/esm/testSetup.native.js +30 -0
  57. package/dist/esm/testSetup.native.js.map +1 -0
  58. package/package.json +8 -2
  59. package/readme.md +10 -8
  60. package/src/combineZeroClients.tsx +4 -6
  61. package/src/createUseQuery.tsx +2 -189
  62. package/src/createUseQueryDirect.tsx +307 -0
  63. package/src/createZeroClient.tsx +65 -32
  64. package/src/helpers/recoverZeroClient.test.ts +8 -2
  65. package/src/index.ts +6 -2
  66. package/src/multi.ts +24 -0
  67. package/src/multiInstanceNested.test.tsx +79 -4
  68. package/src/testSetup.ts +26 -0
  69. package/types/combineZeroClients.d.ts.map +1 -1
  70. package/types/createUseQuery.d.ts +4 -15
  71. package/types/createUseQuery.d.ts.map +1 -1
  72. package/types/createUseQueryDirect.d.ts +29 -0
  73. package/types/createUseQueryDirect.d.ts.map +1 -0
  74. package/types/createZeroClient.d.ts +51 -5
  75. package/types/createZeroClient.d.ts.map +1 -1
  76. package/types/index.d.ts +1 -2
  77. package/types/index.d.ts.map +1 -1
  78. package/types/multi.d.ts +6 -0
  79. package/types/multi.d.ts.map +1 -0
  80. package/types/multiInstanceNested.test.d.ts.map +1 -1
  81. package/types/testSetup.d.ts +1 -0
  82. package/types/testSetup.d.ts.map +1 -0
  83. package/vitest.config.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "on-zero",
3
- "version": "0.4.36",
3
+ "version": "0.4.37",
4
4
  "description": "A typed layer over @rocicorp/zero with queries, mutations, and permissions",
5
5
  "sideEffects": false,
6
6
  "source": "src/index.ts",
@@ -65,6 +65,12 @@
65
65
  "import": "./dist/esm/vite-plugin.mjs",
66
66
  "require": "./dist/cjs/vite-plugin.cjs"
67
67
  },
68
+ "./multi": {
69
+ "react-native": "./dist/esm/multi.native.js",
70
+ "types": "./types/multi.d.ts",
71
+ "import": "./dist/esm/multi.mjs",
72
+ "require": "./dist/cjs/multi.cjs"
73
+ },
68
74
  "./drizzle": {
69
75
  "types": "./types/createSchemaFromDrizzle.d.ts",
70
76
  "import": "./dist/esm/createSchemaFromDrizzle.mjs",
@@ -77,7 +83,7 @@
77
83
  }
78
84
  },
79
85
  "dependencies": {
80
- "@take-out/helpers": "0.4.36",
86
+ "@take-out/helpers": "0.4.37",
81
87
  "chokidar": "^4.0.3",
82
88
  "citty": "^0.1.6",
83
89
  "valibot": "^1.1.0"
package/readme.md CHANGED
@@ -526,9 +526,10 @@ exactly one instance — duplicate claims throw at create time. then combine
526
526
  them into one consumer surface:
527
527
 
528
528
  ```tsx
529
- import { combineZeroClients, createZeroClient } from 'on-zero'
529
+ import { createZeroClient } from 'on-zero'
530
+ import { combineZeroClients, createZeroClientWithDirectQueries } from 'on-zero/multi'
530
531
 
531
- const control = createZeroClient({
532
+ const control = createZeroClientWithDirectQueries({
532
533
  schema,
533
534
  models: controlModels,
534
535
  groupedQueries: controlQueries,
@@ -551,8 +552,8 @@ export const { useQuery, zero, run, preload, getQuery, zeroEvents } =
551
552
  // client passed to combineZeroClients (or the `inner` option) must be the
552
553
  // INNERMOST provider — zero-react's useQuery resolves the nearest provider's
553
554
  // instance from context, so only the inner instance rides that path. all
554
- // other instances' useQuery/usePermission go through a context-free direct
555
- // materialize on their own mounted zero.
555
+ // other instances must opt into the context-free direct adapter through
556
+ // createZeroClientWithDirectQueries.
556
557
  ;<control.ProvideZero server={controlUrl} userID={user.id}>
557
558
  <project.ProvideZero server={projectUrl} userID={`${user.id}:${projectId}`}>
558
559
  <App />
@@ -565,11 +566,12 @@ constraints:
565
566
  - each instance needs its own client-group identity (separate `userID` /
566
567
  storage key / server url) — never swap the backing namespace under a live
567
568
  instance.
569
+ - most apps should use plain `createZeroClient`; the `on-zero/multi` entrypoint
570
+ is only for this nested-provider edge case.
568
571
  - give the INNER slot to the instance owning the bulk of the subscriptions —
569
- inner queries keep zero-react's viewStore dedup + ttl. outer instances use
570
- the direct path, which has no cross-component view dedup (each hook
571
- materializes its own view), so keep outer instances on bounded, tiny
572
- queries (current user, settings, directories).
572
+ inner queries use zero-react's native context path. outer instances use the
573
+ direct adapter on their own mounted zero, so keep those instances on bounded,
574
+ low-fanout queries (current user, settings, directories).
573
575
  - a mutator may only read/write tables owned by its own instance. its
574
576
  transaction runs on that instance alone; cross-instance writes are not
575
577
  detectable at registration and will silently miss the other store.
@@ -19,12 +19,10 @@ import type { ReactNode } from 'react'
19
19
  // provider is mounted INNERMOST — may use the upstream context path. by
20
20
  // default that is the LAST client passed here (override via the `inner`
21
21
  // option); the first client is primary and its provider is expected OUTER.
22
- // the inner instance's queries ride zero-react's useQuery unchanged, keeping
23
- // viewStore dedup + ttl give that slot to the instance owning the bulk of
24
- // the subscriptions. every other instance's useQuery/usePermission route
25
- // through the context-free direct hooks (useQueryDirect), which materialize
26
- // on the owning instance but skip view dedup — keep those instances on
27
- // bounded, tiny queries.
22
+ // the inner instance's queries ride zero-react's useQuery unchanged. every
23
+ // other instance must be created through `createZeroClientWithDirectQueries`
24
+ // from `on-zero/multi`, which opts into the context-free adapter for this
25
+ // nested-provider edge case. keep those non-inner instances bounded.
28
26
 
29
27
  type ControlQueriesProps = {
30
28
  children: ReactNode
@@ -1,13 +1,5 @@
1
1
  import { useQuery as zeroUseQuery } from '@rocicorp/zero/react'
2
- import { useEmitterValue, type Emitter } from '@take-out/helpers'
3
- import {
4
- useContext,
5
- useEffect,
6
- useMemo,
7
- useRef,
8
- useSyncExternalStore,
9
- type Context,
10
- } from 'react'
2
+ import { useContext, useMemo, useRef, type Context } from 'react'
11
3
 
12
4
  import { useZeroDebug } from './helpers/useZeroDebug'
13
5
  import { resolveQuery, type PlainQueryFn } from './resolveQuery'
@@ -50,7 +42,7 @@ export type UseQueryHook<Schema extends ZeroSchema> = {
50
42
  const EMPTY_RESPONSE = [null, { type: 'unknown' }] as never
51
43
 
52
44
  // determine if useQuery-style args are (fn, params, options) or (fn, options)
53
- function parseUseQueryArgs(paramsOrOptions: any, optionsArg: any) {
45
+ export function parseUseQueryArgs(paramsOrOptions: any, optionsArg: any) {
54
46
  const hasParams =
55
47
  optionsArg !== undefined ||
56
48
  (paramsOrOptions &&
@@ -105,182 +97,3 @@ export function createUseQuery<Schema extends ZeroSchema>({
105
97
 
106
98
  return useQuery as UseQueryHook<Schema>
107
99
  }
108
-
109
- // --- direct (context-free) query path -------------------------------------
110
-
111
- // zero-react's useQuery resolves its zero instance via useZero() → react
112
- // context, i.e. the NEAREST mounted ZeroProvider. with multiple client
113
- // instances nested on one page, every context-path query would materialize
114
- // against the innermost provider's instance regardless of which instance owns
115
- // the query. this hook is the context-free counterpart: it materializes the
116
- // resolved query directly on the owning instance's mounted zero.
117
- //
118
- // tradeoffs vs the context path (acceptable for the bounded, tiny queries the
119
- // non-inner instances own — see combineZeroClients):
120
- // - no cross-component view dedup (zero-react's viewStore): each hook
121
- // instance materializes its own view.
122
- // - no strict-mode double-render protection: a discarded render's view is
123
- // not destroyed until the owning zero closes.
124
-
125
- const DISABLED_SUBSCRIBE = () => () => {}
126
- // upstream zero-react returns [undefined, {type:'unknown'}] when disabled
127
- const DISABLED_SNAPSHOT: readonly [undefined, { type: 'unknown' }] = [
128
- undefined,
129
- { type: 'unknown' },
130
- ]
131
- const getDisabledSnapshot = () => DISABLED_SNAPSHOT
132
-
133
- type DirectView = {
134
- subscribe: (notify: () => void) => () => void
135
- getSnapshot: () => readonly [unknown, { type: string }]
136
- destroy: () => void
137
- }
138
-
139
- // method syntax for bivariant param checks — the real Zero['materialize']
140
- // generic signature must remain assignable to this structural slice. ttl is
141
- // untyped pass-through here, matching the context path (zeroUseQuery's
142
- // options are equally unchecked against upstream's TTL type).
143
- type MaterializableZero = {
144
- materialize(
145
- query: any,
146
- options?: { ttl?: any },
147
- ): {
148
- addListener(cb: (data: any, resultType: string) => void): void
149
- destroy(): void
150
- }
151
- }
152
-
153
- function createDirectView(
154
- getZero: () => MaterializableZero | null,
155
- queryRequest: unknown,
156
- ttl: UseQueryOptions['ttl'],
157
- ): DirectView {
158
- let snapshot: readonly [unknown, { type: string }] = DISABLED_SNAPSHOT
159
- const listeners = new Set<() => void>()
160
- let view: { destroy(): void } | null = null
161
-
162
- const materialize = () => {
163
- const zero = getZero()
164
- if (!zero) return
165
- // zero.materialize accepts a QueryRequest (it applies the instance context
166
- // itself) — the same public api the upstream ViewWrapper uses
167
- const next = zero.materialize(queryRequest, ttl === undefined ? undefined : { ttl })
168
- view = next
169
-
170
- // addListener fires immediately with current data, so the snapshot is
171
- // populated before react's first getSnapshot. data must be cloned: the
172
- // underlying view mutates it in place, and useSyncExternalStore requires
173
- // immutable snapshots to detect changes.
174
- next.addListener((data, resultType) => {
175
- snapshot = [
176
- data === undefined ? undefined : structuredClone(data),
177
- { type: resultType },
178
- ]
179
- for (const listener of listeners) {
180
- listener()
181
- }
182
- })
183
- }
184
-
185
- // eager: snapshot is warm before react's first getSnapshot
186
- materialize()
187
-
188
- return {
189
- subscribe: (notify) => {
190
- listeners.add(notify)
191
- // react runs effect cleanup + re-setup with unchanged deps under
192
- // StrictMode double-invoke and suspense hide/reveal — the cleanup
193
- // destroyed the view while this memoized store survived, so the
194
- // resubscribe must re-materialize or the snapshot freezes forever
195
- if (view === null) {
196
- materialize()
197
- }
198
- return () => listeners.delete(notify)
199
- },
200
- getSnapshot: () => snapshot,
201
- destroy: () => {
202
- const current = view
203
- view = null
204
- if (!current) return
205
- // identity rotation closes the owning zero before react runs this
206
- // cleanup (the version bump that swaps the view is post-commit) — the
207
- // closed instance already removed the view's ttl connection record, so
208
- // zero's destroy asserts ("Connection not found"). destroy-after-close
209
- // is semantically a no-op; swallow it instead of killing the route.
210
- try {
211
- current.destroy()
212
- } catch {
213
- // instance closed first — nothing left to tear down
214
- }
215
- },
216
- }
217
- }
218
-
219
- export function createUseQueryDirect<Schema extends ZeroSchema>({
220
- DisabledContext,
221
- customQueries,
222
- getZero,
223
- zeroVersion,
224
- }: {
225
- DisabledContext: Context<QueryControlMode>
226
- customQueries: AnyQueryRegistry
227
- // the owning instance's mounted zero (null before its provider mounts)
228
- getZero: () => MaterializableZero | null
229
- // bumps after commit whenever the mounted zero changes (mount, rotation)
230
- zeroVersion: Emitter<number>
231
- }): UseQueryHook<Schema> {
232
- function useQueryDirect(...args: any[]): any {
233
- const disableMode = useContext(DisabledContext)
234
- const lastRef = useRef<any>(EMPTY_RESPONSE)
235
- const [fn, paramsOrOptions, optionsArg] = args
236
-
237
- const version = useEmitterValue(zeroVersion)
238
- const { params, options } = parseUseQueryArgs(paramsOrOptions, optionsArg)
239
-
240
- let enabled = true
241
- let ttl: UseQueryOptions['ttl']
242
- if (typeof options === 'boolean') {
243
- enabled = options
244
- } else if (options) {
245
- enabled = options.enabled !== false
246
- ttl = options.ttl
247
- }
248
-
249
- // key the view by VALUE (serialized params), not params object identity —
250
- // there is no view dedup on this path, so an unstable inline params object
251
- // must not re-materialize every render
252
- const paramsKey = params === undefined ? '' : JSON.stringify(params)
253
-
254
- const view = useMemo((): DirectView | null => {
255
- if (!enabled || !getZero()) return null
256
- const queryRequest = resolveQuery({ customQueries, fn, params })
257
- return createDirectView(getZero, queryRequest, ttl)
258
- // params is keyed by paramsKey; version re-materializes on a new zero
259
- // eslint-disable-next-line react-hooks/exhaustive-deps
260
- }, [fn, paramsKey, enabled, ttl, version])
261
-
262
- useEffect(() => {
263
- if (!view) return
264
- return () => view.destroy()
265
- }, [view])
266
-
267
- const out = useSyncExternalStore(
268
- view ? view.subscribe : DISABLED_SUBSCRIBE,
269
- view ? view.getSnapshot : getDisabledSnapshot,
270
- view ? view.getSnapshot : getDisabledSnapshot,
271
- )
272
-
273
- if (!disableMode) {
274
- lastRef.current = out
275
- return out
276
- }
277
-
278
- if (disableMode === 'last-value') {
279
- return lastRef.current
280
- }
281
-
282
- return EMPTY_RESPONSE
283
- }
284
-
285
- return useQueryDirect as UseQueryHook<Schema>
286
- }
@@ -0,0 +1,307 @@
1
+ import {
2
+ addContextToQuery,
3
+ asQueryInternals,
4
+ DEFAULT_TTL_MS,
5
+ deepClone,
6
+ } from '@rocicorp/zero/bindings'
7
+ import { useEmitterValue, type Emitter } from '@take-out/helpers'
8
+ import { useContext, useMemo, useRef, useSyncExternalStore, type Context } from 'react'
9
+
10
+ import {
11
+ parseUseQueryArgs,
12
+ type QueryControlMode,
13
+ type UseQueryHook,
14
+ type UseQueryOptions,
15
+ } from './createUseQuery'
16
+ import { resolveQuery } from './resolveQuery'
17
+
18
+ import type {
19
+ AnyQueryRegistry,
20
+ ReadonlyJSONValue,
21
+ Schema as ZeroSchema,
22
+ } from '@rocicorp/zero'
23
+
24
+ // optional multi-instance adapter. normal apps should use zero-react's native
25
+ // useQuery path via createZeroClient; this exists only for nested providers
26
+ // where a non-innermost instance cannot be selected through react context.
27
+
28
+ const EMPTY_RESPONSE = [null, { type: 'unknown' }] as never
29
+ const DISABLED_SUBSCRIBE = () => () => {}
30
+ const DISABLED_SNAPSHOT: readonly [undefined, { type: 'unknown' }] = [
31
+ undefined,
32
+ { type: 'unknown' },
33
+ ]
34
+ const getDisabledSnapshot = () => DISABLED_SNAPSHOT
35
+
36
+ type DirectSnapshot = readonly [unknown, { type: string }]
37
+
38
+ type DirectView = {
39
+ subscribe: (notify: () => void) => () => void
40
+ getSnapshot: () => DirectSnapshot
41
+ }
42
+
43
+ export type MaterializableZero = {
44
+ clientID: string
45
+ context: unknown
46
+ materialize(
47
+ query: any,
48
+ options?: { ttl?: any },
49
+ ): {
50
+ addListener(
51
+ cb: (data: any, resultType: string, error?: DirectQueryError) => void,
52
+ ): void
53
+ destroy(): void
54
+ updateTTL(ttl: any): void
55
+ }
56
+ }
57
+
58
+ export type CreateUseQueryDirect<Schema extends ZeroSchema> = (props: {
59
+ DisabledContext: Context<QueryControlMode>
60
+ customQueries: AnyQueryRegistry
61
+ getZero: () => MaterializableZero | null
62
+ zeroVersion: Emitter<number>
63
+ }) => UseQueryHook<Schema>
64
+
65
+ type DirectResultType = 'unknown' | 'complete' | 'error'
66
+ type DirectQueryError = {
67
+ error: 'app' | 'parse'
68
+ message?: string
69
+ details?: unknown
70
+ }
71
+
72
+ const emptyArray: readonly unknown[] = []
73
+ const resultTypeUnknown = { type: 'unknown' } as const
74
+ const resultTypeComplete = { type: 'complete' } as const
75
+ const resultTypeError = { type: 'error' } as const
76
+ const emptySnapshotSingularUnknown: DirectSnapshot = [undefined, resultTypeUnknown]
77
+ const emptySnapshotSingularComplete: DirectSnapshot = [undefined, resultTypeComplete]
78
+ const emptySnapshotSingularError: DirectSnapshot = [undefined, resultTypeError]
79
+ const emptySnapshotPluralUnknown: DirectSnapshot = [emptyArray, resultTypeUnknown]
80
+ const emptySnapshotPluralComplete: DirectSnapshot = [emptyArray, resultTypeComplete]
81
+ const emptySnapshotPluralError: DirectSnapshot = [emptyArray, resultTypeError]
82
+
83
+ function getDefaultSnapshot(singular: boolean) {
84
+ return singular ? emptySnapshotSingularUnknown : emptySnapshotPluralUnknown
85
+ }
86
+
87
+ function makeError(retry: () => void, error?: DirectQueryError) {
88
+ const message = error?.message ?? 'An unknown error occurred'
89
+ return {
90
+ type: 'error',
91
+ retry,
92
+ refetch: retry,
93
+ error: {
94
+ type: error?.error ?? 'app',
95
+ message,
96
+ ...(error?.details ? { details: error.details } : {}),
97
+ },
98
+ } as const
99
+ }
100
+
101
+ function getSnapshot(
102
+ singular: boolean,
103
+ data: unknown,
104
+ resultType: DirectResultType,
105
+ retry: () => void,
106
+ error?: DirectQueryError,
107
+ ): DirectSnapshot {
108
+ if (singular && data === undefined) {
109
+ if (resultType === 'complete') return emptySnapshotSingularComplete
110
+ if (resultType === 'error')
111
+ return error ? [undefined, makeError(retry, error)] : emptySnapshotSingularError
112
+ return emptySnapshotSingularUnknown
113
+ }
114
+
115
+ if (!singular && Array.isArray(data) && data.length === 0) {
116
+ if (resultType === 'complete') return emptySnapshotPluralComplete
117
+ if (resultType === 'error')
118
+ return error ? [emptyArray, makeError(retry, error)] : emptySnapshotPluralError
119
+ return emptySnapshotPluralUnknown
120
+ }
121
+
122
+ if (resultType === 'complete') return [data, resultTypeComplete]
123
+ if (resultType === 'error') return [data, makeError(retry, error)]
124
+ return [data, resultTypeUnknown]
125
+ }
126
+
127
+ class DirectViewWrapper implements DirectView {
128
+ private view: ReturnType<MaterializableZero['materialize']> | undefined
129
+ private snapshot: DirectSnapshot
130
+ private readonly listeners = new Set<() => void>()
131
+ private destroyTimer: ReturnType<typeof setTimeout> | undefined
132
+
133
+ constructor(
134
+ private readonly query: any,
135
+ private readonly zero: MaterializableZero,
136
+ private ttl: UseQueryOptions['ttl'] | number,
137
+ private readonly singular: boolean,
138
+ private readonly onDematerialized: (view: DirectViewWrapper) => void,
139
+ ) {
140
+ this.snapshot = getDefaultSnapshot(singular)
141
+ this.materializeIfNeeded()
142
+ }
143
+
144
+ private onData = (data: unknown, resultType: string, error?: DirectQueryError) => {
145
+ const cloned = data === undefined ? undefined : deepClone(data as ReadonlyJSONValue)
146
+ this.snapshot = getSnapshot(
147
+ this.singular,
148
+ cloned,
149
+ resultType as DirectResultType,
150
+ this.retry,
151
+ error,
152
+ )
153
+ for (const listener of this.listeners) {
154
+ listener()
155
+ }
156
+ }
157
+
158
+ private retry = () => {
159
+ this.destroyView()
160
+ this.materializeIfNeeded()
161
+ }
162
+
163
+ private materializeIfNeeded() {
164
+ if (this.view) return
165
+ this.view = this.zero.materialize(this.query, { ttl: this.ttl })
166
+ this.view.addListener(this.onData)
167
+ }
168
+
169
+ private destroyView() {
170
+ const current = this.view
171
+ this.view = undefined
172
+ if (!current) return
173
+ try {
174
+ current.destroy()
175
+ } catch {
176
+ // the owning zero can close before react unsubscribes during rotation
177
+ }
178
+ }
179
+
180
+ updateTTL(ttl: UseQueryOptions['ttl'] | number) {
181
+ this.ttl = ttl
182
+ this.view?.updateTTL(ttl)
183
+ }
184
+
185
+ subscribe = (notify: () => void) => {
186
+ this.listeners.add(notify)
187
+ if (this.destroyTimer !== undefined) {
188
+ clearTimeout(this.destroyTimer)
189
+ this.destroyTimer = undefined
190
+ }
191
+ this.materializeIfNeeded()
192
+
193
+ return () => {
194
+ this.listeners.delete(notify)
195
+ if (this.listeners.size === 0) {
196
+ this.destroyTimer = setTimeout(() => {
197
+ this.destroyTimer = undefined
198
+ if (this.listeners.size > 0) return
199
+ this.destroyView()
200
+ this.onDematerialized(this)
201
+ }, 10)
202
+ }
203
+ }
204
+ }
205
+
206
+ getSnapshot = () => this.snapshot
207
+ }
208
+
209
+ class DirectViewStore {
210
+ private readonly views = new Map<string, DirectViewWrapper>()
211
+
212
+ getView(
213
+ zero: MaterializableZero,
214
+ queryRequest: unknown,
215
+ enabled: boolean,
216
+ ttl: UseQueryOptions['ttl'] | number,
217
+ ): DirectView {
218
+ const query = addContextToQuery(queryRequest as any, zero.context as any)
219
+ const queryInternals = asQueryInternals(query)
220
+
221
+ if (!enabled) {
222
+ const snapshot = getDefaultSnapshot(queryInternals.format.singular)
223
+ return {
224
+ subscribe: DISABLED_SUBSCRIBE,
225
+ getSnapshot: () => snapshot,
226
+ }
227
+ }
228
+
229
+ const hash = `${zero.clientID}:${queryInternals.hash()}`
230
+ let view = this.views.get(hash)
231
+ if (!view) {
232
+ view = new DirectViewWrapper(
233
+ query,
234
+ zero,
235
+ ttl,
236
+ queryInternals.format.singular,
237
+ (dematerialized) => {
238
+ if (this.views.get(hash) === dematerialized) {
239
+ this.views.delete(hash)
240
+ }
241
+ },
242
+ )
243
+ this.views.set(hash, view)
244
+ } else {
245
+ view.updateTTL(ttl)
246
+ }
247
+
248
+ return view
249
+ }
250
+ }
251
+
252
+ export function createUseQueryDirect<Schema extends ZeroSchema>({
253
+ DisabledContext,
254
+ customQueries,
255
+ getZero,
256
+ zeroVersion,
257
+ }: Parameters<CreateUseQueryDirect<Schema>>[0]): UseQueryHook<Schema> {
258
+ const directViewStore = new DirectViewStore()
259
+
260
+ function useQueryDirect(...args: any[]): any {
261
+ const disableMode = useContext(DisabledContext)
262
+ const lastRef = useRef<any>(EMPTY_RESPONSE)
263
+ const [fn, paramsOrOptions, optionsArg] = args
264
+
265
+ const version = useEmitterValue(zeroVersion)
266
+ const { params, options } = parseUseQueryArgs(paramsOrOptions, optionsArg)
267
+
268
+ let enabled = true
269
+ let ttl: UseQueryOptions['ttl'] | number = DEFAULT_TTL_MS
270
+ if (typeof options === 'boolean') {
271
+ enabled = options
272
+ } else if (options) {
273
+ enabled = options.enabled !== false
274
+ ttl = options.ttl ?? DEFAULT_TTL_MS
275
+ }
276
+
277
+ const paramsKey = params === undefined ? '' : JSON.stringify(params)
278
+
279
+ const view = useMemo((): DirectView | null => {
280
+ const zero = getZero()
281
+ if (!zero) return null
282
+ const queryRequest = resolveQuery({ customQueries, fn, params })
283
+ return directViewStore.getView(zero, queryRequest, enabled, ttl)
284
+ // params is keyed by paramsKey; version re-materializes on a new zero
285
+ // eslint-disable-next-line react-hooks/exhaustive-deps
286
+ }, [fn, paramsKey, enabled, ttl, version])
287
+
288
+ const out = useSyncExternalStore(
289
+ view ? view.subscribe : DISABLED_SUBSCRIBE,
290
+ view ? view.getSnapshot : getDisabledSnapshot,
291
+ view ? view.getSnapshot : getDisabledSnapshot,
292
+ )
293
+
294
+ if (!disableMode) {
295
+ lastRef.current = out
296
+ return out
297
+ }
298
+
299
+ if (disableMode === 'last-value') {
300
+ return lastRef.current
301
+ }
302
+
303
+ return EMPTY_RESPONSE
304
+ }
305
+
306
+ return useQueryDirect as UseQueryHook<Schema>
307
+ }