on-zero 0.4.36 → 0.4.38

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 +7 -97
  3. package/dist/cjs/createUseQuery.native.js +7 -133
  4. package/dist/cjs/createUseQuery.native.js.map +1 -1
  5. package/dist/cjs/createUseQueryDirect.cjs +229 -0
  6. package/dist/cjs/createUseQueryDirect.native.js +335 -0
  7. package/dist/cjs/createUseQueryDirect.native.js.map +1 -0
  8. package/dist/cjs/createZeroClient.cjs +61 -17
  9. package/dist/cjs/createZeroClient.native.js +65 -17
  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 +8 -98
  27. package/dist/esm/createUseQuery.mjs.map +1 -1
  28. package/dist/esm/createUseQuery.native.js +8 -134
  29. package/dist/esm/createUseQuery.native.js.map +1 -1
  30. package/dist/esm/createUseQueryDirect.mjs +204 -0
  31. package/dist/esm/createUseQueryDirect.mjs.map +1 -0
  32. package/dist/esm/createUseQueryDirect.native.js +307 -0
  33. package/dist/esm/createUseQueryDirect.native.js.map +1 -0
  34. package/dist/esm/createZeroClient.mjs +62 -19
  35. package/dist/esm/createZeroClient.mjs.map +1 -1
  36. package/dist/esm/createZeroClient.native.js +66 -19
  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 +9 -3
  59. package/readme.md +10 -8
  60. package/src/combineZeroClients.tsx +4 -6
  61. package/src/createUseQuery.tsx +20 -190
  62. package/src/createUseQueryDirect.tsx +309 -0
  63. package/src/createZeroClient.tsx +158 -54
  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 +53 -7
  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
@@ -0,0 +1,309 @@
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
+ // SSG: skip every hook below — see createUseQuery for the rationale.
262
+ if (typeof window === 'undefined') return EMPTY_RESPONSE
263
+ const disableMode = useContext(DisabledContext)
264
+ const lastRef = useRef<any>(EMPTY_RESPONSE)
265
+ const [fn, paramsOrOptions, optionsArg] = args
266
+
267
+ const version = useEmitterValue(zeroVersion)
268
+ const { params, options } = parseUseQueryArgs(paramsOrOptions, optionsArg)
269
+
270
+ let enabled = true
271
+ let ttl: UseQueryOptions['ttl'] | number = DEFAULT_TTL_MS
272
+ if (typeof options === 'boolean') {
273
+ enabled = options
274
+ } else if (options) {
275
+ enabled = options.enabled !== false
276
+ ttl = options.ttl ?? DEFAULT_TTL_MS
277
+ }
278
+
279
+ const paramsKey = params === undefined ? '' : JSON.stringify(params)
280
+
281
+ const view = useMemo((): DirectView | null => {
282
+ const zero = getZero()
283
+ if (!zero) return null
284
+ const queryRequest = resolveQuery({ customQueries, fn, params })
285
+ return directViewStore.getView(zero, queryRequest, enabled, ttl)
286
+ // params is keyed by paramsKey; version re-materializes on a new zero
287
+ // eslint-disable-next-line react-hooks/exhaustive-deps
288
+ }, [fn, paramsKey, enabled, ttl, version])
289
+
290
+ const out = useSyncExternalStore(
291
+ view ? view.subscribe : DISABLED_SUBSCRIBE,
292
+ view ? view.getSnapshot : getDisabledSnapshot,
293
+ view ? view.getSnapshot : getDisabledSnapshot,
294
+ )
295
+
296
+ if (!disableMode) {
297
+ lastRef.current = out
298
+ return out
299
+ }
300
+
301
+ if (disableMode === 'last-value') {
302
+ return lastRef.current
303
+ }
304
+
305
+ return EMPTY_RESPONSE
306
+ }
307
+
308
+ return useQueryDirect as UseQueryHook<Schema>
309
+ }
@@ -1,6 +1,11 @@
1
1
  import { defineQueries, defineQuery, Zero as ZeroClient } from '@rocicorp/zero'
