@asteby/metacore-runtime-react 39.2.10 → 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.
- package/CHANGELOG.md +36 -0
- package/dist/agent-result-registry.d.ts +44 -0
- package/dist/agent-result-registry.d.ts.map +1 -0
- package/dist/agent-result-registry.js +78 -0
- package/dist/attribute-classes.d.ts +46 -0
- package/dist/attribute-classes.d.ts.map +1 -0
- package/dist/attribute-classes.js +124 -0
- package/dist/dialogs/dynamic-record.d.ts +2 -2
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +14 -9
- package/dist/dynamic-form-schema.d.ts +6 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +12 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/motion.d.ts +19 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +35 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/use-flip-animation.d.ts +30 -0
- package/dist/use-flip-animation.d.ts.map +1 -0
- package/dist/use-flip-animation.js +123 -0
- package/dist/use-optimistic-mutation.d.ts +73 -0
- package/dist/use-optimistic-mutation.d.ts.map +1 -0
- package/dist/use-optimistic-mutation.js +153 -0
- package/package.json +3 -3
- package/src/__tests__/motion.test.ts +29 -0
- package/src/__tests__/use-flip-animation.test.tsx +109 -0
- package/src/__tests__/use-optimistic-mutation.test.tsx +201 -0
- package/src/agent-result-registry.test.tsx +52 -0
- package/src/agent-result-registry.tsx +129 -0
- package/src/attribute-classes.test.ts +79 -0
- package/src/attribute-classes.ts +147 -0
- package/src/dialogs/dynamic-record.tsx +22 -7
- package/src/dynamic-form-schema.ts +13 -1
- package/src/index.ts +35 -0
- package/src/motion.ts +47 -0
- package/src/types.ts +8 -1
- package/src/use-flip-animation.ts +157 -0
- package/src/use-optimistic-mutation.ts +218 -0
- package/tsconfig.json +1 -1
|
@@ -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