@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/agent-result-registry.d.ts +44 -0
  3. package/dist/agent-result-registry.d.ts.map +1 -0
  4. package/dist/agent-result-registry.js +78 -0
  5. package/dist/attribute-classes.d.ts +18 -3
  6. package/dist/attribute-classes.d.ts.map +1 -1
  7. package/dist/attribute-classes.js +47 -6
  8. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  9. package/dist/dialogs/dynamic-record.js +3 -2
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -0
  13. package/dist/motion.d.ts +19 -0
  14. package/dist/motion.d.ts.map +1 -0
  15. package/dist/motion.js +35 -0
  16. package/dist/use-flip-animation.d.ts +30 -0
  17. package/dist/use-flip-animation.d.ts.map +1 -0
  18. package/dist/use-flip-animation.js +123 -0
  19. package/dist/use-optimistic-mutation.d.ts +73 -0
  20. package/dist/use-optimistic-mutation.d.ts.map +1 -0
  21. package/dist/use-optimistic-mutation.js +153 -0
  22. package/dist/use-persisted-query.d.ts +53 -0
  23. package/dist/use-persisted-query.d.ts.map +1 -0
  24. package/dist/use-persisted-query.js +92 -0
  25. package/package.json +5 -5
  26. package/src/__tests__/motion.test.ts +29 -0
  27. package/src/__tests__/use-flip-animation.test.tsx +109 -0
  28. package/src/__tests__/use-optimistic-mutation.test.tsx +201 -0
  29. package/src/__tests__/use-persisted-query.test.tsx +132 -0
  30. package/src/agent-result-registry.test.tsx +52 -0
  31. package/src/agent-result-registry.tsx +129 -0
  32. package/src/attribute-classes.test.ts +37 -1
  33. package/src/attribute-classes.ts +47 -5
  34. package/src/dialogs/dynamic-record.tsx +3 -2
  35. package/src/index.ts +35 -0
  36. package/src/motion.ts +47 -0
  37. package/src/use-flip-animation.ts +157 -0
  38. package/src/use-optimistic-mutation.ts +218 -0
  39. package/src/use-persisted-query.ts +152 -0
  40. package/tsconfig.json +1 -1
package/src/index.ts CHANGED
@@ -129,6 +129,28 @@ export {
129
129
  type UseDynamicFiltersResult,
130
130
  } from './use-dynamic-filters'
131
131
  export { useDebouncedValue, SEARCH_DEBOUNCE_MS } from './use-debounced-value'
