on-zero 0.4.23 → 0.4.24

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 (93) hide show
  1. package/dist/cjs/combineZeroClients.cjs +101 -0
  2. package/dist/cjs/combineZeroClients.native.js +150 -0
  3. package/dist/cjs/combineZeroClients.native.js.map +1 -0
  4. package/dist/cjs/createUseQuery.cjs +92 -4
  5. package/dist/cjs/createUseQuery.native.js +130 -4
  6. package/dist/cjs/createUseQuery.native.js.map +1 -1
  7. package/dist/cjs/createZeroClient.cjs +41 -13
  8. package/dist/cjs/createZeroClient.native.js +75 -43
  9. package/dist/cjs/createZeroClient.native.js.map +1 -1
  10. package/dist/cjs/index.cjs +1 -0
  11. package/dist/cjs/index.native.js +1 -0
  12. package/dist/cjs/index.native.js.map +1 -1
  13. package/dist/cjs/instanceRegistry.cjs +65 -0
  14. package/dist/cjs/instanceRegistry.native.js +111 -0
  15. package/dist/cjs/instanceRegistry.native.js.map +1 -0
  16. package/dist/cjs/multiInstance.test.cjs +322 -0
  17. package/dist/cjs/multiInstance.test.native.js +387 -0
  18. package/dist/cjs/multiInstance.test.native.js.map +1 -0
  19. package/dist/cjs/multiInstanceNested.test.cjs +206 -0
  20. package/dist/cjs/multiInstanceNested.test.native.js +254 -0
  21. package/dist/cjs/multiInstanceNested.test.native.js.map +1 -0
  22. package/dist/cjs/run.cjs +5 -5
  23. package/dist/cjs/run.native.js +6 -5
  24. package/dist/cjs/run.native.js.map +1 -1
  25. package/dist/cjs/zeroRunner.cjs +4 -1
  26. package/dist/cjs/zeroRunner.native.js +4 -1
  27. package/dist/cjs/zeroRunner.native.js.map +1 -1
  28. package/dist/esm/combineZeroClients.mjs +76 -0
  29. package/dist/esm/combineZeroClients.mjs.map +1 -0
  30. package/dist/esm/combineZeroClients.native.js +122 -0
  31. package/dist/esm/combineZeroClients.native.js.map +1 -0
  32. package/dist/esm/createUseQuery.mjs +92 -5
  33. package/dist/esm/createUseQuery.mjs.map +1 -1
  34. package/dist/esm/createUseQuery.native.js +130 -5
  35. package/dist/esm/createUseQuery.native.js.map +1 -1
  36. package/dist/esm/createZeroClient.mjs +42 -14
  37. package/dist/esm/createZeroClient.mjs.map +1 -1
  38. package/dist/esm/createZeroClient.native.js +76 -44
  39. package/dist/esm/createZeroClient.native.js.map +1 -1
  40. package/dist/esm/index.js +1 -0
  41. package/dist/esm/index.js.map +1 -1
  42. package/dist/esm/index.mjs +1 -0
  43. package/dist/esm/index.mjs.map +1 -1
  44. package/dist/esm/index.native.js +1 -0
  45. package/dist/esm/index.native.js.map +1 -1
  46. package/dist/esm/instanceRegistry.mjs +38 -0
  47. package/dist/esm/instanceRegistry.mjs.map +1 -0
  48. package/dist/esm/instanceRegistry.native.js +81 -0
  49. package/dist/esm/instanceRegistry.native.js.map +1 -0
  50. package/dist/esm/multiInstance.test.mjs +323 -0
  51. package/dist/esm/multiInstance.test.mjs.map +1 -0
  52. package/dist/esm/multiInstance.test.native.js +385 -0
  53. package/dist/esm/multiInstance.test.native.js.map +1 -0
  54. package/dist/esm/multiInstanceNested.test.mjs +207 -0
  55. package/dist/esm/multiInstanceNested.test.mjs.map +1 -0
  56. package/dist/esm/multiInstanceNested.test.native.js +252 -0
  57. package/dist/esm/multiInstanceNested.test.native.js.map +1 -0
  58. package/dist/esm/run.mjs +5 -5
  59. package/dist/esm/run.mjs.map +1 -1
  60. package/dist/esm/run.native.js +6 -5
  61. package/dist/esm/run.native.js.map +1 -1
  62. package/dist/esm/zeroRunner.mjs +4 -1
  63. package/dist/esm/zeroRunner.mjs.map +1 -1
  64. package/dist/esm/zeroRunner.native.js +4 -1
  65. package/dist/esm/zeroRunner.native.js.map +1 -1
  66. package/package.json +5 -3
  67. package/readme.md +59 -0
  68. package/src/combineZeroClients.tsx +186 -0
  69. package/src/createUseQuery.tsx +175 -12
  70. package/src/createZeroClient.tsx +107 -42
  71. package/src/index.ts +1 -0
  72. package/src/instanceRegistry.ts +75 -0
  73. package/src/multiInstance.test.tsx +284 -0
  74. package/src/multiInstanceNested.test.tsx +205 -0
  75. package/src/run.ts +7 -6
  76. package/src/zeroRunner.ts +7 -1
  77. package/types/combineZeroClients.d.ts +38 -0
  78. package/types/combineZeroClients.d.ts.map +1 -0
  79. package/types/createUseQuery.d.ts +15 -0
  80. package/types/createUseQuery.d.ts.map +1 -1
  81. package/types/createZeroClient.d.ts +7 -2
  82. package/types/createZeroClient.d.ts.map +1 -1
  83. package/types/index.d.ts +1 -0
  84. package/types/index.d.ts.map +1 -1
  85. package/types/instanceRegistry.d.ts +15 -0
  86. package/types/instanceRegistry.d.ts.map +1 -0
  87. package/types/multiInstance.test.d.ts +2 -0
  88. package/types/multiInstance.test.d.ts.map +1 -0
  89. package/types/multiInstanceNested.test.d.ts +5 -0
  90. package/types/multiInstanceNested.test.d.ts.map +1 -0
  91. package/types/run.d.ts.map +1 -1
  92. package/types/zeroRunner.d.ts +3 -1
  93. package/types/zeroRunner.d.ts.map +1 -1
