on-zero 0.4.35 → 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 (110) 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/multiInstance.test.cjs +75 -0
  19. package/dist/cjs/multiInstance.test.native.js +111 -0
  20. package/dist/cjs/multiInstance.test.native.js.map +1 -1
  21. package/dist/cjs/multiInstanceNested.test.cjs +92 -12
  22. package/dist/cjs/multiInstanceNested.test.native.js +108 -12
  23. package/dist/cjs/multiInstanceNested.test.native.js.map +1 -1
  24. package/dist/cjs/run.cjs +6 -1
  25. package/dist/cjs/run.native.js +6 -1
  26. package/dist/cjs/run.native.js.map +1 -1
  27. package/dist/cjs/testSetup.cjs +26 -0
  28. package/dist/cjs/testSetup.native.js +32 -0
  29. package/dist/cjs/testSetup.native.js.map +1 -0
  30. package/dist/cjs/zeroRunner.cjs +4 -0
  31. package/dist/cjs/zeroRunner.native.js +4 -0
  32. package/dist/cjs/zeroRunner.native.js.map +1 -1
  33. package/dist/esm/combineZeroClients.mjs.map +1 -1
  34. package/dist/esm/combineZeroClients.native.js.map +1 -1
  35. package/dist/esm/createUseQuery.mjs +2 -97
  36. package/dist/esm/createUseQuery.mjs.map +1 -1
  37. package/dist/esm/createUseQuery.native.js +2 -133
  38. package/dist/esm/createUseQuery.native.js.map +1 -1
  39. package/dist/esm/createUseQueryDirect.mjs +203 -0
  40. package/dist/esm/createUseQueryDirect.mjs.map +1 -0
  41. package/dist/esm/createUseQueryDirect.native.js +306 -0
  42. package/dist/esm/createUseQueryDirect.native.js.map +1 -0
  43. package/dist/esm/createZeroClient.mjs +18 -8
  44. package/dist/esm/createZeroClient.mjs.map +1 -1
  45. package/dist/esm/createZeroClient.native.js +18 -8
  46. package/dist/esm/createZeroClient.native.js.map +1 -1
  47. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  48. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  49. package/dist/esm/index.js +2 -3
  50. package/dist/esm/index.js.map +1 -1
  51. package/dist/esm/index.mjs +2 -3
  52. package/dist/esm/index.mjs.map +1 -1
  53. package/dist/esm/index.native.js +2 -3
  54. package/dist/esm/index.native.js.map +1 -1
  55. package/dist/esm/multi.mjs +12 -0
  56. package/dist/esm/multi.mjs.map +1 -0
  57. package/dist/esm/multi.native.js +12 -0
  58. package/dist/esm/multi.native.js.map +1 -0
  59. package/dist/esm/multiInstance.test.mjs +75 -0
  60. package/dist/esm/multiInstance.test.mjs.map +1 -1
  61. package/dist/esm/multiInstance.test.native.js +111 -0
  62. package/dist/esm/multiInstance.test.native.js.map +1 -1
  63. package/dist/esm/multiInstanceNested.test.mjs +85 -5
  64. package/dist/esm/multiInstanceNested.test.mjs.map +1 -1
  65. package/dist/esm/multiInstanceNested.test.native.js +101 -5
  66. package/dist/esm/multiInstanceNested.test.native.js.map +1 -1
  67. package/dist/esm/run.mjs +7 -2
  68. package/dist/esm/run.mjs.map +1 -1
  69. package/dist/esm/run.native.js +7 -2
  70. package/dist/esm/run.native.js.map +1 -1
  71. package/dist/esm/testSetup.mjs +27 -0
  72. package/dist/esm/testSetup.mjs.map +1 -0
  73. package/dist/esm/testSetup.native.js +30 -0
  74. package/dist/esm/testSetup.native.js.map +1 -0
  75. package/dist/esm/zeroRunner.mjs +4 -1
  76. package/dist/esm/zeroRunner.mjs.map +1 -1
  77. package/dist/esm/zeroRunner.native.js +4 -1
  78. package/dist/esm/zeroRunner.native.js.map +1 -1
  79. package/package.json +8 -2
  80. package/readme.md +10 -8
  81. package/src/combineZeroClients.tsx +4 -6
  82. package/src/createUseQuery.tsx +2 -189
  83. package/src/createUseQueryDirect.tsx +307 -0
  84. package/src/createZeroClient.tsx +65 -32
  85. package/src/helpers/recoverZeroClient.test.ts +8 -2
  86. package/src/index.ts +6 -2
  87. package/src/multi.ts +24 -0
  88. package/src/multiInstance.test.tsx +69 -0
  89. package/src/multiInstanceNested.test.tsx +79 -4
  90. package/src/run.ts +10 -2
  91. package/src/testSetup.ts +26 -0
  92. package/src/zeroRunner.ts +4 -0
  93. package/types/combineZeroClients.d.ts.map +1 -1
  94. package/types/createUseQuery.d.ts +4 -15
  95. package/types/createUseQuery.d.ts.map +1 -1
  96. package/types/createUseQueryDirect.d.ts +29 -0
  97. package/types/createUseQueryDirect.d.ts.map +1 -0
  98. package/types/createZeroClient.d.ts +51 -5
  99. package/types/createZeroClient.d.ts.map +1 -1
  100. package/types/index.d.ts +1 -2
  101. package/types/index.d.ts.map +1 -1
  102. package/types/multi.d.ts +6 -0
  103. package/types/multi.d.ts.map +1 -0
  104. package/types/multiInstanceNested.test.d.ts.map +1 -1
  105. package/types/run.d.ts.map +1 -1
  106. package/types/testSetup.d.ts +1 -0
  107. package/types/testSetup.d.ts.map +1 -0
  108. package/types/zeroRunner.d.ts +3 -0
  109. package/types/zeroRunner.d.ts.map +1 -1
  110. package/vitest.config.ts +1 -0
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { defineQueries, defineQuery, Zero as ZeroClient } from '@rocicorp/zero'
2
2
  import { useConnectionState, useZero, ZeroProvider } from '@rocicorp/zero/react'
