on-zero 0.4.22 → 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 +89 -12
  8. package/dist/cjs/createZeroClient.native.js +134 -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 +93 -16
  37. package/dist/esm/createZeroClient.mjs.map +1 -1
  38. package/dist/esm/createZeroClient.native.js +138 -47
  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 +227 -54
  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 +10 -4
  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
+ }