on-zero 0.4.13 → 0.4.14

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 (33) hide show
  1. package/dist/cjs/helpers/useMutation.cjs +114 -0
  2. package/dist/cjs/helpers/useMutation.native.js +166 -0
  3. package/dist/cjs/helpers/useMutation.native.js.map +1 -0
  4. package/dist/cjs/helpers/useMutation.test.cjs +139 -0
  5. package/dist/cjs/helpers/useMutation.test.native.js +156 -0
  6. package/dist/cjs/helpers/useMutation.test.native.js.map +1 -0
  7. package/dist/cjs/index.cjs +1 -0
  8. package/dist/cjs/index.native.js +1 -0
  9. package/dist/cjs/index.native.js.map +1 -1
  10. package/dist/esm/helpers/useMutation.mjs +87 -0
  11. package/dist/esm/helpers/useMutation.mjs.map +1 -0
  12. package/dist/esm/helpers/useMutation.native.js +136 -0
  13. package/dist/esm/helpers/useMutation.native.js.map +1 -0
  14. package/dist/esm/helpers/useMutation.test.mjs +140 -0
  15. package/dist/esm/helpers/useMutation.test.mjs.map +1 -0
  16. package/dist/esm/helpers/useMutation.test.native.js +154 -0
  17. package/dist/esm/helpers/useMutation.test.native.js.map +1 -0
  18. package/dist/esm/index.js +1 -0
  19. package/dist/esm/index.js.map +1 -1
  20. package/dist/esm/index.mjs +1 -0
  21. package/dist/esm/index.mjs.map +1 -1
  22. package/dist/esm/index.native.js +1 -0
  23. package/dist/esm/index.native.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/helpers/useMutation.test.ts +117 -0
  26. package/src/helpers/useMutation.tsx +170 -0
  27. package/src/index.ts +1 -0
  28. package/types/helpers/useMutation.d.ts +47 -0
  29. package/types/helpers/useMutation.d.ts.map +1 -0
  30. package/types/helpers/useMutation.test.d.ts +2 -0
  31. package/types/helpers/useMutation.test.d.ts.map +1 -0
  32. package/types/index.d.ts +1 -0
  33. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,117 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ import { observeMutation, onMutationError, type MutationError } from './useMutation'