2
- import { useConnectionState, useZero, ZeroProvider } from '@rocicorp/zero/react'
3
- import { createEmitter } from '@take-out/helpers'
2
+ import {
3
+ useConnectionState,
4
+ useZero,
5
+ ZeroContext,
6
+ ZeroProvider,
7
+ } from '@rocicorp/zero/react'
8
+ import { createEmitter, type Emitter } from '@take-out/helpers'
4
9
  import {
5
10
  createContext,
6
11
  memo,
@@ -9,6 +14,7 @@ import {
9
14
  useMemo,
10
15
  useRef,
11
16
  useState,
17
+ type Context,
12
18
  type ReactNode,
13
19
  } from 'react'
14
20
 
@@ -16,7 +22,6 @@ import { createPermissions } from './createPermissions'
16
22
  import { ensureHttpPullTransport } from './httpPullTransport'
17
23
  import {
18
24
  createUseQuery,
19
- createUseQueryDirect,
20
25
  type QueryControlMode,
21
26
  type UseQueryHook,
22
27
  } from './createUseQuery'
@@ -38,7 +43,14 @@ import { setRunner, type ZeroRunner } from './zeroRunner'
38
43
  import { zql } from './zql'
39
44
 
40
45
  import type { AuthData, GenericModels, GetZeroMutators, ZeroEvent } from './types'
41
- import type { Query, Row, Zero, ZeroOptions, Schema as ZeroSchema } from '@rocicorp/zero'
46
+ import type {
47
+ AnyQueryRegistry,
48
+ Query,
49
+ Row,
50
+ Zero,
51
+ ZeroOptions,
52
+ Schema as ZeroSchema,
53
+ } from '@rocicorp/zero'
42
54
 
43
55
  type PreloadOptions = { ttl?: 'always' | 'never' | number | undefined }
44
56
 
@@ -50,6 +62,27 @@ export type GroupedQueries = Record<string, Record<string, (...args: any[]) => a
50
62
  // - 'optimistic-allow': return true until server confirms
51
63
  export type PermissionStrategy = 'optimistic' | 'optimistic-deny' | 'optimistic-allow'
52
64
 
65
+ export type CreateZeroClientOptions<
66
+ Schema extends ZeroSchema,
67
+ Models extends GenericModels,
68
+ > = {
69
+ schema: Schema
70
+ models: Models
71
+ groupedQueries: GroupedQueries
72
+ permissionStrategy?: PermissionStrategy
73
+ // names this client instance so multiple instances can coexist on one page.
74
+ // each query/mutator namespace is claimed by exactly one instance, and the
75
+ // ambient run() + the combineZeroClients facade dispatch by that claim.
76
+ instanceName?: string
77
+ }
78
+
79
+ export type DirectQueryAdapter<Schema extends ZeroSchema> = (props: {
80
+ DisabledContext: Context<QueryControlMode>
81
+ customQueries: AnyQueryRegistry
82
+ getZero: () => any
83
+ zeroVersion: Emitter<number>
84
+ }) => UseQueryHook<Schema>
85
+
53
86
  function getZeroProxyValue(instance: object, key: PropertyKey) {
54
87
  const value = Reflect.get(instance, key, instance)
55
88
  if (typeof value !== 'function') return value
@@ -67,7 +100,25 @@ function getZeroProxyValue(instance: object, key: PropertyKey) {
67
100
  return bound
68
101
  }
69
102
 
70
- export function createZeroClient<
103
+ function createUnavailableDirectUseQuery<
104
+ Schema extends ZeroSchema,
105
+ >(): UseQueryHook<Schema> {
106
+ function useQueryDirect(): never {
107
+ throw new Error(
108
+ `[on-zero] direct queries are optional. Import createZeroClientWithDirectQueries from 'on-zero/multi' for clients used outside the innermost ZeroProvider.`,
109
+ )
110
+ }
111
+
112
+ return useQueryDirect as UseQueryHook<Schema>
113
+ }
114
+
115
+ export function createZeroClient<Schema extends ZeroSchema, Models extends GenericModels>(
116
+ options: CreateZeroClientOptions<Schema, Models>,
117
+ ) {
118
+ return createZeroClientInternal(options)
119
+ }
120
+
121
+ export function createZeroClientInternal<
71
122
  Schema extends ZeroSchema,
72
123
  Models extends GenericModels,
73
124
  >({
@@ -76,15 +127,9 @@ export function createZeroClient<
76
127
  groupedQueries,
77
128
  permissionStrategy = 'optimistic',
78
129
  instanceName = 'default',
79
- }: {
80
- schema: Schema
81
- models: Models
82
- groupedQueries: GroupedQueries
83
- permissionStrategy?: PermissionStrategy
84
- // names this client instance so multiple instances can coexist on one page.
85
- // each query/mutator namespace is claimed by exactly one instance, and the
86
- // ambient run() + the combineZeroClients facade dispatch by that claim.
87
- instanceName?: string
130
+ createDirectUseQuery,
131
+ }: CreateZeroClientOptions<Schema, Models> & {
132
+ createDirectUseQuery?: DirectQueryAdapter<Schema>
88
133
  }) {
89
134
  type ZeroMutators = GetZeroMutators<Models>
90
135
  type ZeroInstance = Zero<Schema, ZeroMutators>
@@ -230,13 +275,9 @@ export function createZeroClient<
230
275
 
231
276
  const zeroEvents = createEmitter<ZeroEvent | null>(`zero${emitterScope}`, null)
232
277
 
233
- // bumped after commit whenever the mounted zero instance changes (first
234
- // mount, identity change) — the direct query path re-materializes its views
235
- // against the new instance
236
- const zeroInstanceVersion = createEmitter<number>(
237
- `zero-instance-version${emitterScope}`,
238
- 0,
239
- )
278
+ const zeroInstanceVersion = createDirectUseQuery
279
+ ? createEmitter<number>(`zero-instance-version${emitterScope}`, 0)
280
+ : null
240
281
 
241
282
  const AuthDataContext = createContext<AuthData>({} as AuthData)
242
283
 
@@ -245,15 +286,14 @@ export function createZeroClient<
245
286
  customQueries,
246
287
  })
247
288
 
248
- // context-free counterpart: materializes directly on THIS instance's
249
- // mounted zero instead of the nearest ZeroProvider context. used by
250
- // combineZeroClients for instances whose provider is not innermost.
251
- const useQueryDirect = createUseQueryDirect<Schema>({
252
- DisabledContext,
253
- customQueries,
254
- getZero: () => latestZeroInstance,
255
- zeroVersion: zeroInstanceVersion,
256
- })
289
+ const useQueryDirect = createDirectUseQuery
290
+ ? createDirectUseQuery({
291
+ DisabledContext,
292
+ customQueries,
293
+ getZero: () => latestZeroInstance,
294
+ zeroVersion: zeroInstanceVersion!,
295
+ })
296
+ : createUnavailableDirectUseQuery<Schema>()
257
297
 
258
298
  // permission check uses a per-model synced query so server is authoritative
259
299
  // permissionStrategy controls client behavior before server responds.
@@ -266,6 +306,8 @@ export function createZeroClient<
266
306
  enabled = typeof objOrId !== 'undefined',
267
307
  debug = false,
268
308
  ): boolean | null {
309
+ // SSG: skip every hook below — see createUseQuery for rationale.
310
+ if (typeof window === 'undefined') return null
269
311
  const disableMode = useContext(DisabledContext)
270
312
  const lastRef = useRef<boolean | null>(null)
271
313
  const tableStr = table as string
@@ -321,17 +363,45 @@ export function createZeroClient<
321
363
  // with different identities would thrash this slot.
322
364
  let cachedZero: { key: string; instance: ZeroInstance } | null = null
323
365
 
324
- const ProvideZero = ({
325
- children,
326
- authData: authDataIn,
327
- disable,
328
- transport,
329
- pullIntervalMs = 30_000,
330
- beforeReload,
331
- ...props
332
- }: Omit<ZeroOptions<Schema, ZeroMutators>, 'schema' | 'mutators'> & {
366
+ // when ProvideZero is rendered without a real Zero instance (SSG, disable=true,
367
+ // or transiently while the active path is still creating its first instance),
368
+ // we want descendants' useZero() / useConnectionState() / on-zero useQuery to
369
+ // NOT throw — but also not run real queries. Hand them a stub Zero plus
370
+ // DisabledContext='empty':
371
+ // 1. zero/react's useZero() reads ZeroContext; the stub is truthy so it
372
+ // doesn't throw "useZero must be used within a ZeroProvider".
373
+ // 2. on-zero's useQuery wrapper forces enabled=false to the underlying
374
+ // zero useQuery when DisabledContext is set, so zero's viewStore.getView
375
+ // returns its disabled-view stub without ever reading zero.clientID or
376
+ // subscribing through the stub.
377
+ // 3. addContextToQuery(query, zero.context) is the only deep access in the
378
+ // query path; the stub's .context is a plain object so that call
379
+ // succeeds harmlessly.
380
+ // 4. useConnectionState reads zero.connection.state.{subscribe,current};
381
+ // we provide a perma-'closed' state with a no-op subscribe. consumers
382
+ // handle 'closed' as a normal disconnected state.
383
+ // This stub lets the provider tree render with stable shape regardless of
384
+ // whether Zero is active — so children never re-parent across enable/disable.
385
+ const DISABLED_ZERO_STUB_CONNECTION_STATE = {
386
+ current: { name: 'closed' as const },
387
+ subscribe: () => () => {},
388
+ }
389
+ const DISABLED_ZERO_STUB = {
390
+ context: {},
391
+ connection: { state: DISABLED_ZERO_STUB_CONNECTION_STATE },
392
+ } as unknown as ZeroInstance
393
+
394
+ type ProvideZeroProps = Omit<ZeroOptions<Schema, ZeroMutators>, 'schema' | 'mutators'> & {
333
395
  children: ReactNode
334
396
  authData?: AuthData | null
397
+ // when true, ProvideZero renders a stable shell with stub Zero — no real
398
+ // client is created, no websocket is opened, no IDB store is touched.
399
+ // useQuery descendants receive EMPTY_RESPONSE via DisabledContext='empty'.
400
+ // toggling this on/off NEVER re-parents children: the React tree shape is
401
+ // identical in both modes (active just mounts a sibling lifecycle, which
402
+ // doesn't shift children's position). use this when consumers need the
403
+ // provider tree mounted (e.g. inside a marketing splash that lazily
404
+ // upgrades to the real IDE) without paying for Zero until activation.
335
405
  disable?: boolean
336
406
  // 'http-pull' runs the stock zero client over stateless HTTP pull/push
337
407
  // (no websocket, no resident server state) by intercepting the sync
@@ -343,7 +413,42 @@ export function createZeroClient<
343
413
  // awaited before a self-healing recovery reload — e.g. wait for the dev
344
414
  // origin to be reachable so the reload doesn't hit a restarting server.
345
415
  beforeReload?: () => Promise<void>
346
- }) => {
416
+ }
417
+
418
+ const ProvideZero = ({ children, ...props }: ProvideZeroProps) => {
419
+ // SSG branch: NO HOOKS. on-zero's hooks call useRef/useState/useEffect
420
+ // through its bundled React copy, which the SSG build's runtime sees as
421
+ // null ("Cannot read properties of null (reading 'useRef')") because react
422
+ // isn't deduplicated across the bundle and on-zero's slot is undefined at
423
+ // render time. emit the stable shell synchronously instead. children
424
+ // re-parent ONCE at hydration (server: this branch; client: the active
425
+ // branch), but that happens before any heavy descendant (e.g. consumer
426
+ // F2CShell-style skip-first-render mounts) is in the tree — so the parent
427
+ // swap is invisible at the DOM layer (all our wrappers are
428
+ // Context.Providers with no DOM) and incurs no remount jank.
429
+ if (typeof window === 'undefined' || props.disable) {
430
+ return (
431
+ <AuthDataContext.Provider value={(props.authData ?? {}) as AuthData}>
432
+ <DisabledContext.Provider value="empty">
433
+ <ZeroContext.Provider value={DISABLED_ZERO_STUB as any}>
434
+ {children}
435
+ </ZeroContext.Provider>
436
+ </DisabledContext.Provider>
437
+ </AuthDataContext.Provider>
438
+ )
439
+ }
440
+
441
+ return <ProvideZeroActive {...props}>{children}</ProvideZeroActive>
442
+ }
443
+
444
+ const ProvideZeroActive = ({
445
+ children,
446
+ authData: authDataIn,
447
+ transport,
448
+ pullIntervalMs = 30_000,
449
+ beforeReload,
450
+ ...props
451
+ }: Omit<ProvideZeroProps, 'disable'>) => {
347
452
  // resolve the auth token first: a real logout (token gone) must clear
348
453
  // authData so client mutators don't keep running as the old user, while a
349
454
  // transient authData blip with the token still present (session refresh, tab
@@ -478,20 +583,21 @@ export function createZeroClient<
478
583
  }
479
584
  }, [instance, auth])