@@ -0,0 +1,186 @@
1
+ import { createEmitter, type Emitter } from '@take-out/helpers'
2
+
3
+ import { getInstanceForNamespace, getInstanceForQueryFn } from './instanceRegistry'
4
+ import { run } from './run'
5
+
6
+ import type { ZeroEvent } from './types'
7
+ import type { ReactNode } from 'react'
8
+
9
+ // combines multiple createZeroClient instances into one consumer surface:
10
+ // useQuery/run/preload/getQuery dispatch to the instance that claimed the
11
+ // query fn's namespace, zero.mutate.<namespace> dispatches by model
12
+ // namespace, and everything unclaimed (plus non-mutate zero access like
13
+ // userID/clientID) goes to the FIRST client — the primary. consumers render
14
+ // each client's ProvideZero themselves; this facade does not use react
15
+ // context, matching the existing global-zero-import style.
16
+ //
17
+ // PROVIDER NESTING CONTRACT: zero-react's useQuery resolves its instance from
18
+ // the NEAREST ZeroProvider context, so only ONE instance — the one whose
19
+ // provider is mounted INNERMOST — may use the upstream context path. by
20
+ // default that is the LAST client passed here (override via the `inner`
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.
28
+
29
+ type ControlQueriesProps = {
30
+ children: ReactNode
31
+ action?: 'enable' | 'disable'
32
+ whenDisabled?: 'empty' | 'last-value'
33
+ }
34
+
35
+ // the minimal structural surface the facade dispatches over — the const
36
+ // generic captures each client's real types, this only constrains shape
37
+ type CombinableZeroClient = {
38
+ instanceName: string
39
+ useQuery: (...args: any[]) => any
40
+ useQueryDirect: (...args: any[]) => any
41
+ usePermission: (...args: any[]) => any
42
+ usePermissionDirect: (...args: any[]) => any
43
+ zero: any
44
+ preload: (...args: any[]) => any
45
+ getQuery: (...args: any[]) => any
46
+ zeroEvents: Emitter<ZeroEvent | null>
47
+ ControlQueries: (props: ControlQueriesProps) => ReactNode
48
+ }
49
+
50
+ export type CombineZeroClientsOptions = {
51
+ // instanceName of the client whose ProvideZero is mounted INNERMOST.
52
+ // defaults to the last client passed.
53
+ inner?: string
54
+ }
55
+
56
+ type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (
57
+ x: infer I,
58
+ ) => void
59
+ ? I
60
+ : never
61
+
62
+ export type CombinedZeroClients<Clients extends readonly CombinableZeroClient[]> = {
63
+ useQuery: UnionToIntersection<Clients[number]['useQuery']>
64
+ usePermission: UnionToIntersection<Clients[number]['usePermission']>
65
+ zero: UnionToIntersection<Clients[number]['zero']>
66
+ preload: UnionToIntersection<Clients[number]['preload']>
67
+ getQuery: UnionToIntersection<Clients[number]['getQuery']>
68
+ run: typeof run
69
+ zeroEvents: Emitter<ZeroEvent | null>
70
+ ControlQueries: (props: ControlQueriesProps) => ReactNode
71
+ }
72
+
73
+ export function combineZeroClients<
74
+ const Clients extends readonly [CombinableZeroClient, ...CombinableZeroClient[]],
75
+ >(
76
+ ...clientsAndOptions: [...Clients] | [...Clients, CombineZeroClientsOptions]
77
+ ): CombinedZeroClients<Clients> {
78
+ const last = clientsAndOptions[clientsAndOptions.length - 1]
79
+ const hasOptions = typeof (last as { instanceName?: unknown }).instanceName !== 'string'
80
+ // boundary narrow: everything before a trailing options object is a client
81
+ const clients = (hasOptions
82
+ ? clientsAndOptions.slice(0, -1)
83
+ : clientsAndOptions) as unknown as Clients
84
+ const options: CombineZeroClientsOptions = hasOptions
85
+ ? (last as CombineZeroClientsOptions)
86
+ : {}
87
+
88
+ const primary = clients[0]
89
+ const innerName = options.inner ?? clients[clients.length - 1]!.instanceName
90
+ const clientsByName = new Map(clients.map((client) => [client.instanceName, client]))
91
+
92
+ if (!clientsByName.has(innerName)) {
93
+ throw new Error(
94
+ `[on-zero] combineZeroClients inner instance '${innerName}' is not one of the passed clients`,
95
+ )
96
+ }
97
+
98
+ const ownerOfNamespace = (namespace: string): CombinableZeroClient => {
99
+ const owner = getInstanceForNamespace(namespace)
100
+ return (owner && clientsByName.get(owner.name)) || primary
101
+ }
102
+
103
+ const ownerOfQueryFn = (fn: Function): CombinableZeroClient => {
104
+ const owner = getInstanceForQueryFn(fn)
105
+ return (owner && clientsByName.get(owner.name)) || primary
106
+ }
107
+
108
+ // hooks: a given call site always passes the same query fn / table, so the
109
+ // dispatched hook target is stable across renders (no conditional hooks).
110
+ // only the inner instance may use the upstream context path — its provider
111
+ // is the nearest one, so useZero() resolves to it. everyone else goes
112
+ // through the context-free direct hooks.
113
+ function useQuery(...args: any[]) {
114
+ const owner = ownerOfQueryFn(args[0])
115
+ return owner.instanceName === innerName
116
+ ? owner.useQuery(...args)
117
+ : owner.useQueryDirect(...args)
118
+ }
119
+
120
+ function usePermission(...args: any[]) {
121
+ // model namespaces are table-named, so the table arg picks the owner;
122
+ // permission checks follow the same context vs direct path as the
123
+ // table's other queries
124
+ const owner = ownerOfNamespace(String(args[0]))
125
+ return owner.instanceName === innerName
126
+ ? owner.usePermission(...args)
127
+ : owner.usePermissionDirect(...args)
128
+ }
129
+
130
+ const mutate = new Proxy({} as never, {
131
+ get(_, key) {
132
+ if (typeof key !== 'string') return undefined
133
+ return ownerOfNamespace(key).zero.mutate[key]
134
+ },
135
+ })
136
+
137
+ const zero = new Proxy({} as never, {
138
+ get(_, key) {
139
+ if (key === 'mutate') {
140
+ return mutate
141
+ }
142
+ // non-mutate access (userID, clientID, close, …) forwards to primary
143
+ return primary.zero[key]
144
+ },
145
+ })
146
+
147
+ function preload(...args: any[]) {
148
+ return ownerOfQueryFn(args[0]).preload(...args)
149
+ }
150
+
151
+ function getQuery(...args: any[]) {
152
+ return ownerOfQueryFn(args[0]).getQuery(...args)
153
+ }
154
+
155
+ // one events stream relaying every instance's emitter
156
+ const zeroEvents = createEmitter<ZeroEvent | null>(
157
+ `zero:combined(${clients.map((client) => client.instanceName).join('+')})`,
158
+ null,
159
+ )
160
+ for (const client of clients) {
161
+ client.zeroEvents.listen((event) => zeroEvents.emit(event))
162
+ }
163
+
164
+ const ControlQueries = ({ children, ...props }: ControlQueriesProps) =>
165
+ clients.reduceRight(
166
+ (inner: ReactNode, client) => (
167
+ <client.ControlQueries {...props}>{inner}</client.ControlQueries>
168
+ ),
169
+ children,
170
+ )
171
+
172
+ const combined = {
173
+ useQuery,
174
+ usePermission,
175
+ zero,
176
+ preload,
177
+ getQuery,
178
+ run,
179
+ zeroEvents,
180
+ ControlQueries,
181
+ }
182
+
183
+ // boundary assertion: the dispatching wrappers are untyped internally; the
184
+ // combined type re-applies each client's real surface
185
+ return combined as unknown as CombinedZeroClients<Clients>
186
+ }
@@ -1,5 +1,13 @@
1
1
  import { useQuery as zeroUseQuery } from '@rocicorp/zero/react'