3
- import { createEmitter } from '@take-out/helpers'
3
+ import { createEmitter, type Emitter } from '@take-out/helpers'
4
4
  import {
5
5
  createContext,
6
6
  memo,
@@ -9,6 +9,7 @@ import {
9
9
  useMemo,
10
10
  useRef,
11
11
  useState,
12
+ type Context,
12
13
  type ReactNode,
13
14
  } from 'react'
14
15
 
@@ -16,7 +17,6 @@ import { createPermissions } from './createPermissions'
16
17
  import { ensureHttpPullTransport } from './httpPullTransport'
17
18
  import {
18
19
  createUseQuery,
19
- createUseQueryDirect,
20
20
  type QueryControlMode,
21
21
  type UseQueryHook,
22
22
  } from './createUseQuery'
@@ -38,7 +38,14 @@ import { setRunner, type ZeroRunner } from './zeroRunner'
38
38
  import { zql } from './zql'
39
39
 
40
40
  import type { AuthData, GenericModels, GetZeroMutators, ZeroEvent } from './types'
41
- import type { Query, Row, Zero, ZeroOptions, Schema as ZeroSchema } from '@rocicorp/zero'
41
+ import type {
42
+ AnyQueryRegistry,
43
+ Query,
44
+ Row,
45
+ Zero,
46
+ ZeroOptions,
47
+ Schema as ZeroSchema,
48
+ } from '@rocicorp/zero'
42
49
 
43
50
  type PreloadOptions = { ttl?: 'always' | 'never' | number | undefined }
44
51
 
@@ -50,6 +57,27 @@ export type GroupedQueries = Record<string, Record<string, (...args: any[]) => a
50
57
  // - 'optimistic-allow': return true until server confirms
51
58
  export type PermissionStrategy = 'optimistic' | 'optimistic-deny' | 'optimistic-allow'
52
59
 
60
+ export type CreateZeroClientOptions<
61
+ Schema extends ZeroSchema,
62
+ Models extends GenericModels,
63
+ > = {
64
+ schema: Schema
65
+ models: Models
66
+ groupedQueries: GroupedQueries
67
+ permissionStrategy?: PermissionStrategy
68
+ // names this client instance so multiple instances can coexist on one page.
69
+ // each query/mutator namespace is claimed by exactly one instance, and the
70
+ // ambient run() + the combineZeroClients facade dispatch by that claim.
71
+ instanceName?: string
72
+ }
73
+
74
+ export type DirectQueryAdapter<Schema extends ZeroSchema> = (props: {
75
+ DisabledContext: Context<QueryControlMode>
76
+ customQueries: AnyQueryRegistry
77
+ getZero: () => any
78
+ zeroVersion: Emitter<number>
79
+ }) => UseQueryHook<Schema>
80
+
53
81
  function getZeroProxyValue(instance: object, key: PropertyKey) {
54
82
  const value = Reflect.get(instance, key, instance)
55
83
  if (typeof value !== 'function') return value
@@ -67,7 +95,25 @@ function getZeroProxyValue(instance: object, key: PropertyKey) {
67
95
  return bound
68
96
  }
69
97
 
70
- export function createZeroClient<
98
+ function createUnavailableDirectUseQuery<
99
+ Schema extends ZeroSchema,
100
+ >(): UseQueryHook<Schema> {
101
+ function useQueryDirect(): never {
102
+ throw new Error(
103
+ `[on-zero] direct queries are optional. Import createZeroClientWithDirectQueries from 'on-zero/multi' for clients used outside the innermost ZeroProvider.`,
104
+ )
105
+ }
106
+
107
+ return useQueryDirect as UseQueryHook<Schema>
108
+ }
109
+
110
+ export function createZeroClient<Schema extends ZeroSchema, Models extends GenericModels>(
111
+ options: CreateZeroClientOptions<Schema, Models>,
112
+ ) {
113
+ return createZeroClientInternal(options)
114
+ }
115
+
116
+ export function createZeroClientInternal<
71
117
  Schema extends ZeroSchema,
72
118
  Models extends GenericModels,
73
119
  >({
@@ -76,15 +122,9 @@ export function createZeroClient<
76
122
  groupedQueries,
77
123
  permissionStrategy = 'optimistic',
78
124
  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
125
+ createDirectUseQuery,
126
+ }: CreateZeroClientOptions<Schema, Models> & {
127
+ createDirectUseQuery?: DirectQueryAdapter<Schema>
88
128
  }) {
89
129
  type ZeroMutators = GetZeroMutators<Models>
90
130
  type ZeroInstance = Zero<Schema, ZeroMutators>
@@ -230,13 +270,9 @@ export function createZeroClient<
230
270
 
231
271
  const zeroEvents = createEmitter<ZeroEvent | null>(`zero${emitterScope}`, null)
232
272
 
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
- )
273
+ const zeroInstanceVersion = createDirectUseQuery
274
+ ? createEmitter<number>(`zero-instance-version${emitterScope}`, 0)
275
+ : null
240
276
 
241
277
  const AuthDataContext = createContext<AuthData>({} as AuthData)
242
278
 
@@ -245,15 +281,14 @@ export function createZeroClient<
245
281
  customQueries,
246
282
  })