480
585
 
481
- // for now we re-parent
482
- if (disable) {
483
- return children
484
- }
485
-
586
+ // Always render the same shell shape, with or without an instance. While
587
+ // the instance is being constructed (first effect tick) we hand descendants
588
+ // the stub Zero plus DisabledContext='empty' so useZero/useQuery
589
+ // short-circuit instead of throwing. SetZeroInstance + ConnectionMonitor
590
+ // only mount once instance exists, as siblings of children — they NEVER
591
+ // wrap children, so toggling them never re-parents.
486
592
  return (
487
593
  <AuthDataContext.Provider value={authData}>
488
- {instance ? (
489
- <ZeroProvider zero={instance}>
490
- <SetZeroInstance />
491
- <ConnectionMonitor zeroEvents={zeroEvents} />
594
+ <DisabledContext.Provider value={instance ? false : 'empty'}>
595
+ <ZeroContext.Provider value={instance ?? (DISABLED_ZERO_STUB as any)}>
596
+ {instance ? <SetZeroInstance /> : null}
597
+ {instance ? <ConnectionMonitor zeroEvents={zeroEvents} /> : null}
492
598
  {children}
493
- </ZeroProvider>
494
- ) : null}
599
+ </ZeroContext.Provider>
600
+ </DisabledContext.Provider>
495
601
  </AuthDataContext.Provider>
496
602
  )
