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.
- package/dist/cjs/combineZeroClients.native.js.map +1 -1
- package/dist/cjs/createUseQuery.cjs +7 -97
- package/dist/cjs/createUseQuery.native.js +7 -133
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createUseQueryDirect.cjs +229 -0
- package/dist/cjs/createUseQueryDirect.native.js +335 -0
- package/dist/cjs/createUseQueryDirect.native.js.map +1 -0
- package/dist/cjs/createZeroClient.cjs +61 -17
- package/dist/cjs/createZeroClient.native.js +65 -17
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/cjs/index.cjs +2 -2
- package/dist/cjs/index.native.js +2 -2
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/multi.cjs +38 -0
- package/dist/cjs/multi.native.js +41 -0
- package/dist/cjs/multi.native.js.map +1 -0
- package/dist/cjs/multiInstanceNested.test.cjs +92 -12
- package/dist/cjs/multiInstanceNested.test.native.js +108 -12
- package/dist/cjs/multiInstanceNested.test.native.js.map +1 -1
- package/dist/cjs/testSetup.cjs +26 -0
- package/dist/cjs/testSetup.native.js +32 -0
- package/dist/cjs/testSetup.native.js.map +1 -0
- package/dist/esm/combineZeroClients.mjs.map +1 -1
- package/dist/esm/combineZeroClients.native.js.map +1 -1
- package/dist/esm/createUseQuery.mjs +8 -98
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +8 -134
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createUseQueryDirect.mjs +204 -0
- package/dist/esm/createUseQueryDirect.mjs.map +1 -0
- package/dist/esm/createUseQueryDirect.native.js +307 -0
- package/dist/esm/createUseQueryDirect.native.js.map +1 -0
- package/dist/esm/createZeroClient.mjs +62 -19
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +66 -19
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/esm/index.js +2 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +2 -3
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -3
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/multi.mjs +12 -0
- package/dist/esm/multi.mjs.map +1 -0
- package/dist/esm/multi.native.js +12 -0
- package/dist/esm/multi.native.js.map +1 -0
- package/dist/esm/multiInstanceNested.test.mjs +85 -5
- package/dist/esm/multiInstanceNested.test.mjs.map +1 -1
- package/dist/esm/multiInstanceNested.test.native.js +101 -5
- package/dist/esm/multiInstanceNested.test.native.js.map +1 -1
- package/dist/esm/testSetup.mjs +27 -0
- package/dist/esm/testSetup.mjs.map +1 -0
- package/dist/esm/testSetup.native.js +30 -0
- package/dist/esm/testSetup.native.js.map +1 -0
- package/package.json +9 -3
- package/readme.md +10 -8
- package/src/combineZeroClients.tsx +4 -6
- package/src/createUseQuery.tsx +20 -190
- package/src/createUseQueryDirect.tsx +309 -0
- package/src/createZeroClient.tsx +158 -54
- package/src/helpers/recoverZeroClient.test.ts +8 -2
- package/src/index.ts +6 -2
- package/src/multi.ts +24 -0
- package/src/multiInstanceNested.test.tsx +79 -4
- package/src/testSetup.ts +26 -0
- package/types/combineZeroClients.d.ts.map +1 -1
- package/types/createUseQuery.d.ts +4 -15
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createUseQueryDirect.d.ts +29 -0
- package/types/createUseQueryDirect.d.ts.map +1 -0
- package/types/createZeroClient.d.ts +53 -7
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/index.d.ts +1 -2
- package/types/index.d.ts.map +1 -1
- package/types/multi.d.ts +6 -0
- package/types/multi.d.ts.map +1 -0
- package/types/multiInstanceNested.test.d.ts.map +1 -1
- package/types/testSetup.d.ts +1 -0
- package/types/testSetup.d.ts.map +1 -0
- 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
|
+
}
|
package/src/createZeroClient.tsx
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { defineQueries, defineQuery, Zero as ZeroClient } from '@rocicorp/zero'
|
|
2
|
-
import {
|
|
3
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
494
|
-
|
|
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
|
|
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>(
|
|
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({
|
|
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
|
|
12
|
-
|
|
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
|
+
}
|