2
- import { useContext, useMemo, useRef, type Context } from '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'
3
11
 
4
12
  import { useZeroDebug } from './helpers/useZeroDebug'
5
13
  import { resolveQuery, type PlainQueryFn } from './resolveQuery'
@@ -41,6 +49,21 @@ export type UseQueryHook<Schema extends ZeroSchema> = {
41
49
 
42
50
  const EMPTY_RESPONSE = [null, { type: 'unknown' }] as never
43
51
 
52
+ // determine if useQuery-style args are (fn, params, options) or (fn, options)
53
+ function parseUseQueryArgs(paramsOrOptions: any, optionsArg: any) {
54
+ const hasParams =
55
+ optionsArg !== undefined ||
56
+ (paramsOrOptions &&
57
+ typeof paramsOrOptions === 'object' &&
58
+ !('enabled' in paramsOrOptions) &&
59
+ !('ttl' in paramsOrOptions))
60
+
61
+ return {
62
+ params: hasParams ? paramsOrOptions : undefined,
63
+ options: hasParams ? optionsArg : paramsOrOptions,
64
+ }
65
+ }
66
+
44
67
  export function createUseQuery<Schema extends ZeroSchema>({
45
68
  DisabledContext,
46
69
  customQueries,
@@ -54,17 +77,7 @@ export function createUseQuery<Schema extends ZeroSchema>({
54
77
  const [fn, paramsOrOptions, optionsArg] = args
55
78
 
56
79
  const { queryRequest, options } = useMemo(() => {
57
- // determine if this is with params or no params
58
- const hasParams =
59
- optionsArg !== undefined ||
60
- (paramsOrOptions &&
61
- typeof paramsOrOptions === 'object' &&
62
- !('enabled' in paramsOrOptions) &&
63
- !('ttl' in paramsOrOptions))
64
-
65
- const params = hasParams ? paramsOrOptions : undefined
66
- const opts = hasParams ? optionsArg : paramsOrOptions
67
-
80
+ const { params, options: opts } = parseUseQueryArgs(paramsOrOptions, optionsArg)
68
81
  const queryRequest = resolveQuery({ customQueries, fn, params })
69
82
 
70
83
  return { queryRequest, options: opts }
@@ -92,3 +105,153 @@ export function createUseQuery<Schema extends ZeroSchema>({
92
105
 
93
106
  return useQuery as UseQueryHook<Schema>
94
107
  }
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
+ zero: MaterializableZero,
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
+
161
+ // zero.materialize accepts a QueryRequest (it applies the instance context
162
+ // itself) — the same public api the upstream ViewWrapper uses
163
+ const view = zero.materialize(queryRequest, ttl === undefined ? undefined : { ttl })
164
+
165
+ // addListener fires immediately with current data, so the snapshot is
166
+ // populated before react's first getSnapshot. data must be cloned: the
167
+ // underlying view mutates it in place, and useSyncExternalStore requires
168
+ // immutable snapshots to detect changes.
169
+ view.addListener((data, resultType) => {
170
+ snapshot = [
171
+ data === undefined ? undefined : structuredClone(data),
172
+ { type: resultType },
173
+ ]
174
+ for (const listener of listeners) {
175
+ listener()
176
+ }
177
+ })
178
+
179
+ return {
180
+ subscribe: (notify) => {
181
+ listeners.add(notify)
182
+ return () => listeners.delete(notify)
183
+ },
184
+ getSnapshot: () => snapshot,
185
+ destroy: () => view.destroy(),
186
+ }
187
+ }
188
+
189
+ export function createUseQueryDirect<Schema extends ZeroSchema>({
190
+ DisabledContext,
191
+ customQueries,
192
+ getZero,
193
+ zeroVersion,
194
+ }: {
195
+ DisabledContext: Context<QueryControlMode>
196
+ customQueries: AnyQueryRegistry
197
+ // the owning instance's mounted zero (null before its provider mounts)
198
+ getZero: () => MaterializableZero | null
199
+ // bumps after commit whenever the mounted zero changes (mount, rotation)
200
+ zeroVersion: Emitter<number>
201
+ }): UseQueryHook<Schema> {
202
+ function useQueryDirect(...args: any[]): any {
203
+ const disableMode = useContext(DisabledContext)
204
+ const lastRef = useRef<any>(EMPTY_RESPONSE)
205
+ const [fn, paramsOrOptions, optionsArg] = args
206
+
207
+ const version = useEmitterValue(zeroVersion)
208
+ const { params, options } = parseUseQueryArgs(paramsOrOptions, optionsArg)
209
+
210
+ let enabled = true
211
+ let ttl: UseQueryOptions['ttl']
212
+ if (typeof options === 'boolean') {
213
+ enabled = options
214
+ } else if (options) {
215
+ enabled = options.enabled !== false
216
+ ttl = options.ttl
217
+ }
218
+
219
+ // key the view by VALUE (serialized params), not params object identity —
220
+ // there is no view dedup on this path, so an unstable inline params object
221
+ // must not re-materialize every render
222
+ const paramsKey = params === undefined ? '' : JSON.stringify(params)
223
+
224
+ const view = useMemo((): DirectView | null => {
225
+ const zero = getZero()
226
+ if (!enabled || !zero) return null
227
+ const queryRequest = resolveQuery({ customQueries, fn, params })
228
+ return createDirectView(zero, queryRequest, ttl)
229
+ // params is keyed by paramsKey; version re-materializes on a new zero
230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
231
+ }, [fn, paramsKey, enabled, ttl, version])
232
+
233
+ useEffect(() => {
234
+ if (!view) return
235
+ return () => view.destroy()
236
+ }, [view])
237
+
238
+ const out = useSyncExternalStore(
239
+ view ? view.subscribe : DISABLED_SUBSCRIBE,
240
+ view ? view.getSnapshot : getDisabledSnapshot,
241
+ view ? view.getSnapshot : getDisabledSnapshot,
242
+ )
243
+
244
+ if (!disableMode) {
245
+ lastRef.current = out
246
+ return out
247
+ }
248
+
249
+ if (disableMode === 'last-value') {
250
+ return lastRef.current
251
+ }
252
+
253
+ return EMPTY_RESPONSE
254
+ }
255
+
256
+ return useQueryDirect as UseQueryHook<Schema>
257
+ }
@@ -13,7 +13,13 @@ import {
13
13
  } from 'react'
14
14
 
15
15
  import { createPermissions } from './createPermissions'
16
- import { createUseQuery, type QueryControlMode } from './createUseQuery'
16
+ import {
17
+ createUseQuery,
18
+ createUseQueryDirect,
19
+ type QueryControlMode,
20
+ type UseQueryHook,
21
+ } from './createUseQuery'
22
+ import { registerClientInstance } from './instanceRegistry'
17
23
  import { createMutators } from './helpers/createMutators'
18
24
  import { getAuth } from './helpers/getAuth'
19
25
  import { getAllMutationsPermissions, getMutationsPermissions } from './modelRegistry'
@@ -22,7 +28,7 @@ import { resolveQuery, type PlainQueryFn } from './resolveQuery'
22
28
  import { setCustomQueries } from './run'
23
29
  import { getEnvironment, setAuthData, setEnvironment, setSchema } from './state'
24
30
  import { getRawWhere, setEvaluatingPermission } from './where'
25
- import { setRunner } from './zeroRunner'
31
+ import { setRunner, type ZeroRunner } from './zeroRunner'
26
32
  import { zql } from './zql'
27
33
 
28
34
  import type { AuthData, GenericModels, GetZeroMutators, ZeroEvent } from './types'
@@ -63,11 +69,16 @@ export function createZeroClient<
63
69
  models,
64
70
  groupedQueries,
65
71
  permissionStrategy = 'optimistic',
72
+ instanceName = 'default',
66
73
  }: {
67
74
  schema: Schema
68
75
  models: Models
69
76
  groupedQueries: GroupedQueries
70
77
  permissionStrategy?: PermissionStrategy
78
+ // names this client instance so multiple instances can coexist on one page.
79
+ // each query/mutator namespace is claimed by exactly one instance, and the
80
+ // ambient run() + the combineZeroClients facade dispatch by that claim.
81
+ instanceName?: string
71
82
  }) {
72
83
  type ZeroMutators = GetZeroMutators<Models>
73
84
  type ZeroInstance = Zero<Schema, ZeroMutators>
@@ -177,6 +188,15 @@ export function createZeroClient<
177
188
  // create the single shared CustomQuery registry
178
189
  const customQueries = defineQueries(wrappedNamespaces)
179
190
 
191
+ // claim this instance's query/mutator namespaces so the ambient run() and
192
+ // the combineZeroClients facade dispatch to the owning instance. the
193
+ // auto-generated 'permission' namespace stays unclaimed (per-instance).
194
+ const instance = registerClientInstance({
195
+ name: instanceName,
196
+ namespaces: Object.keys({ ...groupedQueries, ...models }),
197
+ customQueries,
198
+ })
199
+
180
200
  // register for global run() helper
181
201
  setCustomQueries(customQueries)
182
202
 
@@ -197,7 +217,20 @@ export function createZeroClient<
197
217
  },
198
218
  })
199
219
 
200
- const zeroEvents = createEmitter<ZeroEvent | null>('zero', null)
220
+ // emitter names are global keys (dev hmr cache) — scope them per instance
221
+ // so two instances never share cached values. the default name stays
222
+ // unchanged for single-instance back-compat.
223
+ const emitterScope = instanceName === 'default' ? '' : `:${instanceName}`
224
+
225
+ const zeroEvents = createEmitter<ZeroEvent | null>(`zero${emitterScope}`, null)
226
+
227
+ // bumped after commit whenever the mounted zero instance changes (first
228
+ // mount, identity change, client-state-not-found rotation) — the direct
229
+ // query path re-materializes its views against the new instance
230
+ const zeroInstanceVersion = createEmitter<number>(
231
+ `zero-instance-version${emitterScope}`,
232
+ 0,
233
+ )
201
234
 
202
235
  const AuthDataContext = createContext<AuthData>({} as AuthData)
203
236
 
@@ -206,50 +239,66 @@ export function createZeroClient<
206
239
  customQueries,
207
240
  })
208
241
 
242
+ // context-free counterpart: materializes directly on THIS instance's
243
+ // mounted zero instead of the nearest ZeroProvider context. used by
244
+ // combineZeroClients for instances whose provider is not innermost.
245
+ const useQueryDirect = createUseQueryDirect<Schema>({
246
+ DisabledContext,
247
+ customQueries,
248
+ getZero: () => latestZeroInstance,
249
+ zeroVersion: zeroInstanceVersion,
250
+ })
251
+
209
252
  // permission check uses a per-model synced query so server is authoritative
210
- // permissionStrategy controls client behavior before server responds
211
- function usePermission(
212
- table: TableName | (string & {}),
213
- objOrId: string | Partial<Row<any>> | undefined,
214
- enabled = typeof objOrId !== 'undefined',
215
- debug = false,
216
- ): boolean | null {
217
- const disableMode = useContext(DisabledContext)
218
- const lastRef = useRef<boolean | null>(null)
219
- const tableStr = table as string
220
- const checkFn = permissionCheckFns[tableStr]
221
-
222
- // include auth user ID in query args so zero-cache creates per-user
223
- // permission views (prevents dedup across different auth contexts)
224
- const auth = getAuth()
225
- const _uid = auth?.id || 'anon'
226
-
227
- const [data, status] = useQuery(
228
- checkFn as any,
229
- { objOrId: objOrId as any, _uid },
230
- { enabled: Boolean(!disableMode && enabled && objOrId && checkFn) },
231
- )
253
+ // permissionStrategy controls client behavior before server responds.
254
+ // built over a query hook so the facade can route permission checks down
255
+ // the same context vs direct path as the table's other queries.
256
+ const createUsePermission = (useQueryImpl: UseQueryHook<Schema>) =>
257
+ function usePermission(
258
+ table: TableName | (string & {}),
259
+ objOrId: string | Partial<Row<any>> | undefined,
260
+ enabled = typeof objOrId !== 'undefined',
261
+ debug = false,
262
+ ): boolean | null {
263
+ const disableMode = useContext(DisabledContext)
264
+ const lastRef = useRef<boolean | null>(null)
265
+ const tableStr = table as string
266
+ const checkFn = permissionCheckFns[tableStr]
267
+
268
+ // include auth user ID in query args so zero-cache creates per-user
269
+ // permission views (prevents dedup across different auth contexts)
270
+ const auth = getAuth()
271
+ const _uid = auth?.id || 'anon'
272
+
273
+ const [data, status] = useQueryImpl(
274
+ checkFn as any,
275
+ { objOrId: objOrId as any, _uid },
276
+ { enabled: Boolean(!disableMode && enabled && objOrId && checkFn) },
277
+ )
232
278
 
233
- if (debug) {
234
- console.info(`usePermission()`, { table, objOrId, data, status })
235
- }
279
+ if (debug) {
280
+ console.info(`usePermission()`, { table, objOrId, data, status })
281
+ }
236
282
 
237
- if (!objOrId) return false
283
+ if (!objOrId) return false
238
284
 
239
- // null while loading, then server's authoritative answer
240
- const result = status.type === 'unknown' ? null : Boolean(data)
285
+ // null while loading, then server's authoritative answer
286
+ const result = status.type === 'unknown' ? null : Boolean(data)
241
287
 
242
- if (!disableMode) {
243
- lastRef.current = result
244
- return result
245
- }
288
+ if (!disableMode) {
289
+ lastRef.current = result
290
+ return result
291
+ }
292
+
293
+ if (disableMode === 'last-value') {
294
+ return lastRef.current
295
+ }
246
296
 
247
- if (disableMode === 'last-value') {
248
- return lastRef.current
297
+ return null
249
298
  }
250
299
 
251
- return null
252
- }
300
+ const usePermission = createUsePermission(useQuery)
301
+ const usePermissionDirect = createUsePermission(useQueryDirect)
253
302
 
254
303
  // the zero instance lives OUTSIDE the react lifecycle. react destroys and
255
304
  // re-fires the effects of a committed tree on any suspense hide/reveal, and
@@ -272,7 +321,10 @@ export function createZeroClient<
272
321
  // callbacks coalesces into ONE rotation, re-armed when the fresh instance
273
322
  // is created — without it a new instance that immediately re-fires the
274
323
  // callback rotation-loops forever.
275
- const zeroInstanceGeneration = createEmitter<number>('zero-instance-generation', 0)
324
+ const zeroInstanceGeneration = createEmitter<number>(
325
+ `zero-instance-generation${emitterScope}`,
326
+ 0,
327
+ )
276
328
  let zeroRotationPending = false
277
329
 
278
330
  const ProvideZero = ({
@@ -420,10 +472,20 @@ export function createZeroClient<
420
472
  // we'll need to add that soon
421
473
  if (zeroInstance !== latestZeroInstance) {
422
474
  latestZeroInstance = zeroInstance
423
- // register runner for global run() helper
424
- setRunner((query, options) => zeroInstance.run(query as any, options))
475
+ const runner: ZeroRunner = (query, options) =>
476
+ zeroInstance.run(query as any, options)
477
+ // the instance-keyed runner is what run() dispatches owned namespaces
478
+ // to; the global runner stays as the ambient fallback (inline zql)
479
+ instance.runner = runner
480
+ setRunner(runner)
425
481
  }
426
482
 
483
+ // notify direct-path views after commit (emitting during render would
484
+ // setState other components mid-render)
485
+ useEffect(() => {
486
+ zeroInstanceVersion.emit(zeroInstanceVersion.value + 1)
487
+ }, [zeroInstance])
488
+
427
489
  return null
428
490
  }
429
491
 
@@ -506,11 +568,14 @@ export function createZeroClient<
506
568
  }
507
569
 
508
570
  return {
571
+ instanceName,
509
572
  zeroEvents,
510
573
  ProvideZero,
511
574
  ControlQueries,
512
575
  useQuery,
576
+ useQueryDirect,
513
577
  usePermission,
578
+ usePermissionDirect,
514
579
  zero,
515
580
  preload,
516
581
  getQuery,
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ 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'
11
12
  export * from './createZeroClient'
12
13
  export * from './createUseQuery'
13
14
  export * from './resolveQuery'