132
+ export {
133
+ useOptimisticMutation,
134
+ type UseOptimisticMutationOptions,
135
+ type UseOptimisticMutationResult,
136
+ } from './use-optimistic-mutation'
137
+ export { useFlipAnimation, type UseFlipAnimationOptions } from './use-flip-animation'
138
+ export {
139
+ usePersistedQuery,
140
+ createPersistedSnapshot,
141
+ type UsePersistedQueryOptions,
142
+ type PersistedSnapshot,
143
+ type PersistedSnapshotOptions,
144
+ type PersistedEntry,
145
+ } from './use-persisted-query'
146
+ export {
147
+ motionDuration,
148
+ motionEasing,
149
+ prefersReducedMotion,
150
+ MOTION_DEFAULTS,
151
+ type MotionDuration,
152
+ type MotionEasing,
153
+ } from './motion'
132
154
  export {
133
155
  useResource,
134
156
  useMutation,
@@ -385,6 +407,19 @@ export {
385
407
  type ModelExtension,
386
408
  type ModelExtensionProps,
387
409
  } from './model-extension-registry'
410
+ export {
411
+ registerAgentResultRenderer,
412
+ resolveAgentResultRenderer,
413
+ listAgentResultRenderers,
414
+ clearAgentResultRenderers,
415
+ useAgentResultRegistryVersion,
416
+ AgentResultView,
417
+ type AgentResult,
418
+ type AgentResultRenderer,
419
+ type AgentResultRendererOptions,
420
+ type AgentResultRendererProps,
421
+ type AgentResultViewProps,
422
+ } from './agent-result-registry'
388
423
  export {
389
424
  isColumnVisibleInTable,
390
425
  isColumnVisibleInModal,
package/src/motion.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Runtime access to the theme's motion tokens (`--motion-duration-*`,
3
+ * `--motion-ease-*` from @asteby/metacore-theme/tokens.css). Hosts that do
4
+ * not ship those variables get the same defaults, so JS-driven animations
5
+ * (WAAPI, dnd-kit) and CSS transitions stay on one scale.
6
+ */
7
+ export type MotionDuration = 'instant' | 'fast' | 'moderate' | 'slow'
8
+ export type MotionEasing = 'standard' | 'emphasized' | 'exit'
9
+
10
+ export const MOTION_DEFAULTS = {
11
+ duration: { instant: 100, fast: 150, moderate: 220, slow: 320 } as Record<MotionDuration, number>,
12
+ easing: {
13
+ standard: 'cubic-bezier(0.2, 0, 0, 1)',
14
+ emphasized: 'cubic-bezier(0.3, 0, 0, 1)',
15
+ exit: 'cubic-bezier(0.4, 0, 1, 1)',
16
+ } as Record<MotionEasing, string>,
17
+ }
18
+
19
+ function cssVar(name: string): string {
20
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return ''
21
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
22
+ }
23
+
24
+ /** True when the user asked the OS for reduced motion. */
25
+ export function prefersReducedMotion(): boolean {
26
+ return (
27
+ typeof window !== 'undefined' &&
28
+ typeof window.matchMedia === 'function' &&
29
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
30
+ )
31
+ }
32
+
33
+ /** Duration token in ms (0 under prefers-reduced-motion). */
34
+ export function motionDuration(token: MotionDuration): number {
35
+ if (prefersReducedMotion()) return 0
36
+ const raw = cssVar(`--motion-duration-${token}`)
37
+ if (raw) {
38
+ const n = parseFloat(raw)
39
+ if (Number.isFinite(n)) return raw.endsWith('ms') || !raw.endsWith('s') ? n : n * 1000
40
+ }
41
+ return MOTION_DEFAULTS.duration[token]
42
+ }
43
+
44
+ /** Easing token as a CSS timing function. */
45
+ export function motionEasing(token: MotionEasing): string {
46
+ return cssVar(`--motion-ease-${token}`) || MOTION_DEFAULTS.easing[token]
47
+ }
@@ -0,0 +1,157 @@
1
+ import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
2
+ import {
3
+ motionDuration,
4
+ motionEasing,
5
+ prefersReducedMotion,
6
+ type MotionDuration,
7
+ type MotionEasing,
8
+ } from './motion'
9
+
10
+ export interface UseFlipAnimationOptions {
11
+ /** Elements to animate, queried inside the root. */
12
+ selector?: string
13
+ /** Stable identity of an element across renders. Default: data-flip-key, then href, then text. */
14
+ keyOf?: (el: HTMLElement) => string | null
15
+ /**
16
+ * Scroll container used as the coordinate origin, so scrolling between two
17
+ * snapshots does not read as movement. Default: the root.
18
+ */
19
+ scrollContainer?: (root: HTMLElement) => HTMLElement | null
20
+ /** Motion token (default `moderate`) or explicit ms. */
21
+ duration?: MotionDuration | number
22
+ /** Motion token (default `standard`) or a CSS timing function. */
23
+ easing?: MotionEasing | string
24
+ /** Off switch; the hook also stays still under prefers-reduced-motion. */
25
+ disabled?: boolean
26
+ }
27
+
28
+ const DEFAULT_SELECTOR = '[data-flip-key]'
29
+ const MOTION_EASING_KEYS = { standard: 1, emphasized: 1, exit: 1 }
30
+
31
+ const defaultKeyOf = (el: HTMLElement): string | null =>
32
+ el.dataset.flipKey ?? el.getAttribute('href') ?? (el.textContent?.trim() || null)
33
+
34
+ type Positions = Map<string, { top: number; left: number }>
35
+
36
+ /**
37
+ * FLIP reorder animation: when `trigger` changes, elements that exist before
38
+ * and after the change glide from their old position to the new one.
39
+ *
40
+ * Positions are snapshotted when the list is at rest (after mount, after each
41
+ * animation, and after pointer interaction inside the root), never in the
42
+ * render path, so a re-render costs nothing. Uses the Web Animations API on
43
+ * `transform` only; with prefers-reduced-motion the change is instant.
44
+ */
45
+ export function useFlipAnimation(
46
+ rootRef: RefObject<HTMLElement | null>,
47
+ trigger: unknown,
48
+ options: UseFlipAnimationOptions = {},
49
+ ): void {
50
+ const {
51
+ selector = DEFAULT_SELECTOR,
52
+ keyOf = defaultKeyOf,
53
+ scrollContainer,
54
+ duration = 'moderate',
55
+ easing = 'standard',
56
+ disabled = false,
57
+ } = options
58
+ const positionsRef = useRef<Positions | null>(null)
59
+ const triggerRef = useRef(trigger)
60
+ const configRef = useRef({ selector, keyOf, scrollContainer })
61
+ useEffect(() => {
62
+ configRef.current = { selector, keyOf, scrollContainer }
63
+ })
64
+
65
+ const measure = (): Positions | null => {
66
+ const root = rootRef.current
67
+ if (!root) return null
68
+ const { selector: sel, keyOf: key, scrollContainer: sc } = configRef.current
69
+ const origin = (sc ? sc(root) : null) ?? root
70
+ const box = origin.getBoundingClientRect()
71
+ const out: Positions = new Map()
72
+ root.querySelectorAll<HTMLElement>(sel).forEach((el) => {
73
+ const k = key(el)
74
+ if (!k || out.has(k)) return
75
+ const r = el.getBoundingClientRect()
76
+ if (r.width === 0 && r.height === 0) return
77
+ out.set(k, {
78
+ top: r.top - box.top + origin.scrollTop,
79
+ left: r.left - box.left + origin.scrollLeft,
80
+ })
81
+ })
82
+ return out
83
+ }
84
+ const measureRef = useRef(measure)
85
+ useEffect(() => {
86
+ measureRef.current = measure
87
+ })
88
+
89
+ // Snapshot at rest: after mount and after interactions that move things
90
+ // without a trigger change (expanding a collapsible, for instance).
91
+ // Listens on the document and resolves the root per event, so a ref whose
92
+ // element is swapped (remount, mobile sheet) keeps working.
93
+ useEffect(() => {
94
+ let timer: ReturnType<typeof setTimeout> | null = null
95
+ const schedule = () => {
96
+ if (timer) clearTimeout(timer)
97
+ timer = setTimeout(() => {
98
+ positionsRef.current = measureRef.current()
99
+ }, 350)
100
+ }
101
+ const onInteraction = (e: Event) => {
102
+ const root = rootRef.current
103
+ if (root && e.target instanceof Node && root.contains(e.target)) schedule()
104
+ }
105
+ schedule()
106
+ document.addEventListener('pointerup', onInteraction, true)
107
+ document.addEventListener('keyup', onInteraction, true)
108
+ window.addEventListener('resize', schedule)
109
+ return () => {
110
+ if (timer) clearTimeout(timer)
111
+ document.removeEventListener('pointerup', onInteraction, true)
112
+ document.removeEventListener('keyup', onInteraction, true)
113
+ window.removeEventListener('resize', schedule)
114
+ }
115
+ }, [rootRef])
116
+
117
+ useLayoutEffect(() => {
118
+ if (Object.is(triggerRef.current, trigger)) return
119
+ triggerRef.current = trigger
120
+ const before = positionsRef.current
121
+ const root = rootRef.current
122
+ if (!root) return
123
+ const after = measureRef.current()
124
+ positionsRef.current = after
125
+ if (!before || !after || disabled || prefersReducedMotion()) return
126
+ const durationMs = typeof duration === 'number' ? duration : motionDuration(duration)
127
+ if (durationMs <= 0) return
128
+ const timing = easing in MOTION_EASING_KEYS ? motionEasing(easing as MotionEasing) : easing
129
+
130
+ const { selector: sel, keyOf: key } = configRef.current
131
+ // A stale snapshot can report huge jumps; those would read as a glitch.
132
+ const maxJump = root.clientHeight > 0 ? root.clientHeight * 1.5 : Infinity
133
+ root.querySelectorAll<HTMLElement>(sel).forEach((el) => {
134
+ const k = key(el)
135
+ if (!k) return
136
+ const from = before.get(k)
137
+ const to = after.get(k)
138
+ if (!to || typeof el.animate !== 'function') return
139
+ if (!from) {
140
+ // Entered with this change: fade in instead of popping.
141
+ el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: durationMs, easing: timing })
142
+ return
143
+ }
144
+ const dy = from.top - to.top
145
+ const dx = from.left - to.left
146
+ if (Math.abs(dy) < 1 && Math.abs(dx) < 1) return
147
+ if (Math.abs(dy) > maxJump) return
148
+ el.animate(
149
+ [
150
+ { transform: `translate(${dx}px, ${dy}px)` },
151
+ { transform: 'translate(0, 0)' },
152
+ ],
153
+ { duration: durationMs, easing: timing },
154
+ )
155
+ })
156
+ }, [trigger, rootRef, disabled, duration, easing])
157
+ }
@@ -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
+ }
@@ -0,0 +1,152 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import {
3
+ useQuery,
4
+ useQueryClient,
5
+ type QueryKey,
6
+ type UseQueryOptions,
7
+ type UseQueryResult,
8
+ } from '@tanstack/react-query'
9
+
10
+ /**
11
+ * A versioned, scoped copy of a value in localStorage. Shell data that decides
12
+ * the first paint (navigation, the org's menu layout, installed presets,
13
+ * permissions) is read from here synchronously, so a reload paints the last
14
+ * known state instead of a default that reorders when the network answers.
15
+ *
16
+ * The entry lives under `<key>:v<version>:<scope>`. Bump `version` when the
17
+ * stored shape changes; old entries are simply never read again. `scope` keeps
18
+ * one org or user from seeing another's copy on a shared browser.
19
+ */
20
+ export interface PersistedSnapshotOptions {
21
+ key: string
22
+ version: number
23
+ /** Entries older than this are ignored (default: 30 days). */
24
+ maxAgeMs?: number
25
+ }
26
+
27
+ export interface PersistedEntry<T> {
28
+ data: T
29
+ /** When the data was fetched (ms epoch). */
30
+ ts: number
31
+ }
32
+
33
+ export interface PersistedSnapshot<T> {
34
+ read(scope: string): PersistedEntry<T> | undefined
35
+ write(scope: string, data: T, meta?: { ts?: number }): void
36
+ clear(scope: string): void
37
+ }
38
+
39
+ const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
40
+
41
+ function storage(): Storage | null {
42
+ try {
43
+ return typeof localStorage === 'undefined' ? null : localStorage
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ export function createPersistedSnapshot<T>({
50
+ key,
51
+ version,
52
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
53
+ }: PersistedSnapshotOptions): PersistedSnapshot<T> {
54
+ const slot = (scope: string) => `${key}:v${version}:${scope}`
55
+ return {
56
+ read(scope) {
57
+ const s = storage()
58
+ if (!s) return undefined
59
+ try {
60
+ const raw = s.getItem(slot(scope))
61
+ if (!raw) return undefined
62
+ const parsed = JSON.parse(raw) as Partial<PersistedEntry<T>>
63
+ if (typeof parsed?.ts !== 'number' || !('data' in parsed)) return undefined
64
+ if (Date.now() - parsed.ts > maxAgeMs) return undefined
65
+ return { data: parsed.data as T, ts: parsed.ts }
66
+ } catch {
67
+ return undefined
68
+ }
69
+ },
70
+ write(scope, data, { ts = Date.now() } = {}) {
71
+ try {
72
+ storage()?.setItem(slot(scope), JSON.stringify({ data, ts }))
73
+ } catch {
74
+ // quota / private mode: the snapshot is an optimization, never fatal
75
+ }
76
+ },
77
+ clear(scope) {
78
+ try {
79
+ storage()?.removeItem(slot(scope))
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ },
84
+ }
85
+ }
86
+
87
+ export type UsePersistedQueryOptions<TQueryFnData, TData, TQueryKey extends QueryKey> = UseQueryOptions<
88
+ TQueryFnData,
89
+ Error,
90
+ TData,
91
+ TQueryKey
92
+ > & {
93
+ persist: {
94
+ snapshot: PersistedSnapshot<TQueryFnData>
95
+ /**
96
+ * Org/user the data belongs to. Without a scope nothing is read or
97
+ * written (e.g. before sign-in).
98
+ */
99
+ scope: string | null | undefined
100
+ }
101
+ }
102
+
103
+ /**
104
+ * `useQuery` seeded from a {@link PersistedSnapshot}: the first render already
105
+ * has the last fetched value (stale-while-revalidate), and every later value —
106
+ * a refetch, or a `setQueryData` from a mutation — is written back. The seed is
107
+ * stale from the start: every page load revalidates it once in the background,
108
+ * so a change made elsewhere (a plantilla applied, an addon installed,
109
+ * permissions edited) shows on the next load even inside `staleTime`.
110
+ *
111
+ * Persist the raw server payload and derive UI shapes with `select`; the
112
+ * snapshot must be JSON.
113
+ */
114
+ export function usePersistedQuery<
115
+ TQueryFnData,
116
+ TData = TQueryFnData,
117
+ TQueryKey extends QueryKey = QueryKey,
118
+ >(
119
+ options: UsePersistedQueryOptions<TQueryFnData, TData, TQueryKey>,
120
+ ): UseQueryResult<TData, Error> {
121
+ const { persist, ...queryOptions } = options
122
+ const { snapshot, scope } = persist
123
+ const seed = useMemo(
124
+ () => (scope ? snapshot.read(scope) : undefined),
125
+ [snapshot, scope],
126
+ )
127
+ const query = useQuery<TQueryFnData, Error, TData, TQueryKey>({
128
+ ...queryOptions,
129
+ ...(seed && queryOptions.initialData === undefined
130
+ ? { initialData: seed.data, initialDataUpdatedAt: 0 }
131
+ : {}),
132
+ } as UseQueryOptions<TQueryFnData, Error, TData, TQueryKey>)
133
+
134
+ const queryClient = useQueryClient()
135
+ const { dataUpdatedAt, status, isPlaceholderData } = query
136
+ const queryKey = queryOptions.queryKey
137
+ useEffect(() => {
138
+ if (!scope || status !== 'success' || isPlaceholderData) return
139
+ // Still the seed: nothing new to store.
140
+ if (dataUpdatedAt === 0) return
141
+ // `query.data` is the selected shape; persist the raw cache value.
142
+ const raw = queryClient.getQueryData<TQueryFnData>(queryKey)
143
+ if (raw === undefined) return
144
+ snapshot.write(scope, raw, { ts: dataUpdatedAt })
145
+ // queryKey is compared by react-query's hash, not identity: a new array
146
+ // each render must not rewrite the snapshot.
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [queryClient, scope, snapshot, seed, status, isPlaceholderData, dataUpdatedAt])
149
+
150
+ return query
151
+ }
152
+
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
  }