@asteby/metacore-runtime-react 39.3.0 → 41.0.0

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.
@@ -0,0 +1,218 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import {
3
+ useMutation,
4
+ useQueryClient,
5
+ type QueryKey,
6
+ } from '@tanstack/react-query'
7
+
8
+ export interface UseOptimisticMutationOptions<TData, TVariables, TCache> {
9
+ /** Query whose cached value the mutation changes. */
10
+ queryKey: QueryKey
11
+ mutationFn: (variables: TVariables) => Promise<TData>
12
+ /**
13
+ * Cache value to show while the request is in flight. Runs synchronously on
14
+ * `mutate`, so the UI repaints on the same frame as the click. Return
15
+ * `undefined` to leave the cache untouched.
16
+ */
17
+ optimistic: (current: TCache | undefined, variables: TVariables) => TCache | undefined
18
+ /**
19
+ * Cache value once the server confirms. Write/apply endpoints that return
20
+ * the resulting resource should map it here, so nothing is refetched.
21
+ * Default: keep the optimistic value.
22
+ */
23
+ reconcile?: (data: TData, variables: TVariables, current: TCache | undefined) => TCache | undefined
24
+ /**
25
+ * Coalesce bursts (drag and drop, sliders): the cache updates on every
26
+ * call, and only the last variables are sent once `debounceMs` pass
27
+ * without a new call. Pending work is flushed on unmount.
28
+ */
29
+ debounceMs?: number
30
+ /**
31
+ * A call equal to the one already in flight is ignored (double clicks).
32
+ * Default: structural equality via JSON.
33
+ */
34
+ isEqual?: (a: TVariables, b: TVariables) => boolean
35
+ /** Other queries to refresh in the background after a confirmed write. */
36
+ invalidate?: QueryKey[]
37
+ onSuccess?: (data: TData, variables: TVariables) => void
38
+ /**
39
+ * Runs after the cache was rolled back to the last confirmed value. Only
40
+ * the latest call reports: a superseded write that fails stays silent.
41
+ */
42
+ onError?: (error: unknown, variables: TVariables) => void
43
+ }
44
+
45
+ export interface UseOptimisticMutationResult<TVariables> {
46
+ /** Apply optimistically and persist. Safe to call repeatedly. */
47
+ mutate: (variables: TVariables) => void
48
+ /** True from the first `mutate` until the last one settles. */
49
+ isPending: boolean
50
+ /** Variables of the latest unconfirmed call (e.g. which card is applying). */
51
+ pendingVariables: TVariables | undefined
52
+ error: unknown
53
+ /** Re-send the variables of the last call that failed (toast "Reintentar"). */
54
+ retry: () => void
55
+ }
56
+
57
+ type Envelope<TVariables> = { variables: TVariables; seq: number }
58
+
59
+ const jsonEqual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
60
+
61
+ /**
62
+ * Optimistic write over a TanStack Query cache entry, with rollback.
63
+ *
64
+ * ```tsx
65
+ * const apply = useOptimisticMutation({
66
+ * queryKey: ['org-sidebar-layout'],
67
+ * mutationFn: (key: string) => api.post('/apply', { key }).then((r) => r.data),
68
+ * optimistic: (current, key) => ({ ...current, template_key: key }),
69
+ * reconcile: (server) => server,
70
+ * onError: (_e, key) => toast.error('No se aplicó', {
71
+ * action: { label: 'Reintentar', onClick: () => apply.mutate(key) },
72
+ * }),
73
+ * })
74
+ * <Card aria-busy={apply.pendingVariables === tpl.key} onClick={() => apply.mutate(tpl.key)} />
75
+ * ```
76
+ *
77
+ * - The cache is patched before the request, so the screen answers at once.
78
+ * - Writes to the same `queryKey` run one at a time, in call order (mutation
79
+ * `scope`), so the last click is the last write the server sees.
80
+ * - Only the latest call decides what the cache shows: an older response
81
+ * never overwrites a newer optimistic value. If the latest call fails, the
82
+ * cache returns to the last value the server confirmed.
83
+ */
84
+ export function useOptimisticMutation<TData, TVariables, TCache = TData>(
85
+ options: UseOptimisticMutationOptions<TData, TVariables, TCache>,
86
+ ): UseOptimisticMutationResult<TVariables> {
87
+ const qc = useQueryClient()
88
+ const optionsRef = useRef(options)
89
+ useEffect(() => {
90
+ optionsRef.current = options
91
+ })
92
+
93
+ const seqRef = useRef(0)
94
+ // Last value the server confirmed, captured before the first unconfirmed
95
+ // optimistic write. Null while nothing is in flight.
96
+ const confirmedRef = useRef<{ value: TCache | undefined } | null>(null)
97
+ const inFlightRef = useRef<TVariables | undefined>(undefined)
98
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
99
+ const queuedRef = useRef<Envelope<TVariables> | null>(null)
100
+ const [pendingVariables, setPendingVariables] = useState<TVariables | undefined>(undefined)
101
+ const [error, setError] = useState<unknown>(null)
102
+ const failedRef = useRef<{ variables: TVariables } | null>(null)
103
+
104
+ const scopeId = `optimistic:${JSON.stringify(options.queryKey)}`
105
+
106
+ const settleLatest = useCallback(() => {
107
+ confirmedRef.current = null
108
+ inFlightRef.current = undefined
109
+ setPendingVariables(undefined)
110
+ }, [])
111
+
112
+ const mutation = useMutation<TData, unknown, Envelope<TVariables>>({
113
+ scope: { id: scopeId },
114
+ mutationFn: ({ variables }) => optionsRef.current.mutationFn(variables),
115
+ onSuccess: (data, { variables, seq }) => {
116
+ const opts = optionsRef.current
117
+ const latest = seq === seqRef.current && queuedRef.current === null
118
+ if (latest) {
119
+ const current = qc.getQueryData<TCache>(opts.queryKey)
120
+ const next = opts.reconcile ? opts.reconcile(data, variables, current) : current
121
+ if (next !== undefined) qc.setQueryData<TCache>(opts.queryKey, next)
122
+ settleLatest()
123
+ setError(null)
124
+ for (const key of opts.invalidate ?? []) void qc.invalidateQueries({ queryKey: key })
125
+ } else if (confirmedRef.current) {
126
+ // A newer call owns the screen; only move the rollback point forward.
127
+ const base = confirmedRef.current.value
128
+ const next = opts.reconcile
129
+ ? opts.reconcile(data, variables, base)
130
+ : opts.optimistic(base, variables)
131
+ confirmedRef.current = { value: next ?? base }
132
+ }
133
+ opts.onSuccess?.(data, variables)
134
+ },
135
+ onError: (err, { variables, seq }) => {
136
+ const opts = optionsRef.current
137
+ const latest = seq === seqRef.current && queuedRef.current === null
138
+ // A superseded write that fails is moot: the newer call decides what
139
+ // the screen shows and whether the user sees an error.
140
+ if (!latest) return
141
+ const confirmed = confirmedRef.current
142
+ if (confirmed && confirmed.value !== undefined) {
143
+ qc.setQueryData<TCache>(opts.queryKey, confirmed.value)
144
+ }
145
+ settleLatest()
146
+ // The server may have partially applied; resync in the background.
147
+ void qc.invalidateQueries({ queryKey: opts.queryKey, exact: true })
148
+ failedRef.current = { variables }
149
+ setError(err)
150
+ opts.onError?.(err, variables)
151
+ },
152
+ })
153
+
154
+ const mutateRef = useRef(mutation.mutate)
155
+ useEffect(() => {
156
+ mutateRef.current = mutation.mutate
157
+ })
158
+
159
+ const send = useCallback((envelope: Envelope<TVariables>) => {
160
+ inFlightRef.current = envelope.variables
161
+ mutateRef.current(envelope)
162
+ }, [])
163
+
164
+ const flush = useCallback(() => {
165
+ if (timerRef.current) clearTimeout(timerRef.current)
166
+ timerRef.current = null
167
+ const queued = queuedRef.current
168
+ queuedRef.current = null
169
+ if (queued) send(queued)
170
+ }, [send])
171
+
172
+ const mutate = useCallback(
173
+ (variables: TVariables) => {
174
+ const opts = optionsRef.current
175
+ const isEqual = opts.isEqual ?? jsonEqual
176
+ const latestPending = queuedRef.current?.variables ?? inFlightRef.current
177
+ if (latestPending !== undefined && isEqual(latestPending, variables)) return
178
+
179
+ const seq = ++seqRef.current
180
+ if (!confirmedRef.current) {
181
+ confirmedRef.current = { value: qc.getQueryData<TCache>(opts.queryKey) }
182
+ }
183
+ // A refetch landing mid-write would paint the old value back.
184
+ void qc.cancelQueries({ queryKey: opts.queryKey, exact: true })
185
+ const next = opts.optimistic(qc.getQueryData<TCache>(opts.queryKey), variables)
186
+ if (next !== undefined) qc.setQueryData<TCache>(opts.queryKey, next)
187
+ setPendingVariables(variables)
188
+ setError(null)
189
+ failedRef.current = null
190
+
191
+ const envelope = { variables, seq }
192
+ if (opts.debounceMs && opts.debounceMs > 0) {
193
+ queuedRef.current = envelope
194
+ if (timerRef.current) clearTimeout(timerRef.current)
195
+ timerRef.current = setTimeout(flush, opts.debounceMs)
196
+ return
197
+ }
198
+ send(envelope)
199
+ },
200
+ [qc, flush, send],
201
+ )
202
+
203
+ // Never drop a debounced write because the editor closed.
204
+ useEffect(() => flush, [flush])
205
+
206
+ const retry = useCallback(() => {
207
+ const failed = failedRef.current
208
+ if (failed) mutate(failed.variables)
209
+ }, [mutate])
210
+
211
+ return {
212
+ mutate,
213
+ isPending: pendingVariables !== undefined,
214
+ pendingVariables,
215
+ error,
216
+ retry,
217
+ }
218
+ }
package/tsconfig.json CHANGED
@@ -13,5 +13,5 @@
13
13
  "rootDir": "./src"
14
14
  },
15
15
  "include": ["src/**/*"],
16
- "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
16
+ "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**"]
17
17
  }