247
283
 
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
- })
284
+ const useQueryDirect = createDirectUseQuery
285
+ ? createDirectUseQuery({
286
+ DisabledContext,
287
+ customQueries,
288
+ getZero: () => latestZeroInstance,
289
+ zeroVersion: zeroInstanceVersion!,
290
+ })
291
+ : createUnavailableDirectUseQuery<Schema>()
257
292
 
258
293
  // permission check uses a per-model synced query so server is authoritative
259
294
  // permissionStrategy controls client behavior before server responds.
@@ -515,10 +550,8 @@ export function createZeroClient<
515
550
  setRunner(runner)
516
551
  }
517
552
 
518
- // notify direct-path views after commit (emitting during render would
519
- // setState other components mid-render)
520
553
  useEffect(() => {
521
- zeroInstanceVersion.emit(zeroInstanceVersion.value + 1)
554
+ zeroInstanceVersion?.emit(zeroInstanceVersion.value + 1)
522
555
  }, [zeroInstance])
523
556
 
524
557
  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
+ }
@@ -4,10 +4,12 @@ import { describe, expect, test, vi } from 'vitest'
4
4
 
5
5
  import { combineZeroClients } from './combineZeroClients'
6
6
  import { createZeroClient } from './createZeroClient'
7
+ import { runWithContext } from './helpers/mutatorContext'
7
8
  import { getInstanceForNamespace, registerClientInstance } from './instanceRegistry'
