@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.
- package/CHANGELOG.md +30 -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 +18 -3
- package/dist/attribute-classes.d.ts.map +1 -1
- package/dist/attribute-classes.js +47 -6
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +3 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/motion.d.ts +19 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +35 -0
- 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 +37 -1
- package/src/attribute-classes.ts +47 -5
- package/src/dialogs/dynamic-record.tsx +3 -2
- package/src/index.ts +27 -0
- package/src/motion.ts +47 -0
- package/src/use-flip-animation.ts +157 -0
- package/src/use-optimistic-mutation.ts +218 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type QueryKey } from '@tanstack/react-query';
|
|
2
|
+
export interface UseOptimisticMutationOptions<TData, TVariables, TCache> {
|
|
3
|
+
/** Query whose cached value the mutation changes. */
|
|
4
|
+
queryKey: QueryKey;
|
|
5
|
+
mutationFn: (variables: TVariables) => Promise<TData>;
|
|
6
|
+
/**
|
|
7
|
+
* Cache value to show while the request is in flight. Runs synchronously on
|
|
8
|
+
* `mutate`, so the UI repaints on the same frame as the click. Return
|
|
9
|
+
* `undefined` to leave the cache untouched.
|
|
10
|
+
*/
|
|
11
|
+
optimistic: (current: TCache | undefined, variables: TVariables) => TCache | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Cache value once the server confirms. Write/apply endpoints that return
|
|
14
|
+
* the resulting resource should map it here, so nothing is refetched.
|
|
15
|
+
* Default: keep the optimistic value.
|
|
16
|
+
*/
|
|
17
|
+
reconcile?: (data: TData, variables: TVariables, current: TCache | undefined) => TCache | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Coalesce bursts (drag and drop, sliders): the cache updates on every
|
|
20
|
+
* call, and only the last variables are sent once `debounceMs` pass
|
|
21
|
+
* without a new call. Pending work is flushed on unmount.
|
|
22
|
+
*/
|
|
23
|
+
debounceMs?: number;
|
|
24
|
+
/**
|
|
25
|
+
* A call equal to the one already in flight is ignored (double clicks).
|
|
26
|
+
* Default: structural equality via JSON.
|
|
27
|
+
*/
|
|
28
|
+
isEqual?: (a: TVariables, b: TVariables) => boolean;
|
|
29
|
+
/** Other queries to refresh in the background after a confirmed write. */
|
|
30
|
+
invalidate?: QueryKey[];
|
|
31
|
+
onSuccess?: (data: TData, variables: TVariables) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Runs after the cache was rolled back to the last confirmed value. Only
|
|
34
|
+
* the latest call reports: a superseded write that fails stays silent.
|
|
35
|
+
*/
|
|
36
|
+
onError?: (error: unknown, variables: TVariables) => void;
|
|
37
|
+
}
|
|
38
|
+
export interface UseOptimisticMutationResult<TVariables> {
|
|
39
|
+
/** Apply optimistically and persist. Safe to call repeatedly. */
|
|
40
|
+
mutate: (variables: TVariables) => void;
|
|
41
|
+
/** True from the first `mutate` until the last one settles. */
|
|
42
|
+
isPending: boolean;
|
|
43
|
+
/** Variables of the latest unconfirmed call (e.g. which card is applying). */
|
|
44
|
+
pendingVariables: TVariables | undefined;
|
|
45
|
+
error: unknown;
|
|
46
|
+
/** Re-send the variables of the last call that failed (toast "Reintentar"). */
|
|
47
|
+
retry: () => void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Optimistic write over a TanStack Query cache entry, with rollback.
|
|
51
|
+
*
|
|
52
|
+
* ```tsx
|
|
53
|
+
* const apply = useOptimisticMutation({
|
|
54
|
+
* queryKey: ['org-sidebar-layout'],
|
|
55
|
+
* mutationFn: (key: string) => api.post('/apply', { key }).then((r) => r.data),
|
|
56
|
+
* optimistic: (current, key) => ({ ...current, template_key: key }),
|
|
57
|
+
* reconcile: (server) => server,
|
|
58
|
+
* onError: (_e, key) => toast.error('No se aplicó', {
|
|
59
|
+
* action: { label: 'Reintentar', onClick: () => apply.mutate(key) },
|
|
60
|
+
* }),
|
|
61
|
+
* })
|
|
62
|
+
* <Card aria-busy={apply.pendingVariables === tpl.key} onClick={() => apply.mutate(tpl.key)} />
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* - The cache is patched before the request, so the screen answers at once.
|
|
66
|
+
* - Writes to the same `queryKey` run one at a time, in call order (mutation
|
|
67
|
+
* `scope`), so the last click is the last write the server sees.
|
|
68
|
+
* - Only the latest call decides what the cache shows: an older response
|
|
69
|
+
* never overwrites a newer optimistic value. If the latest call fails, the
|
|
70
|
+
* cache returns to the last value the server confirmed.
|
|
71
|
+
*/
|
|
72
|
+
export declare function useOptimisticMutation<TData, TVariables, TCache = TData>(options: UseOptimisticMutationOptions<TData, TVariables, TCache>): UseOptimisticMutationResult<TVariables>;
|
|
73
|
+
//# sourceMappingURL=use-optimistic-mutation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-optimistic-mutation.d.ts","sourceRoot":"","sources":["../src/use-optimistic-mutation.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,QAAQ,EACd,MAAM,uBAAuB,CAAA;AAE9B,MAAM,WAAW,4BAA4B,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM;IACrE,qDAAqD;IACrD,QAAQ,EAAE,QAAQ,CAAA;IAClB,UAAU,EAAE,CAAC,SAAS,EAAE,UAAU,KAAK,OAAO,CAAC,KAAK,CAAC,CAAA;IACrD;;;;OAIG;IACH,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,UAAU,KAAK,MAAM,GAAG,SAAS,CAAA;IACtF;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,KAAK,MAAM,GAAG,SAAS,CAAA;IACnG;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,UAAU,KAAK,OAAO,CAAA;IACnD,0EAA0E;IAC1E,UAAU,CAAC,EAAE,QAAQ,EAAE,CAAA;IACvB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,UAAU,KAAK,IAAI,CAAA;IACxD;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,KAAK,IAAI,CAAA;CAC1D;AAED,MAAM,WAAW,2BAA2B,CAAC,UAAU;IACrD,iEAAiE;IACjE,MAAM,EAAE,CAAC,SAAS,EAAE,UAAU,KAAK,IAAI,CAAA;IACvC,+DAA+D;IAC/D,SAAS,EAAE,OAAO,CAAA;IAClB,8EAA8E;IAC9E,gBAAgB,EAAE,UAAU,GAAG,SAAS,CAAA;IACxC,KAAK,EAAE,OAAO,CAAA;IACd,+EAA+E;IAC/E,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAMD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,GAAG,KAAK,EACrE,OAAO,EAAE,4BAA4B,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,CAAC,GAC/D,2BAA2B,CAAC,UAAU,CAAC,CAoIzC"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useMutation, useQueryClient, } from '@tanstack/react-query';
|
|
3
|
+
const jsonEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
4
|
+
/**
|
|
5
|
+
* Optimistic write over a TanStack Query cache entry, with rollback.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* const apply = useOptimisticMutation({
|
|
9
|
+
* queryKey: ['org-sidebar-layout'],
|
|
10
|
+
* mutationFn: (key: string) => api.post('/apply', { key }).then((r) => r.data),
|
|
11
|
+
* optimistic: (current, key) => ({ ...current, template_key: key }),
|
|
12
|
+
* reconcile: (server) => server,
|
|
13
|
+
* onError: (_e, key) => toast.error('No se aplicó', {
|
|
14
|
+
* action: { label: 'Reintentar', onClick: () => apply.mutate(key) },
|
|
15
|
+
* }),
|
|
16
|
+
* })
|
|
17
|
+
* <Card aria-busy={apply.pendingVariables === tpl.key} onClick={() => apply.mutate(tpl.key)} />
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* - The cache is patched before the request, so the screen answers at once.
|
|
21
|
+
* - Writes to the same `queryKey` run one at a time, in call order (mutation
|
|
22
|
+
* `scope`), so the last click is the last write the server sees.
|
|
23
|
+
* - Only the latest call decides what the cache shows: an older response
|
|
24
|
+
* never overwrites a newer optimistic value. If the latest call fails, the
|
|
25
|
+
* cache returns to the last value the server confirmed.
|
|
26
|
+
*/
|
|
27
|
+
export function useOptimisticMutation(options) {
|
|
28
|
+
const qc = useQueryClient();
|
|
29
|
+
const optionsRef = useRef(options);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
optionsRef.current = options;
|
|
32
|
+
});
|
|
33
|
+
const seqRef = useRef(0);
|
|
34
|
+
// Last value the server confirmed, captured before the first unconfirmed
|
|
35
|
+
// optimistic write. Null while nothing is in flight.
|
|
36
|
+
const confirmedRef = useRef(null);
|
|
37
|
+
const inFlightRef = useRef(undefined);
|
|
38
|
+
const timerRef = useRef(null);
|
|
39
|
+
const queuedRef = useRef(null);
|
|
40
|
+
const [pendingVariables, setPendingVariables] = useState(undefined);
|
|
41
|
+
const [error, setError] = useState(null);
|
|
42
|
+
const failedRef = useRef(null);
|
|
43
|
+
const scopeId = `optimistic:${JSON.stringify(options.queryKey)}`;
|
|
44
|
+
const settleLatest = useCallback(() => {
|
|
45
|
+
confirmedRef.current = null;
|
|
46
|
+
inFlightRef.current = undefined;
|
|
47
|
+
setPendingVariables(undefined);
|
|
48
|
+
}, []);
|
|
49
|
+
const mutation = useMutation({
|
|
50
|
+
scope: { id: scopeId },
|
|
51
|
+
mutationFn: ({ variables }) => optionsRef.current.mutationFn(variables),
|
|
52
|
+
onSuccess: (data, { variables, seq }) => {
|
|
53
|
+
const opts = optionsRef.current;
|
|
54
|
+
const latest = seq === seqRef.current && queuedRef.current === null;
|
|
55
|
+
if (latest) {
|
|
56
|
+
const current = qc.getQueryData(opts.queryKey);
|
|
57
|
+
const next = opts.reconcile ? opts.reconcile(data, variables, current) : current;
|
|
58
|
+
if (next !== undefined)
|
|
59
|
+
qc.setQueryData(opts.queryKey, next);
|
|
60
|
+
settleLatest();
|
|
61
|
+
setError(null);
|
|
62
|
+
for (const key of opts.invalidate ?? [])
|
|
63
|
+
void qc.invalidateQueries({ queryKey: key });
|
|
64
|
+
}
|
|
65
|
+
else if (confirmedRef.current) {
|
|
66
|
+
// A newer call owns the screen; only move the rollback point forward.
|
|
67
|
+
const base = confirmedRef.current.value;
|
|
68
|
+
const next = opts.reconcile
|
|
69
|
+
? opts.reconcile(data, variables, base)
|
|
70
|
+
: opts.optimistic(base, variables);
|
|
71
|
+
confirmedRef.current = { value: next ?? base };
|
|
72
|
+
}
|
|
73
|
+
opts.onSuccess?.(data, variables);
|
|
74
|
+
},
|
|
75
|
+
onError: (err, { variables, seq }) => {
|
|
76
|
+
const opts = optionsRef.current;
|
|
77
|
+
const latest = seq === seqRef.current && queuedRef.current === null;
|
|
78
|
+
// A superseded write that fails is moot: the newer call decides what
|
|
79
|
+
// the screen shows and whether the user sees an error.
|
|
80
|
+
if (!latest)
|
|
81
|
+
return;
|
|
82
|
+
const confirmed = confirmedRef.current;
|
|
83
|
+
if (confirmed && confirmed.value !== undefined) {
|
|
84
|
+
qc.setQueryData(opts.queryKey, confirmed.value);
|
|
85
|
+
}
|
|
86
|
+
settleLatest();
|
|
87
|
+
// The server may have partially applied; resync in the background.
|
|
88
|
+
void qc.invalidateQueries({ queryKey: opts.queryKey, exact: true });
|
|
89
|
+
failedRef.current = { variables };
|
|
90
|
+
setError(err);
|
|
91
|
+
opts.onError?.(err, variables);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const mutateRef = useRef(mutation.mutate);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
mutateRef.current = mutation.mutate;
|
|
97
|
+
});
|
|
98
|
+
const send = useCallback((envelope) => {
|
|
99
|
+
inFlightRef.current = envelope.variables;
|
|
100
|
+
mutateRef.current(envelope);
|
|
101
|
+
}, []);
|
|
102
|
+
const flush = useCallback(() => {
|
|
103
|
+
if (timerRef.current)
|
|
104
|
+
clearTimeout(timerRef.current);
|
|
105
|
+
timerRef.current = null;
|
|
106
|
+
const queued = queuedRef.current;
|
|
107
|
+
queuedRef.current = null;
|
|
108
|
+
if (queued)
|
|
109
|
+
send(queued);
|
|
110
|
+
}, [send]);
|
|
111
|
+
const mutate = useCallback((variables) => {
|
|
112
|
+
const opts = optionsRef.current;
|
|
113
|
+
const isEqual = opts.isEqual ?? jsonEqual;
|
|
114
|
+
const latestPending = queuedRef.current?.variables ?? inFlightRef.current;
|
|
115
|
+
if (latestPending !== undefined && isEqual(latestPending, variables))
|
|
116
|
+
return;
|
|
117
|
+
const seq = ++seqRef.current;
|
|
118
|
+
if (!confirmedRef.current) {
|
|
119
|
+
confirmedRef.current = { value: qc.getQueryData(opts.queryKey) };
|
|
120
|
+
}
|
|
121
|
+
// A refetch landing mid-write would paint the old value back.
|
|
122
|
+
void qc.cancelQueries({ queryKey: opts.queryKey, exact: true });
|
|
123
|
+
const next = opts.optimistic(qc.getQueryData(opts.queryKey), variables);
|
|
124
|
+
if (next !== undefined)
|
|
125
|
+
qc.setQueryData(opts.queryKey, next);
|
|
126
|
+
setPendingVariables(variables);
|
|
127
|
+
setError(null);
|
|
128
|
+
failedRef.current = null;
|
|
129
|
+
const envelope = { variables, seq };
|
|
130
|
+
if (opts.debounceMs && opts.debounceMs > 0) {
|
|
131
|
+
queuedRef.current = envelope;
|
|
132
|
+
if (timerRef.current)
|
|
133
|
+
clearTimeout(timerRef.current);
|
|
134
|
+
timerRef.current = setTimeout(flush, opts.debounceMs);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
send(envelope);
|
|
138
|
+
}, [qc, flush, send]);
|
|
139
|
+
// Never drop a debounced write because the editor closed.
|
|
140
|
+
useEffect(() => flush, [flush]);
|
|
141
|
+
const retry = useCallback(() => {
|
|
142
|
+
const failed = failedRef.current;
|
|
143
|
+
if (failed)
|
|
144
|
+
mutate(failed.variables);
|
|
145
|
+
}, [mutate]);
|
|
146
|
+
return {
|
|
147
|
+
mutate,
|
|
148
|
+
isPending: pendingVariables !== undefined,
|
|
149
|
+
pendingVariables,
|
|
150
|
+
error,
|
|
151
|
+
retry,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "41.0.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@asteby/metacore-sdk": "^3.8.0",
|
|
30
|
-
"@asteby/metacore-ui": "^2.
|
|
30
|
+
"@asteby/metacore-ui": "^2.21.0",
|
|
31
31
|
"@tanstack/react-query": ">=5",
|
|
32
32
|
"@tanstack/react-router": ">=1.100",
|
|
33
33
|
"@tanstack/react-table": ">=8",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@asteby/metacore-sdk": "3.8.0",
|
|
54
|
-
"@asteby/metacore-ui": "2.
|
|
54
|
+
"@asteby/metacore-ui": "2.21.0",
|
|
55
55
|
"@tanstack/react-query": "^5.95.0",
|
|
56
56
|
"@tanstack/react-router": "^1.168.0",
|
|
57
57
|
"@tanstack/react-table": "^8.20.0",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, afterEach, vi } from 'vitest'
|
|
3
|
+
import { motionDuration, motionEasing, MOTION_DEFAULTS } from '../motion'
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
document.documentElement.style.cssText = ''
|
|
7
|
+
vi.restoreAllMocks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('motion tokens', () => {
|
|
11
|
+
it('falls back to the theme defaults when the host defines no variables', () => {
|
|
12
|
+
expect(motionDuration('moderate')).toBe(MOTION_DEFAULTS.duration.moderate)
|
|
13
|
+
expect(motionEasing('standard')).toBe(MOTION_DEFAULTS.easing.standard)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('reads the host theme variables (ms and s)', () => {
|
|
17
|
+
document.documentElement.style.setProperty('--motion-duration-moderate', '0.3s')
|
|
18
|
+
document.documentElement.style.setProperty('--motion-duration-fast', '90ms')
|
|
19
|
+
document.documentElement.style.setProperty('--motion-ease-standard', 'linear')
|
|
20
|
+
expect(motionDuration('moderate')).toBe(300)
|
|
21
|
+
expect(motionDuration('fast')).toBe(90)
|
|
22
|
+
expect(motionEasing('standard')).toBe('linear')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('is zero under prefers-reduced-motion', () => {
|
|
26
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList)
|
|
27
|
+
expect(motionDuration('slow')).toBe(0)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
3
|
+
import { render } from '@testing-library/react'
|
|
4
|
+
import { useRef } from 'react'
|
|
5
|
+
import { useFlipAnimation } from '../use-flip-animation'
|
|
6
|
+
|
|
7
|
+
function List({ order, reduced }: { order: string[]; reduced?: boolean }) {
|
|
8
|
+
const ref = useRef<HTMLUListElement>(null)
|
|
9
|
+
useFlipAnimation(ref, order.join(','), { disabled: reduced })
|
|
10
|
+
return (
|
|
11
|
+
<ul ref={ref}>
|
|
12
|
+
{order.map((k) => (
|
|
13
|
+
<li key={k} data-flip-key={k}>
|
|
14
|
+
{k}
|
|
15
|
+
</li>
|
|
16
|
+
))}
|
|
17
|
+
</ul>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// happy-dom has no layout: place each item by its index in the list.
|
|
22
|
+
function fakeLayout() {
|
|
23
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
24
|
+
const parent = this.parentElement
|
|
25
|
+
const index = parent && this.dataset.flipKey ? Array.from(parent.children).indexOf(this) : 0
|
|
26
|
+
const top = this.dataset.flipKey ? index * 40 : 0
|
|
27
|
+
return { top, left: 0, width: 100, height: 40, right: 100, bottom: top + 40, x: 0, y: top, toJSON() {} } as DOMRect
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks()
|
|
33
|
+
vi.useRealTimers()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('useFlipAnimation', () => {
|
|
37
|
+
it('animates moved items from their old position', () => {
|
|
38
|
+
vi.useFakeTimers()
|
|
39
|
+
fakeLayout()
|
|
40
|
+
const animate = vi.fn()
|
|
41
|
+
HTMLElement.prototype.animate = animate as never
|
|
42
|
+
|
|
43
|
+
const view = render(<List order={['a', 'b', 'c']} />)
|
|
44
|
+
vi.advanceTimersByTime(400) // at-rest snapshot
|
|
45
|
+
view.rerender(<List order={['c', 'a', 'b']} />)
|
|
46
|
+
|
|
47
|
+
const moves = animate.mock.calls.map(([frames]) => (frames as Keyframe[])[0]!.transform)
|
|
48
|
+
// c: 80 → 0, a: 0 → 40, b: 40 → 80
|
|
49
|
+
expect(moves).toContain('translate(0px, 80px)')
|
|
50
|
+
expect(moves).toContain('translate(0px, -40px)')
|
|
51
|
+
expect(animate).toHaveBeenCalledTimes(3)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('stays still when disabled (reduced motion path)', () => {
|
|
55
|
+
vi.useFakeTimers()
|
|
56
|
+
fakeLayout()
|
|
57
|
+
const animate = vi.fn()
|
|
58
|
+
HTMLElement.prototype.animate = animate as never
|
|
59
|
+
|
|
60
|
+
const view = render(<List order={['a', 'b']} reduced />)
|
|
61
|
+
vi.advanceTimersByTime(400)
|
|
62
|
+
view.rerender(<List order={['b', 'a']} reduced />)
|
|
63
|
+
expect(animate).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('stays still under prefers-reduced-motion', () => {
|
|
67
|
+
vi.useFakeTimers()
|
|
68
|
+
fakeLayout()
|
|
69
|
+
const animate = vi.fn()
|
|
70
|
+
HTMLElement.prototype.animate = animate as never
|
|
71
|
+
vi.spyOn(window, 'matchMedia').mockImplementation(
|
|
72
|
+
(query: string) => ({ matches: query.includes('reduce'), media: query }) as MediaQueryList,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const view = render(<List order={['a', 'b']} />)
|
|
76
|
+
vi.advanceTimersByTime(400)
|
|
77
|
+
view.rerender(<List order={['b', 'a']} />)
|
|
78
|
+
expect(animate).not.toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('works with a root resolved from the DOM instead of a wrapper element', () => {
|
|
82
|
+
vi.useFakeTimers()
|
|
83
|
+
fakeLayout()
|
|
84
|
+
const animate = vi.fn()
|
|
85
|
+
HTMLElement.prototype.animate = animate as never
|
|
86
|
+
const domRef = {
|
|
87
|
+
get current() {
|
|
88
|
+
return document.querySelector<HTMLElement>('[data-slot="list"]')
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
function Resolved({ order }: { order: string[] }) {
|
|
92
|
+
useFlipAnimation(domRef, order.join(','))
|
|
93
|
+
return (
|
|
94
|
+
<ul data-slot="list">
|
|
95
|
+
{order.map((k) => (
|
|
96
|
+
<li key={k} data-flip-key={k}>
|
|
97
|
+
{k}
|
|
98
|
+
</li>
|
|
99
|
+
))}
|
|
100
|
+
</ul>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const view = render(<Resolved order={['a', 'b']} />)
|
|
105
|
+
vi.advanceTimersByTime(400)
|
|
106
|
+
view.rerender(<Resolved order={['b', 'a']} />)
|
|
107
|
+
expect(animate).toHaveBeenCalledTimes(2)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
3
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import { useOptimisticMutation } from '../use-optimistic-mutation'
|
|
7
|
+
|
|
8
|
+
type Doc = { template: string }
|
|
9
|
+
const KEY = ['layout']
|
|
10
|
+
|
|
11
|
+
function deferred<T>() {
|
|
12
|
+
let resolve!: (v: T) => void
|
|
13
|
+
let reject!: (e: unknown) => void
|
|
14
|
+
const promise = new Promise<T>((res, rej) => {
|
|
15
|
+
resolve = res
|
|
16
|
+
reject = rej
|
|
17
|
+
})
|
|
18
|
+
return { promise, resolve, reject }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setup(
|
|
22
|
+
mutationFn: (template: string) => Promise<Doc>,
|
|
23
|
+
extra: { debounceMs?: number; onError?: (e: unknown, v: string) => void } = {},
|
|
24
|
+
) {
|
|
25
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
|
26
|
+
qc.setQueryData<Doc>(KEY, { template: 'default' })
|
|
27
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
28
|
+
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
29
|
+
)
|
|
30
|
+
const hook = renderHook(
|
|
31
|
+
() =>
|
|
32
|
+
useOptimisticMutation<Doc, string, Doc>({
|
|
33
|
+
queryKey: KEY,
|
|
34
|
+
mutationFn,
|
|
35
|
+
optimistic: (_current, template) => ({ template }),
|
|
36
|
+
reconcile: (data) => data,
|
|
37
|
+
...extra,
|
|
38
|
+
}),
|
|
39
|
+
{ wrapper },
|
|
40
|
+
)
|
|
41
|
+
return { qc, hook }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.useRealTimers()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('useOptimisticMutation', () => {
|
|
49
|
+
it('patches the cache before the request resolves and reconciles with the response', async () => {
|
|
50
|
+
const req = deferred<Doc>()
|
|
51
|
+
const { qc, hook } = setup(() => req.promise)
|
|
52
|
+
|
|
53
|
+
act(() => hook.result.current.mutate('classic'))
|
|
54
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
55
|
+
expect(hook.result.current.isPending).toBe(true)
|
|
56
|
+
expect(hook.result.current.pendingVariables).toBe('classic')
|
|
57
|
+
|
|
58
|
+
await act(async () => req.resolve({ template: 'classic-server' }))
|
|
59
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
60
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic-server' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('rolls back to the confirmed value and reports the error', async () => {
|
|
64
|
+
const req = deferred<Doc>()
|
|
65
|
+
const onError = vi.fn()
|
|
66
|
+
const { qc, hook } = setup(() => req.promise, { onError })
|
|
67
|
+
|
|
68
|
+
act(() => hook.result.current.mutate('classic'))
|
|
69
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
70
|
+
|
|
71
|
+
await act(async () => req.reject(new Error('boom')))
|
|
72
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
73
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
74
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error), 'classic')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('ignores a repeated call while the same write is in flight (double click)', async () => {
|
|
78
|
+
const req = deferred<Doc>()
|
|
79
|
+
const fn = vi.fn(() => req.promise)
|
|
80
|
+
const { hook } = setup(fn)
|
|
81
|
+
|
|
82
|
+
act(() => {
|
|
83
|
+
hook.result.current.mutate('classic')
|
|
84
|
+
hook.result.current.mutate('classic')
|
|
85
|
+
})
|
|
86
|
+
await act(async () => req.resolve({ template: 'classic' }))
|
|
87
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
88
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('runs writes in call order and an older response never overwrites a newer choice', async () => {
|
|
92
|
+
const first = deferred<Doc>()
|
|
93
|
+
const second = deferred<Doc>()
|
|
94
|
+
const calls: string[] = []
|
|
95
|
+
const fn = vi.fn((t: string) => {
|
|
96
|
+
calls.push(t)
|
|
97
|
+
return t === 'classic' ? first.promise : second.promise
|
|
98
|
+
})
|
|
99
|
+
const { qc, hook } = setup(fn)
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
hook.result.current.mutate('classic')
|
|
103
|
+
hook.result.current.mutate('default')
|
|
104
|
+
})
|
|
105
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
106
|
+
|
|
107
|
+
await act(async () => first.resolve({ template: 'classic' }))
|
|
108
|
+
// Serialized: the second write starts only after the first settles.
|
|
109
|
+
await waitFor(() => expect(calls).toEqual(['classic', 'default']))
|
|
110
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
111
|
+
expect(hook.result.current.pendingVariables).toBe('default')
|
|
112
|
+
|
|
113
|
+
await act(async () => second.resolve({ template: 'default' }))
|
|
114
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
115
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('rolls back to the last server-confirmed value when only the newest write fails', async () => {
|
|
119
|
+
const first = deferred<Doc>()
|
|
120
|
+
const second = deferred<Doc>()
|
|
121
|
+
const { qc, hook } = setup((t) => (t === 'classic' ? first.promise : second.promise))
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
hook.result.current.mutate('classic')
|
|
125
|
+
hook.result.current.mutate('custom')
|
|
126
|
+
})
|
|
127
|
+
await act(async () => first.resolve({ template: 'classic' }))
|
|
128
|
+
await act(async () => second.reject(new Error('nope')))
|
|
129
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
130
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('keeps a superseded failure silent when the newest write succeeds', async () => {
|
|
134
|
+
const first = deferred<Doc>()
|
|
135
|
+
const second = deferred<Doc>()
|
|
136
|
+
const onError = vi.fn()
|
|
137
|
+
const { qc, hook } = setup((t) => (t === 'classic' ? first.promise : second.promise), { onError })
|
|
138
|
+
|
|
139
|
+
act(() => {
|
|
140
|
+
hook.result.current.mutate('classic')
|
|
141
|
+
hook.result.current.mutate('custom')
|
|
142
|
+
})
|
|
143
|
+
await act(async () => first.reject(new Error('stale')))
|
|
144
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'custom' })
|
|
145
|
+
await act(async () => second.resolve({ template: 'custom' }))
|
|
146
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
147
|
+
expect(onError).not.toHaveBeenCalled()
|
|
148
|
+
expect(hook.result.current.error).toBeNull()
|
|
149
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'custom' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('debounces bursts: every call paints, only the last one is sent', async () => {
|
|
153
|
+
vi.useFakeTimers()
|
|
154
|
+
const fn = vi.fn((t: string) => Promise.resolve({ template: t }))
|
|
155
|
+
const { qc, hook } = setup(fn, { debounceMs: 400 })
|
|
156
|
+
|
|
157
|
+
act(() => hook.result.current.mutate('a'))
|
|
158
|
+
act(() => hook.result.current.mutate('b'))
|
|
159
|
+
act(() => hook.result.current.mutate('c'))
|
|
160
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'c' })
|
|
161
|
+
expect(fn).not.toHaveBeenCalled()
|
|
162
|
+
|
|
163
|
+
await act(async () => {
|
|
164
|
+
vi.advanceTimersByTime(400)
|
|
165
|
+
})
|
|
166
|
+
vi.useRealTimers()
|
|
167
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
168
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
169
|
+
expect(fn).toHaveBeenCalledWith('c')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('flushes a pending debounced write on unmount', async () => {
|
|
173
|
+
vi.useFakeTimers()
|
|
174
|
+
const fn = vi.fn((t: string) => Promise.resolve({ template: t }))
|
|
175
|
+
const { hook } = setup(fn, { debounceMs: 400 })
|
|
176
|
+
|
|
177
|
+
act(() => hook.result.current.mutate('saved-on-close'))
|
|
178
|
+
hook.unmount()
|
|
179
|
+
vi.useRealTimers()
|
|
180
|
+
await waitFor(() => expect(fn).toHaveBeenCalledWith('saved-on-close'))
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('useOptimisticMutation retry', () => {
|
|
185
|
+
it('re-sends the failed variables and re-applies them optimistically', async () => {
|
|
186
|
+
let fail = true
|
|
187
|
+
const fn = vi.fn((t: string) => (fail ? Promise.reject(new Error('offline')) : Promise.resolve({ template: t })))
|
|
188
|
+
const { qc, hook } = setup(fn)
|
|
189
|
+
|
|
190
|
+
act(() => hook.result.current.mutate('classic'))
|
|
191
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
192
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'default' })
|
|
193
|
+
|
|
194
|
+
fail = false
|
|
195
|
+
act(() => hook.result.current.retry())
|
|
196
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
197
|
+
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
|
198
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
199
|
+
expect(qc.getQueryData(KEY)).toEqual({ template: 'classic' })
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import {
|
|
4
|
+
AgentResultView,
|
|
5
|
+
clearAgentResultRenderers,
|
|
6
|
+
listAgentResultRenderers,
|
|
7
|
+
registerAgentResultRenderer,
|
|
8
|
+
resolveAgentResultRenderer,
|
|
9
|
+
type AgentResultRendererProps,
|
|
10
|
+
} from './agent-result-registry'
|
|
11
|
+
|
|
12
|
+
const Generic = ({ result }: AgentResultRendererProps) => <span>generic:{String(result.title)}</span>
|
|
13
|
+
const Invoice = ({ result }: AgentResultRendererProps) => <span>invoice:{String(result.title)}</span>
|
|
14
|
+
|
|
15
|
+
afterEach(() => clearAgentResultRenderers())
|
|
16
|
+
|
|
17
|
+
describe('agent result registry', () => {
|
|
18
|
+
it('renders a result through the renderer of its kind', () => {
|
|
19
|
+
registerAgentResultRenderer('record', Generic)
|
|
20
|
+
const html = renderToStaticMarkup(
|
|
21
|
+
<AgentResultView result={{ kind: 'record', model: 'products', title: 'Llanta' }} />,
|
|
22
|
+
)
|
|
23
|
+
expect(html).toBe('<span>generic:Llanta</span>')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('prefers a model-specific renderer over the generic one', () => {
|
|
27
|
+
registerAgentResultRenderer('record', Generic)
|
|
28
|
+
registerAgentResultRenderer('record', Invoice, { model: 'invoices' })
|
|
29
|
+
expect(resolveAgentResultRenderer({ kind: 'record', model: 'invoices' })).toBe(Invoice)
|
|
30
|
+
expect(resolveAgentResultRenderer({ kind: 'record', model: 'products' })).toBe(Generic)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders nothing (never the raw payload) for an unknown kind', () => {
|
|
34
|
+
const html = renderToStaticMarkup(
|
|
35
|
+
<AgentResultView result={{ kind: 'mystery', secret: 'Model: warehouses' }} />,
|
|
36
|
+
)
|
|
37
|
+
expect(html).toBe('')
|
|
38
|
+
expect(
|
|
39
|
+
renderToStaticMarkup(
|
|
40
|
+
<AgentResultView result={{ kind: 'mystery' }} fallback={<i>sin vista</i>} />,
|
|
41
|
+
),
|
|
42
|
+
).toBe('<i>sin vista</i>')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('unregister only removes its own registration', () => {
|
|
46
|
+
const off = registerAgentResultRenderer('count', Generic)
|
|
47
|
+
registerAgentResultRenderer('count', Invoice)
|
|
48
|
+
off()
|
|
49
|
+
expect(resolveAgentResultRenderer({ kind: 'count' })).toBe(Invoice)
|
|
50
|
+
expect(listAgentResultRenderers()).toEqual(['count'])
|
|
51
|
+
})
|
|
52
|
+
})
|