497
603
  }
@@ -515,10 +621,8 @@ export function createZeroClient<
515
621
  setRunner(runner)
516
622
  }
517
623
 
518
- // notify direct-path views after commit (emitting during render would
519
- // setState other components mid-render)
520
624
  useEffect(() => {
521
- zeroInstanceVersion.emit(zeroInstanceVersion.value + 1)
625
+ zeroInstanceVersion?.emit(zeroInstanceVersion.value + 1)
522
626
  }, [zeroInstance])
523
627
 
524
628
  return null
@@ -177,7 +177,10 @@ describe('zero recovery', () => {
177
177
  // remount. previously crashed as an unhandled rejection.
178
178
  test('default reload path is a safe no-op when location is absent', async () => {
179
179
  const events: ZeroEvent[] = []
180
- const zeroEvents = createEmitter<ZeroEvent | null>(`test-recover-${emitterSeq++}`, null)
180
+ const zeroEvents = createEmitter<ZeroEvent | null>(
181
+ `test-recover-${emitterSeq++}`,
182
+ null,
183
+ )
181
184
  zeroEvents.listen((event) => {
182
185
  if (event) events.push(event)
183
186
  })
@@ -200,6 +203,9 @@ describe('zero recovery', () => {
200
203
  })
201
204
  }
202
205
  expect(deleteLocalState).toHaveBeenCalledTimes(1)
203
- expect(events).toContainEqual({ type: 'recovering', reason: 'client state not found' })
206
+ expect(events).toContainEqual({
207
+ type: 'recovering',
208
+ reason: 'client state not found',
209
+ })
204
210
  })
205
211
  })