4
+
5
+ // a controllable stand-in for Zero's MutatorResult { client, server }.
6
+ function deferred<T>() {
7
+ let resolve!: (v: T) => void
8
+ let reject!: (e: unknown) => void
9
+ const promise = new Promise<T>((res, rej) => {
10
+ resolve = res
11
+ reject = rej
12
+ })
13
+ return { promise, resolve, reject }
14
+ }
15
+
16
+ function fakeResult() {
17
+ const client = deferred<unknown>()
18
+ const server = deferred<unknown>()
19
+ return {
20
+ result: { client: client.promise, server: server.promise },
21
+ client,
22
+ server,
23
+ }
24
+ }
25
+
26
+ const SUCCESS = { type: 'success' as const }
27
+ const appError = (message: string) => ({
28
+ type: 'error' as const,
29
+ error: { type: 'app' as const, message },
30
+ })
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks()
34
+ })
35
+
36
+ describe('observeMutation', () => {
37
+ test('success on both phases reports nothing', async () => {
38
+ const { result, client, server } = fakeResult()
39
+ const errors: MutationError[] = []
40
+ const dispose = onMutationError((e) => errors.push(e))
41
+
42
+ const done = observeMutation(result, (e) => errors.push(e))
43
+ client.resolve(SUCCESS)
44
+ server.resolve(SUCCESS)
45
+ await done
46
+
47
+ expect(errors).toEqual([])
48
+ dispose()
49
+ })
50
+
51
+ test('a server rejection surfaces a normalized error locally and globally', async () => {
52
+ const { result, client, server } = fakeResult()
53
+ const local: MutationError[] = []
54
+ const global: MutationError[] = []
55
+ const dispose = onMutationError((e) => global.push(e))
56
+
57
+ const done = observeMutation(result, (e) => local.push(e))
58
+ // optimistic phase succeeds, authoritative phase is rejected by the server
59
+ client.resolve(SUCCESS)
60
+ server.resolve(appError('Could not create post.'))
61
+ await done
62
+
63
+ expect(local).toEqual([
64
+ { scope: 'server', kind: 'app', message: 'Could not create post.', details: undefined },
65
+ ])
66
+ expect(global).toEqual(local)
67
+ dispose()
68
+ })
69
+
70
+ test('client + server both failing emits to the global catch only once', async () => {
71
+ const { result, client, server } = fakeResult()
72
+ const global: MutationError[] = []
73
+ const dispose = onMutationError((e) => global.push(e))
74
+
75
+ // a thrown optimistic mutator rejects both phases
76
+ const done = observeMutation(result)
77
+ client.reject(new Error('boom'))
78
+ server.reject(new Error('boom'))
79
+ await done
80
+
81
+ expect(global).toHaveLength(1)
82
+ expect(global[0]).toMatchObject({ scope: 'client', kind: 'zero', message: 'boom' })
83
+ dispose()
84
+ })
85
+
86
+ test('never rejects even when both phases reject', async () => {
87
+ const { result, client, server } = fakeResult()
88
+ client.reject(new Error('x'))
89
+ server.reject(new Error('x'))
90
+ await expect(observeMutation(result)).resolves.toBeUndefined()
91
+ })
92
+
93
+ test('with no listener registered it falls back to console.error in dev', async () => {
94
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
95
+ const { result, client, server } = fakeResult()
96
+ const done = observeMutation(result)
97
+ client.resolve(SUCCESS)
98
+ server.resolve(appError('denied'))
99
+ await done
100
+ expect(spy).toHaveBeenCalledTimes(1)
101
+ })
102
+ })
103
+
104
+ describe('onMutationError', () => {
105
+ test('dispose stops delivery', async () => {
106
+ const seen: MutationError[] = []
107
+ const dispose = onMutationError((e) => seen.push(e))
108
+ dispose()
109
+
110
+ const { result, client, server } = fakeResult()
111
+ const done = observeMutation(result)
112
+ client.resolve(SUCCESS)
113
+ server.resolve(appError('denied'))
114
+ await done
115
+ expect(seen).toEqual([])
116
+ })
117
+ })
@@ -0,0 +1,170 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ // a Zero mutator call returns this — two promises for the optimistic-local and
4
+ // the authoritative-server phases. we never make callers await either one.
5
+ type MutatorResultLike = {
6
+ client: Promise<unknown>
7
+ server: Promise<unknown>
8
+ }
9
+
10
+ // normalized error from either phase. `scope` says which run failed:
11
+ // - 'client': the optimistic mutator threw locally (basically a real bug)
12
+ // - 'server': the authoritative run rejected (permission/validation) and Zero
13
+ // rolled the optimistic write back
14
+ // `kind` mirrors Zero's MutatorResultDetails error.type ('app' | 'zero').
15
+ export type MutationError = {
16
+ scope: 'client' | 'server'
17
+ kind: 'app' | 'zero'
18
+ message: string
19
+ details?: unknown
20
+ }
21
+
22
+ export type MutationState = {
23
+ // a server round-trip from the latest call is in flight. only for guarding a
24
+ // re-submit or a subtle "saving" affordance — never gate the rendered result
25
+ // on this, the optimistic store already updated the UI.
26
+ pending: boolean
27
+ // latest-call error (client or server), or null. render it inline.
28
+ error: MutationError | null
29
+ reset: () => void
30
+ }
31
+
32
+ // global catch so a fire-and-forget mutation can never silently swallow a server
33
+ // rejection. apps register a handler (toast/log); with none registered we still
34
+ // surface in dev so a swallowed error is impossible.
35
+ const mutationErrorListeners = new Set<(error: MutationError) => void>()
36
+
37
+ export function onMutationError(cb: (error: MutationError) => void): () => void {
38
+ mutationErrorListeners.add(cb)
39
+ return () => {
40
+ mutationErrorListeners.delete(cb)
41
+ }
42
+ }
43
+
44
+ function emitMutationError(error: MutationError): void {
45
+ if (mutationErrorListeners.size === 0) {
46
+ if (process.env.NODE_ENV !== 'production') {
47
+ console.error('[on-zero] unhandled mutation error', error)
48
+ }
49
+ return
50
+ }
51
+ for (const cb of mutationErrorListeners) cb(error)
52
+ }
53
+
54
+ function toMutationError(
55
+ scope: 'client' | 'server',
56
+ details: unknown,
57
+ ): MutationError | null {
58
+ if (!details || typeof details !== 'object') return null
59
+ const d = details as {
60
+ type?: string
61
+ error?: { type?: string; message?: string; details?: unknown }
62
+ }
63
+ if (d.type !== 'error') return null
64
+ return {
65
+ scope,
66
+ kind: d.error?.type === 'app' ? 'app' : 'zero',
67
+ message: d.error?.message || 'Mutation failed',
68
+ details: d.error?.details,
69
+ }
70
+ }
71
+
72
+ function rejectionToError(scope: 'client' | 'server', e: unknown): MutationError {
73
+ return {
74
+ scope,
75
+ kind: 'zero',
76
+ message: e instanceof Error ? e.message : String(e),
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Wire a mutation result's optimistic-client and authoritative-server phases to
82
+ * normalized errors, without awaiting either. Every error reaches the global
83
+ * `onMutationError` catch (deduped — client and server can surface the same
84
+ * failure); an optional `onError` sink receives them too (the hook uses it for
85
+ * local state). Use this directly for a fire-and-forget call outside React:
86
+ *
87
+ * observeMutation(zero.mutate.post.delete({ id }))
88
+ *
89
+ * Resolves once both phases settle. Never rejects.
90
+ */
91
+ export function observeMutation(
92
+ result: MutatorResultLike,
93
+ onError?: (error: MutationError) => void,
94
+ ): Promise<void> {
95
+ let reportedGlobal = false
96
+ const report = (err: MutationError | null) => {
97
+ if (!err) return
98
+ onError?.(err)
99
+ // first error per call reaches the global catch
100
+ if (!reportedGlobal) {
101
+ reportedGlobal = true
102
+ emitMutationError(err)
103
+ }
104
+ }
105
+ const client = result.client
106
+ .then((d) => report(toMutationError('client', d)))
107
+ .catch((e) => report(rejectionToError('client', e)))
108
+ const server = result.server
109
+ .then((d) => report(toMutationError('server', d)))
110
+ .catch((e) => report(rejectionToError('server', e)))
111
+ return Promise.all([client, server]).then(() => undefined)
112
+ }
113
+
114
+ /**
115
+ * Bind one Zero mutator to local pending/error state without ever awaiting it.
116
+ *
117
+ * const [insertPost, state] = useMutation(zero.mutate.post.insert)
118
+ * insertPost({ ... }) // fires optimistically, returns immediately
119
+ * state.error // render inline; client OR server failures land here
120
+ * state.pending // only to guard a re-submit, not to gate the UI
121
+ *
122
+ * The returned mutator has the exact same signature as the one passed in, so arg
123
+ * types are preserved. It returns Zero's native `{ client, server }` for the rare
124
+ * authoritative-wait escape hatch — product code should not await it. Every error
125
+ * also flows to `onMutationError` so a fire-and-forget call is never silent.
126
+ *
127
+ * For N writes use one custom mutator that loops `tx.mutate` in a single
128
+ * transaction, not N calls — that keeps it atomic and a single state.
129
+ */
130
+ export function useMutation<Fn extends (...args: any[]) => MutatorResultLike>(
131
+ mutator: Fn,
132
+ ): [Fn, MutationState] {
133
+ const [pending, setPending] = useState(false)
134
+ const [error, setError] = useState<MutationError | null>(null)
135
+ const seqRef = useRef(0)
136
+ const mountedRef = useRef(true)
137
+ const mutatorRef = useRef(mutator)
138
+ mutatorRef.current = mutator
139
+
140
+ useEffect(() => {
141
+ mountedRef.current = true
142
+ return () => {
143
+ mountedRef.current = false
144
+ }
145
+ }, [])
146
+
147
+ const reset = useCallback(() => {
148
+ setError(null)
149
+ setPending(false)
150
+ }, [])
151
+
152
+ const run = useCallback((...args: any[]) => {
153
+ const result = mutatorRef.current(...args)
154
+ const seq = ++seqRef.current
155
+ setPending(true)
156
+ setError(null)
157
+
158
+ // latest-wins: only the most recent call's error/pending touch state
159
+ const isCurrent = () => mountedRef.current && seq === seqRef.current
160
+ observeMutation(result, (err) => {
161
+ if (isCurrent()) setError(err)
162
+ }).finally(() => {
163
+ if (isCurrent()) setPending(false)
164
+ })
165
+
166
+ return result
167
+ }, []) as Fn
168
+
169
+ return [run, { pending, error, reset }]
170
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from './helpers/batchQuery'
4
4
  export * from './helpers/createMutators'
5
5
  export * from './helpers/ensureLoggedIn'
6
6
  export * from './helpers/mutatorContext'
7
+ export * from './helpers/useMutation'
7
8
  export { ensureAuth, getAuth } from './helpers/getAuth'
8
9
  export { setAuthData, setEnvironment } from './state'
9
10
 
@@ -0,0 +1,47 @@
1
+ type MutatorResultLike = {
2
+ client: Promise<unknown>;
3
+ server: Promise<unknown>;
4
+ };
5
+ export type MutationError = {
6
+ scope: 'client' | 'server';
7
+ kind: 'app' | 'zero';
8
+ message: string;
9
+ details?: unknown;
10
+ };
11
+ export type MutationState = {
12
+ pending: boolean;
13
+ error: MutationError | null;
14
+ reset: () => void;
15
+ };
16
+ export declare function onMutationError(cb: (error: MutationError) => void): () => void;
17
+ /**
18
+ * Wire a mutation result's optimistic-client and authoritative-server phases to
19
+ * normalized errors, without awaiting either. Every error reaches the global
20
+ * `onMutationError` catch (deduped — client and server can surface the same
21
+ * failure); an optional `onError` sink receives them too (the hook uses it for
22
+ * local state). Use this directly for a fire-and-forget call outside React:
23
+ *
24
+ * observeMutation(zero.mutate.post.delete({ id }))
25
+ *
26
+ * Resolves once both phases settle. Never rejects.
27
+ */
28
+ export declare function observeMutation(result: MutatorResultLike, onError?: (error: MutationError) => void): Promise<void>;
29
+ /**
30
+ * Bind one Zero mutator to local pending/error state without ever awaiting it.
31
+ *
32
+ * const [insertPost, state] = useMutation(zero.mutate.post.insert)
33
+ * insertPost({ ... }) // fires optimistically, returns immediately
34
+ * state.error // render inline; client OR server failures land here
35
+ * state.pending // only to guard a re-submit, not to gate the UI
36
+ *
37
+ * The returned mutator has the exact same signature as the one passed in, so arg
38
+ * types are preserved. It returns Zero's native `{ client, server }` for the rare
39
+ * authoritative-wait escape hatch — product code should not await it. Every error
40
+ * also flows to `onMutationError` so a fire-and-forget call is never silent.
41
+ *
42
+ * For N writes use one custom mutator that loops `tx.mutate` in a single
43
+ * transaction, not N calls — that keeps it atomic and a single state.
44
+ */
45
+ export declare function useMutation<Fn extends (...args: any[]) => MutatorResultLike>(mutator: Fn): [Fn, MutationState];
46
+ export {};
47
+ //# sourceMappingURL=useMutation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useMutation.d.ts","sourceRoot":"","sources":["../../src/helpers/useMutation.tsx"],"names":[],"mappings":"AAIA,KAAK,iBAAiB,GAAG;IACvB,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,CAAA;IACxB,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,CAAA;CACzB,CAAA;AAOD,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,QAAQ,GAAG,QAAQ,CAAA;IAC1B,IAAI,EAAE,KAAK,GAAG,MAAM,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAI1B,OAAO,EAAE,OAAO,CAAA;IAEhB,KAAK,EAAE,aAAa,GAAG,IAAI,CAAA;IAC3B,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB,CAAA;AAOD,wBAAgB,eAAe,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GAAG,MAAM,IAAI,CAK9E;AAsCD;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,iBAAiB,EACzB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GACvC,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,EAAE,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,iBAAiB,EAC1E,OAAO,EAAE,EAAE,GACV,CAAC,EAAE,EAAE,aAAa,CAAC,CAsCrB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=useMutation.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useMutation.test.d.ts","sourceRoot":"","sources":["../../src/helpers/useMutation.test.ts"],"names":[],"mappings":""}
package/types/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './helpers/batchQuery';
4
4
  export * from './helpers/createMutators';
5
5
  export * from './helpers/ensureLoggedIn';
6
6
  export * from './helpers/mutatorContext';
7
+ export * from './helpers/useMutation';
7
8
  export { ensureAuth, getAuth } from './helpers/getAuth';
8
9
  export { setAuthData, setEnvironment } from './state';
9
10
  export * from './createZeroClient';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AACnC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAErD,cAAc,oBAAoB,CAAA;AAClC,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,OAAO,CAAA;AACrB,OAAO,EAAE,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAA;AACzD,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,eAAe,CAAA;AAC7B,cAAc,OAAO,CAAA;AACrB,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAM3D,mBAAmB,SAAS,CAAA;AAE5B,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,GAChC,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,KAAK,0BAA0B,EAC/B,KAAK,mBAAmB,GACzB,MAAM,+BAA+B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AACnC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,cAAc,uBAAuB,CAAA;AACrC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAErD,cAAc,oBAAoB,CAAA;AAClC,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,OAAO,CAAA;AACrB,OAAO,EAAE,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAA;AACzD,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,eAAe,CAAA;AAC7B,cAAc,OAAO,CAAA;AACrB,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAM3D,mBAAmB,SAAS,CAAA;AAE5B,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,GAChC,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,KAAK,0BAA0B,EAC/B,KAAK,mBAAmB,GACzB,MAAM,+BAA+B,CAAA"}