8
9
  import { registerQuery } from './queryRegistry'
9
10
  import { run } from './run'
10
11
  import { setRunner, type ZeroRunner } from './zeroRunner'
12
+ import { zql } from './zql'
11
13
 
12
14
  import type { ZeroEvent } from './types'
13
15
  import type { AnyQueryRegistry, Query } from '@rocicorp/zero'
@@ -89,6 +91,73 @@ describe('multi-instance namespace dispatch', () => {
89
91
  expect(request.query.queryName).toBe('runUser.byId')
90
92
  })
91
93
 
94
+ test('run() rejects named queries inside server mutation context', async () => {
95
+ const { byId } = makeClient('run-ctx-owner', 'runCtxThing')
96
+ const ownerRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'owner' }))
97
+ getInstanceForNamespace('runCtxThing')!.runner = ownerRunner as ZeroRunner
98
+ const txRun = vi.fn(async (..._args: unknown[]) => ({ from: 'tx' }))
99
+
100
+ await expect(
101
+ runWithContext(
102
+ {
103
+ authData: null,
104
+ environment: 'server',
105
+ can: async () => {},
106
+ tx: { run: txRun },
107
+ } as any,
108
+ () => run(byId, { id: '1' }),
109
+ ),
110
+ ).rejects.toThrow(/run\(namedQuery\) cannot be used inside a Zero mutation/)
111
+
112
+ expect(ownerRunner).not.toHaveBeenCalled()
113
+ expect(txRun).not.toHaveBeenCalled()
114
+ })
115
+
116
+ test('run() keeps named queries on their owning client runner when browser context leaks', async () => {
117
+ const { byId } = makeClient('run-client-leak-owner', 'runClientLeakThing')
118
+ const ownerRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'owner' }))
119
+ getInstanceForNamespace('runClientLeakThing')!.runner = ownerRunner as ZeroRunner
120
+ const txRun = vi.fn(async (..._args: unknown[]) => ({ from: 'tx' }))
121
+
122
+ await expect(
123
+ runWithContext(
124
+ {
125
+ authData: null,
126
+ environment: 'client',
127
+ can: async () => {},
128
+ tx: { run: txRun },
129
+ } as any,
130
+ () => run(byId, { id: '1' }),
131
+ ),
132
+ ).resolves.toEqual({ from: 'owner' })
133
+
134
+ expect(ownerRunner).toHaveBeenCalledTimes(1)
135
+ expect(txRun).not.toHaveBeenCalled()
136
+ })
137
+
138
+ test('run() keeps inline zql on the active transaction runner', async () => {
139
+ makeClient('run-inline-context', 'runInlineThing')
140
+ const ambientRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'ambient' }))
141
+ setRunner(ambientRunner as ZeroRunner)
142
+ const txRun = vi.fn(async (..._args: unknown[]) => ({ from: 'tx' }))
143
+ const query = zql.user.where('id', '1')
144
+
145
+ await expect(
146
+ runWithContext(
147
+ {
148
+ authData: null,
149
+ environment: 'client',
150
+ can: async () => {},
151
+ tx: { run: txRun },
152
+ } as any,
153
+ () => run(query),
154
+ ),
155
+ ).resolves.toEqual({ from: 'tx' })
156
+
157
+ expect(txRun).toHaveBeenCalledWith(query, undefined)
158
+ expect(ambientRunner).not.toHaveBeenCalled()
159
+ })
160
+
92
161
  test('a claimed namespace with an unmounted instance uses the ambient runner (server path)', async () => {
93
162
  const { byId } = makeClient('srv-instance', 'srvThing')
94
163
  const ambient = vi.fn(async (..._args: unknown[]) => ({ from: 'ambient' }))