@asteby/metacore-runtime-react 39.3.0 → 42.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 +47 -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 +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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/dist/use-persisted-query.d.ts +53 -0
- package/dist/use-persisted-query.d.ts.map +1 -0
- package/dist/use-persisted-query.js +92 -0
- package/package.json +5 -5
- 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/__tests__/use-persisted-query.test.tsx +132 -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 +35 -0
- package/src/motion.ts +47 -0
- package/src/use-flip-animation.ts +157 -0
- package/src/use-optimistic-mutation.ts +218 -0
- package/src/use-persisted-query.ts +152 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import { motionDuration, motionEasing, prefersReducedMotion, } from './motion';
|
|
3
|
+
const DEFAULT_SELECTOR = '[data-flip-key]';
|
|
4
|
+
const MOTION_EASING_KEYS = { standard: 1, emphasized: 1, exit: 1 };
|
|
5
|
+
const defaultKeyOf = (el) => el.dataset.flipKey ?? el.getAttribute('href') ?? (el.textContent?.trim() || null);
|
|
6
|
+
/**
|
|
7
|
+
* FLIP reorder animation: when `trigger` changes, elements that exist before
|
|
8
|
+
* and after the change glide from their old position to the new one.
|
|
9
|
+
*
|
|
10
|
+
* Positions are snapshotted when the list is at rest (after mount, after each
|
|
11
|
+
* animation, and after pointer interaction inside the root), never in the
|
|
12
|
+
* render path, so a re-render costs nothing. Uses the Web Animations API on
|
|
13
|
+
* `transform` only; with prefers-reduced-motion the change is instant.
|
|
14
|
+
*/
|
|
15
|
+
export function useFlipAnimation(rootRef, trigger, options = {}) {
|
|
16
|
+
const { selector = DEFAULT_SELECTOR, keyOf = defaultKeyOf, scrollContainer, duration = 'moderate', easing = 'standard', disabled = false, } = options;
|
|
17
|
+
const positionsRef = useRef(null);
|
|
18
|
+
const triggerRef = useRef(trigger);
|
|
19
|
+
const configRef = useRef({ selector, keyOf, scrollContainer });
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
configRef.current = { selector, keyOf, scrollContainer };
|
|
22
|
+
});
|
|
23
|
+
const measure = () => {
|
|
24
|
+
const root = rootRef.current;
|
|
25
|
+
if (!root)
|
|
26
|
+
return null;
|
|
27
|
+
const { selector: sel, keyOf: key, scrollContainer: sc } = configRef.current;
|
|
28
|
+
const origin = (sc ? sc(root) : null) ?? root;
|
|
29
|
+
const box = origin.getBoundingClientRect();
|
|
30
|
+
const out = new Map();
|
|
31
|
+
root.querySelectorAll(sel).forEach((el) => {
|
|
32
|
+
const k = key(el);
|
|
33
|
+
if (!k || out.has(k))
|
|
34
|
+
return;
|
|
35
|
+
const r = el.getBoundingClientRect();
|
|
36
|
+
if (r.width === 0 && r.height === 0)
|
|
37
|
+
return;
|
|
38
|
+
out.set(k, {
|
|
39
|
+
top: r.top - box.top + origin.scrollTop,
|
|
40
|
+
left: r.left - box.left + origin.scrollLeft,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
return out;
|
|
44
|
+
};
|
|
45
|
+
const measureRef = useRef(measure);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
measureRef.current = measure;
|
|
48
|
+
});
|
|
49
|
+
// Snapshot at rest: after mount and after interactions that move things
|
|
50
|
+
// without a trigger change (expanding a collapsible, for instance).
|
|
51
|
+
// Listens on the document and resolves the root per event, so a ref whose
|
|
52
|
+
// element is swapped (remount, mobile sheet) keeps working.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
let timer = null;
|
|
55
|
+
const schedule = () => {
|
|
56
|
+
if (timer)
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
timer = setTimeout(() => {
|
|
59
|
+
positionsRef.current = measureRef.current();
|
|
60
|
+
}, 350);
|
|
61
|
+
};
|
|
62
|
+
const onInteraction = (e) => {
|
|
63
|
+
const root = rootRef.current;
|
|
64
|
+
if (root && e.target instanceof Node && root.contains(e.target))
|
|
65
|
+
schedule();
|
|
66
|
+
};
|
|
67
|
+
schedule();
|
|
68
|
+
document.addEventListener('pointerup', onInteraction, true);
|
|
69
|
+
document.addEventListener('keyup', onInteraction, true);
|
|
70
|
+
window.addEventListener('resize', schedule);
|
|
71
|
+
return () => {
|
|
72
|
+
if (timer)
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
document.removeEventListener('pointerup', onInteraction, true);
|
|
75
|
+
document.removeEventListener('keyup', onInteraction, true);
|
|
76
|
+
window.removeEventListener('resize', schedule);
|
|
77
|
+
};
|
|
78
|
+
}, [rootRef]);
|
|
79
|
+
useLayoutEffect(() => {
|
|
80
|
+
if (Object.is(triggerRef.current, trigger))
|
|
81
|
+
return;
|
|
82
|
+
triggerRef.current = trigger;
|
|
83
|
+
const before = positionsRef.current;
|
|
84
|
+
const root = rootRef.current;
|
|
85
|
+
if (!root)
|
|
86
|
+
return;
|
|
87
|
+
const after = measureRef.current();
|
|
88
|
+
positionsRef.current = after;
|
|
89
|
+
if (!before || !after || disabled || prefersReducedMotion())
|
|
90
|
+
return;
|
|
91
|
+
const durationMs = typeof duration === 'number' ? duration : motionDuration(duration);
|
|
92
|
+
if (durationMs <= 0)
|
|
93
|
+
return;
|
|
94
|
+
const timing = easing in MOTION_EASING_KEYS ? motionEasing(easing) : easing;
|
|
95
|
+
const { selector: sel, keyOf: key } = configRef.current;
|
|
96
|
+
// A stale snapshot can report huge jumps; those would read as a glitch.
|
|
97
|
+
const maxJump = root.clientHeight > 0 ? root.clientHeight * 1.5 : Infinity;
|
|
98
|
+
root.querySelectorAll(sel).forEach((el) => {
|
|
99
|
+
const k = key(el);
|
|
100
|
+
if (!k)
|
|
101
|
+
return;
|
|
102
|
+
const from = before.get(k);
|
|
103
|
+
const to = after.get(k);
|
|
104
|
+
if (!to || typeof el.animate !== 'function')
|
|
105
|
+
return;
|
|
106
|
+
if (!from) {
|
|
107
|
+
// Entered with this change: fade in instead of popping.
|
|
108
|
+
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: durationMs, easing: timing });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const dy = from.top - to.top;
|
|
112
|
+
const dx = from.left - to.left;
|
|
113
|
+
if (Math.abs(dy) < 1 && Math.abs(dx) < 1)
|
|
114
|
+
return;
|
|
115
|
+
if (Math.abs(dy) > maxJump)
|
|
116
|
+
return;
|
|
117
|
+
el.animate([
|
|
118
|
+
{ transform: `translate(${dx}px, ${dy}px)` },
|
|
119
|
+
{ transform: 'translate(0, 0)' },
|
|
120
|
+
], { duration: durationMs, easing: timing });
|
|
121
|
+
});
|
|
122
|
+
}, [trigger, rootRef, disabled, duration, easing]);
|
|
123
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type QueryKey, type UseQueryOptions, type UseQueryResult } from '@tanstack/react-query';
|
|
2
|
+
/**
|
|
3
|
+
* A versioned, scoped copy of a value in localStorage. Shell data that decides
|
|
4
|
+
* the first paint (navigation, the org's menu layout, installed presets,
|
|
5
|
+
* permissions) is read from here synchronously, so a reload paints the last
|
|
6
|
+
* known state instead of a default that reorders when the network answers.
|
|
7
|
+
*
|
|
8
|
+
* The entry lives under `<key>:v<version>:<scope>`. Bump `version` when the
|
|
9
|
+
* stored shape changes; old entries are simply never read again. `scope` keeps
|
|
10
|
+
* one org or user from seeing another's copy on a shared browser.
|
|
11
|
+
*/
|
|
12
|
+
export interface PersistedSnapshotOptions {
|
|
13
|
+
key: string;
|
|
14
|
+
version: number;
|
|
15
|
+
/** Entries older than this are ignored (default: 30 days). */
|
|
16
|
+
maxAgeMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface PersistedEntry<T> {
|
|
19
|
+
data: T;
|
|
20
|
+
/** When the data was fetched (ms epoch). */
|
|
21
|
+
ts: number;
|
|
22
|
+
}
|
|
23
|
+
export interface PersistedSnapshot<T> {
|
|
24
|
+
read(scope: string): PersistedEntry<T> | undefined;
|
|
25
|
+
write(scope: string, data: T, meta?: {
|
|
26
|
+
ts?: number;
|
|
27
|
+
}): void;
|
|
28
|
+
clear(scope: string): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function createPersistedSnapshot<T>({ key, version, maxAgeMs, }: PersistedSnapshotOptions): PersistedSnapshot<T>;
|
|
31
|
+
export type UsePersistedQueryOptions<TQueryFnData, TData, TQueryKey extends QueryKey> = UseQueryOptions<TQueryFnData, Error, TData, TQueryKey> & {
|
|
32
|
+
persist: {
|
|
33
|
+
snapshot: PersistedSnapshot<TQueryFnData>;
|
|
34
|
+
/**
|
|
35
|
+
* Org/user the data belongs to. Without a scope nothing is read or
|
|
36
|
+
* written (e.g. before sign-in).
|
|
37
|
+
*/
|
|
38
|
+
scope: string | null | undefined;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* `useQuery` seeded from a {@link PersistedSnapshot}: the first render already
|
|
43
|
+
* has the last fetched value (stale-while-revalidate), and every later value —
|
|
44
|
+
* a refetch, or a `setQueryData` from a mutation — is written back. The seed is
|
|
45
|
+
* stale from the start: every page load revalidates it once in the background,
|
|
46
|
+
* so a change made elsewhere (a plantilla applied, an addon installed,
|
|
47
|
+
* permissions edited) shows on the next load even inside `staleTime`.
|
|
48
|
+
*
|
|
49
|
+
* Persist the raw server payload and derive UI shapes with `select`; the
|
|
50
|
+
* snapshot must be JSON.
|
|
51
|
+
*/
|
|
52
|
+
export declare function usePersistedQuery<TQueryFnData, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(options: UsePersistedQueryOptions<TQueryFnData, TData, TQueryKey>): UseQueryResult<TData, Error>;
|
|
53
|
+
//# sourceMappingURL=use-persisted-query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-persisted-query.d.ts","sourceRoot":"","sources":["../src/use-persisted-query.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAA;AAE9B;;;;;;;;;GASG;AACH,MAAM,WAAW,wBAAwB;IACvC,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,MAAM,CAAA;IACf,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,IAAI,EAAE,CAAC,CAAA;IACP,4CAA4C;IAC5C,EAAE,EAAE,MAAM,CAAA;CACX;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC;IAClC,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,GAAG,SAAS,CAAA;IAClD,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IAC3D,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAYD,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,EACzC,GAAG,EACH,OAAO,EACP,QAA6B,GAC9B,EAAE,wBAAwB,GAAG,iBAAiB,CAAC,CAAC,CAAC,CAgCjD;AAED,MAAM,MAAM,wBAAwB,CAAC,YAAY,EAAE,KAAK,EAAE,SAAS,SAAS,QAAQ,IAAI,eAAe,CACrG,YAAY,EACZ,KAAK,EACL,KAAK,EACL,SAAS,CACV,GAAG;IACF,OAAO,EAAE;QACP,QAAQ,EAAE,iBAAiB,CAAC,YAAY,CAAC,CAAA;QACzC;;;WAGG;QACH,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;KACjC,CAAA;CACF,CAAA;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EACZ,KAAK,GAAG,YAAY,EACpB,SAAS,SAAS,QAAQ,GAAG,QAAQ,EAErC,OAAO,EAAE,wBAAwB,CAAC,YAAY,EAAE,KAAK,EAAE,SAAS,CAAC,GAChE,cAAc,CAAC,KAAK,EAAE,KAAK,CAAC,CA+B9B"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react';
|
|
2
|
+
import { useQuery, useQueryClient, } from '@tanstack/react-query';
|
|
3
|
+
const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
4
|
+
function storage() {
|
|
5
|
+
try {
|
|
6
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createPersistedSnapshot({ key, version, maxAgeMs = DEFAULT_MAX_AGE_MS, }) {
|
|
13
|
+
const slot = (scope) => `${key}:v${version}:${scope}`;
|
|
14
|
+
return {
|
|
15
|
+
read(scope) {
|
|
16
|
+
const s = storage();
|
|
17
|
+
if (!s)
|
|
18
|
+
return undefined;
|
|
19
|
+
try {
|
|
20
|
+
const raw = s.getItem(slot(scope));
|
|
21
|
+
if (!raw)
|
|
22
|
+
return undefined;
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (typeof parsed?.ts !== 'number' || !('data' in parsed))
|
|
25
|
+
return undefined;
|
|
26
|
+
if (Date.now() - parsed.ts > maxAgeMs)
|
|
27
|
+
return undefined;
|
|
28
|
+
return { data: parsed.data, ts: parsed.ts };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
write(scope, data, { ts = Date.now() } = {}) {
|
|
35
|
+
try {
|
|
36
|
+
storage()?.setItem(slot(scope), JSON.stringify({ data, ts }));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// quota / private mode: the snapshot is an optimization, never fatal
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
clear(scope) {
|
|
43
|
+
try {
|
|
44
|
+
storage()?.removeItem(slot(scope));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* ignore */
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `useQuery` seeded from a {@link PersistedSnapshot}: the first render already
|
|
54
|
+
* has the last fetched value (stale-while-revalidate), and every later value —
|
|
55
|
+
* a refetch, or a `setQueryData` from a mutation — is written back. The seed is
|
|
56
|
+
* stale from the start: every page load revalidates it once in the background,
|
|
57
|
+
* so a change made elsewhere (a plantilla applied, an addon installed,
|
|
58
|
+
* permissions edited) shows on the next load even inside `staleTime`.
|
|
59
|
+
*
|
|
60
|
+
* Persist the raw server payload and derive UI shapes with `select`; the
|
|
61
|
+
* snapshot must be JSON.
|
|
62
|
+
*/
|
|
63
|
+
export function usePersistedQuery(options) {
|
|
64
|
+
const { persist, ...queryOptions } = options;
|
|
65
|
+
const { snapshot, scope } = persist;
|
|
66
|
+
const seed = useMemo(() => (scope ? snapshot.read(scope) : undefined), [snapshot, scope]);
|
|
67
|
+
const query = useQuery({
|
|
68
|
+
...queryOptions,
|
|
69
|
+
...(seed && queryOptions.initialData === undefined
|
|
70
|
+
? { initialData: seed.data, initialDataUpdatedAt: 0 }
|
|
71
|
+
: {}),
|
|
72
|
+
});
|
|
73
|
+
const queryClient = useQueryClient();
|
|
74
|
+
const { dataUpdatedAt, status, isPlaceholderData } = query;
|
|
75
|
+
const queryKey = queryOptions.queryKey;
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!scope || status !== 'success' || isPlaceholderData)
|
|
78
|
+
return;
|
|
79
|
+
// Still the seed: nothing new to store.
|
|
80
|
+
if (dataUpdatedAt === 0)
|
|
81
|
+
return;
|
|
82
|
+
// `query.data` is the selected shape; persist the raw cache value.
|
|
83
|
+
const raw = queryClient.getQueryData(queryKey);
|
|
84
|
+
if (raw === undefined)
|
|
85
|
+
return;
|
|
86
|
+
snapshot.write(scope, raw, { ts: dataUpdatedAt });
|
|
87
|
+
// queryKey is compared by react-query's hash, not identity: a new array
|
|
88
|
+
// each render must not rewrite the snapshot.
|
|
89
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
+
}, [queryClient, scope, snapshot, seed, status, isPlaceholderData, dataUpdatedAt]);
|
|
91
|
+
return query;
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "42.0.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"zod": "^4.3.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@asteby/metacore-sdk": "^3.
|
|
30
|
-
"@asteby/metacore-ui": "^2.
|
|
29
|
+
"@asteby/metacore-sdk": "^3.9.0",
|
|
30
|
+
"@asteby/metacore-ui": "^2.22.0",
|
|
31
31
|
"@tanstack/react-query": ">=5",
|
|
32
32
|
"@tanstack/react-router": ">=1.100",
|
|
33
33
|
"@tanstack/react-table": ">=8",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@asteby/metacore-sdk": "3.
|
|
54
|
-
"@asteby/metacore-ui": "2.
|
|
53
|
+
"@asteby/metacore-sdk": "3.9.0",
|
|
54
|
+
"@asteby/metacore-ui": "2.22.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
|
+
})
|