package/src/index.ts CHANGED
@@ -8,8 +8,12 @@ export * from './helpers/useMutation'
8
8
  export { ensureAuth, getAuth } from './helpers/getAuth'
9
9
  export { setAuthData, setEnvironment } from './state'
10
10
 
11
- export * from './combineZeroClients'
12
- export * from './createZeroClient'
11
+ export {
12
+ createZeroClient,
13
+ type CreateZeroClientOptions,
14
+ type GroupedQueries,
15
+ type PermissionStrategy,
16
+ } from './createZeroClient'
13
17
  export * from './httpPullTransport'
14
18
  export * from './createUseQuery'
15
19
  export * from './resolveQuery'
package/src/multi.ts ADDED
@@ -0,0 +1,24 @@
1
+ import {
2
+ createZeroClientInternal,
3
+ type DirectQueryAdapter,
4
+ type CreateZeroClientOptions,
5
+ } from './createZeroClient'
6
+ import { createUseQueryDirect } from './createUseQueryDirect'
7
+
8
+ import type { GenericModels } from './types'
9
+ import type { Schema as ZeroSchema } from '@rocicorp/zero'
10
+
11
+ export * from './combineZeroClients'
12
+
13
+ export function createZeroClientWithDirectQueries<
14
+ Schema extends ZeroSchema,
15
+ Models extends GenericModels,
16
+ >(
17
+ options: CreateZeroClientOptions<Schema, Models>,
18
+ ): ReturnType<typeof createZeroClientInternal<Schema, Models>> {
19
+ const createDirectUseQuery: DirectQueryAdapter<Schema> = createUseQueryDirect
20
+ return createZeroClientInternal<Schema, Models>({
21
+ ...options,
22
+ createDirectUseQuery,
23
+ })
24
+ }