@asteby/metacore-runtime-react 41.0.0 → 43.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 +45 -0
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/dynamic-icon.d.ts.map +1 -1
- package/dist/dynamic-icon.js +9 -13
- package/dist/dynamic-kanban.d.ts +83 -6
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +647 -146
- package/dist/dynamic-table.d.ts +1 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +14 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/license/license-gate.d.ts +0 -21
- package/dist/license/license-gate.d.ts.map +1 -1
- package/dist/license/license-gate.js +5 -2
- package/dist/portal-drag-overlay.d.ts +5 -0
- package/dist/portal-drag-overlay.d.ts.map +1 -0
- package/dist/portal-drag-overlay.js +8 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +6 -3
- package/dist/sidebar-layout-manager.js +3 -2
- package/dist/use-flip-animation.d.ts +6 -0
- package/dist/use-flip-animation.d.ts.map +1 -1
- package/dist/use-flip-animation.js +11 -4
- 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 +6 -5
- package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
- package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
- package/src/__tests__/dynamic-kanban.test.tsx +15 -2
- package/src/__tests__/image-cell-lucide.test.tsx +7 -5
- package/src/__tests__/use-persisted-query.test.tsx +132 -0
- package/src/api-context.tsx +7 -5
- package/src/dynamic-icon.tsx +12 -16
- package/src/dynamic-kanban.tsx +950 -262
- package/src/dynamic-table.tsx +16 -2
- package/src/index.ts +14 -0
- package/src/license/license-gate.tsx +6 -2
- package/src/portal-drag-overlay.tsx +20 -0
- package/src/query-batch.ts +6 -3
- package/src/sidebar-layout-manager.tsx +3 -3
- package/src/use-flip-animation.ts +16 -3
- package/src/use-persisted-query.ts +152 -0
package/src/dynamic-table.tsx
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// column cells are tightly coupled to their design system).
|
|
13
13
|
import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent } from 'react'
|
|
14
14
|
import { useTranslation } from 'react-i18next'
|
|
15
|
+
import { useTimeZone, useCurrency } from './org-runtime-context'
|
|
15
16
|
import { format } from 'date-fns'
|
|
16
17
|
import type { DateRange } from 'react-day-picker'
|
|
17
18
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
useReactTable,
|
|
36
37
|
} from '@tanstack/react-table'
|
|
37
38
|
import { cn } from '@asteby/metacore-ui/lib'
|
|
39
|
+
import { preloadGlyphs } from '@asteby/metacore-ui/icons'
|
|
38
40
|
import {
|
|
39
41
|
Table,
|
|
40
42
|
TableBody,
|
|
@@ -282,12 +284,18 @@ export function DynamicTable({
|
|
|
282
284
|
defaultFilters,
|
|
283
285
|
extraColumns = [],
|
|
284
286
|
getDynamicColumns = defaultGetDynamicColumns,
|
|
285
|
-
timeZone,
|
|
286
|
-
currency,
|
|
287
|
+
timeZone: timeZoneProp,
|
|
288
|
+
currency: currencyProp,
|
|
287
289
|
pagination: paginationMode,
|
|
288
290
|
infiniteScroll: infiniteScrollProp = false,
|
|
289
291
|
virtualizeRows,
|
|
290
292
|
}: DynamicTableProps) {
|
|
293
|
+
// The org's timezone/currency: an explicit prop wins, else the app-wide
|
|
294
|
+
// OrgRuntimeProvider (without it, money fell back to USD).
|
|
295
|
+
const orgTimeZone = useTimeZone()
|
|
296
|
+
const orgCurrency = useCurrency()
|
|
297
|
+
const timeZone = timeZoneProp ?? orgTimeZone
|
|
298
|
+
const currency = currencyProp ?? orgCurrency
|
|
291
299
|
// The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean
|
|
292
300
|
// still selects the mode when `pagination` is absent (back-compat).
|
|
293
301
|
const infiniteScroll = paginationMode ? paginationMode === 'infinite' : infiniteScrollProp
|
|
@@ -754,6 +762,12 @@ export function DynamicTable({
|
|
|
754
762
|
return gateTableMetadata(metadata, model, can, (key, fallback) => t(key, { defaultValue: fallback }))
|
|
755
763
|
}, [metadata, permissionsActive, can, model, t])
|
|
756
764
|
|
|
765
|
+
// Row-action menus mount their icons on open: load them with the
|
|
766
|
+
// metadata so the first open draws every glyph at once.
|
|
767
|
+
useEffect(() => {
|
|
768
|
+
void preloadGlyphs((metadata?.actions ?? []).map((a) => a.icon))
|
|
769
|
+
}, [metadata])
|
|
770
|
+
|
|
757
771
|
// Importing is offered when the host opts in explicitly (`canImport`) or,
|
|
758
772
|
// when it says nothing, whenever the kernel served an import spec for the
|
|
759
773
|
// model — which it does for every model with at least one importable form
|
package/src/index.ts
CHANGED
|
@@ -57,9 +57,15 @@ export {
|
|
|
57
57
|
groupByStage,
|
|
58
58
|
isTransitionAllowed,
|
|
59
59
|
applyOptimisticMove,
|
|
60
|
+
applyStageOverrides,
|
|
61
|
+
laneDeltasFromOverrides,
|
|
60
62
|
selectCardColumns,
|
|
63
|
+
visibleCardFields,
|
|
64
|
+
isEmptyCardValue,
|
|
65
|
+
nextLaneCoordinates,
|
|
61
66
|
UNASSIGNED_LANE,
|
|
62
67
|
} from './dynamic-kanban'
|
|
68
|
+
export { PortalDragOverlay, type PortalDragOverlayProps } from './portal-drag-overlay'
|
|
63
69
|
export {
|
|
64
70
|
useStageAutomations,
|
|
65
71
|
StageAutomationsButton,
|
|
@@ -135,6 +141,14 @@ export {
|
|
|
135
141
|
type UseOptimisticMutationResult,
|
|
136
142
|
} from './use-optimistic-mutation'
|
|
137
143
|
export { useFlipAnimation, type UseFlipAnimationOptions } from './use-flip-animation'
|
|
144
|
+
export {
|
|
145
|
+
usePersistedQuery,
|
|
146
|
+
createPersistedSnapshot,
|
|
147
|
+
type UsePersistedQueryOptions,
|
|
148
|
+
type PersistedSnapshot,
|
|
149
|
+
type PersistedSnapshotOptions,
|
|
150
|
+
type PersistedEntry,
|
|
151
|
+
} from './use-persisted-query'
|
|
138
152
|
export {
|
|
139
153
|
motionDuration,
|
|
140
154
|
motionEasing,
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* INDEPENDIENTE del kernel: no importa su cliente ni fija su versión. Solo
|
|
20
20
|
* consume `LicenseState` y una promesa `onActivate`.
|
|
21
21
|
*/
|
|
22
|
+
import { createPortal } from 'react-dom'
|
|
22
23
|
import { useState, type ReactNode } from 'react'
|
|
23
24
|
import { useTranslation } from 'react-i18next'
|
|
24
25
|
import { ShieldAlert, Clock, Loader2 } from 'lucide-react'
|
|
@@ -165,7 +166,9 @@ function LicenseGateModal({
|
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
|
|
169
|
+
// In <body>: mounted inside a glass panel (backdrop-filter), a fixed veil
|
|
170
|
+
// would cover only that panel.
|
|
171
|
+
return createPortal(
|
|
169
172
|
<div
|
|
170
173
|
role="dialog"
|
|
171
174
|
aria-modal="true"
|
|
@@ -300,6 +303,7 @@ function LicenseGateModal({
|
|
|
300
303
|
)}
|
|
301
304
|
</div>
|
|
302
305
|
</div>
|
|
303
|
-
</div
|
|
306
|
+
</div>,
|
|
307
|
+
document.body,
|
|
304
308
|
)
|
|
305
309
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// PortalDragOverlay — dnd-kit's <DragOverlay> rendered into <body>.
|
|
2
|
+
//
|
|
3
|
+
// The overlay is `position: fixed` at the viewport coordinates of the item
|
|
4
|
+
// being dragged. Any ancestor with a transform, filter or backdrop-filter
|
|
5
|
+
// becomes the containing block of a fixed descendant, so inside such an
|
|
6
|
+
// ancestor (a glass panel from @asteby/metacore-theme/glass.css, an animated
|
|
7
|
+
// dialog) the lifted item shows up shifted by that ancestor's offset instead of
|
|
8
|
+
// under the pointer. Rendering it into <body> keeps the viewport as its
|
|
9
|
+
// containing block wherever the board or list is mounted. React context (the
|
|
10
|
+
// DndContext) still reaches it through the portal.
|
|
11
|
+
import * as React from 'react'
|
|
12
|
+
import { createPortal } from 'react-dom'
|
|
13
|
+
import { DragOverlay } from '@dnd-kit/core'
|
|
14
|
+
|
|
15
|
+
export type PortalDragOverlayProps = React.ComponentProps<typeof DragOverlay>
|
|
16
|
+
|
|
17
|
+
export function PortalDragOverlay(props: PortalDragOverlayProps) {
|
|
18
|
+
if (typeof document === 'undefined') return null
|
|
19
|
+
return createPortal(<DragOverlay {...props} />, document.body)
|
|
20
|
+
}
|
package/src/query-batch.ts
CHANGED
|
@@ -377,8 +377,11 @@ function batchForbidden(message?: string): boolean {
|
|
|
377
377
|
* or options read. Anything the batch refuses falls back to the original GET.
|
|
378
378
|
*/
|
|
379
379
|
export async function batchGet(api: ApiClient, url: string, config?: { params?: Record<string, unknown> }) {
|
|
380
|
+
// The fallback GET gets exactly the arguments the caller passed: no
|
|
381
|
+
// trailing `undefined` config reaching the host client.
|
|
382
|
+
const plainGet = () => (config === undefined ? api.get(url) : api.get(url, config))
|
|
380
383
|
const token = tokenForGet(url, config?.params)
|
|
381
|
-
if (!token) return
|
|
384
|
+
if (!token) return plainGet()
|
|
382
385
|
try {
|
|
383
386
|
const part = await loadQueryPart(api, token)
|
|
384
387
|
if (!part.success) {
|
|
@@ -391,13 +394,13 @@ export async function batchGet(api: ApiClient, url: string, config?: { params?:
|
|
|
391
394
|
err.response = { status: 403 }
|
|
392
395
|
throw err
|
|
393
396
|
}
|
|
394
|
-
return
|
|
397
|
+
return plainGet()
|
|
395
398
|
}
|
|
396
399
|
return { data: { success: true, data: part.data, meta: part.meta } }
|
|
397
400
|
} catch (err) {
|
|
398
401
|
const status = (err as { status?: number; response?: { status?: number } })?.status
|
|
399
402
|
?? (err as { response?: { status?: number } })?.response?.status
|
|
400
403
|
if (status === 403) throw err
|
|
401
|
-
return
|
|
404
|
+
return plainGet()
|
|
402
405
|
}
|
|
403
406
|
}
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
import * as React from 'react'
|
|
6
6
|
import {
|
|
7
7
|
DndContext,
|
|
8
|
-
DragOverlay,
|
|
9
8
|
PointerSensor,
|
|
10
9
|
closestCenter,
|
|
11
10
|
useSensor,
|
|
@@ -20,6 +19,7 @@ import {
|
|
|
20
19
|
verticalListSortingStrategy,
|
|
21
20
|
} from '@dnd-kit/sortable'
|
|
22
21
|
import { CSS } from '@dnd-kit/utilities'
|
|
22
|
+
import { PortalDragOverlay } from './portal-drag-overlay'
|
|
23
23
|
import {
|
|
24
24
|
ChevronDown,
|
|
25
25
|
ChevronRight,
|
|
@@ -733,13 +733,13 @@ export function SidebarLayoutManager({
|
|
|
733
733
|
) : (
|
|
734
734
|
renderList(tree, [])
|
|
735
735
|
)}
|
|
736
|
-
<
|
|
736
|
+
<PortalDragOverlay>
|
|
737
737
|
{activeLabel ? (
|
|
738
738
|
<div className="bg-background border-border rounded-md border px-3 py-1.5 text-sm shadow-lg">
|
|
739
739
|
{activeLabel}
|
|
740
740
|
</div>
|
|
741
741
|
) : null}
|
|
742
|
-
</
|
|
742
|
+
</PortalDragOverlay>
|
|
743
743
|
</DndContext>
|
|
744
744
|
|
|
745
745
|
<div className="mt-3 flex gap-2 px-2">
|
|
@@ -23,6 +23,12 @@ export interface UseFlipAnimationOptions {
|
|
|
23
23
|
easing?: MotionEasing | string
|
|
24
24
|
/** Off switch; the hook also stays still under prefers-reduced-motion. */
|
|
25
25
|
disabled?: boolean
|
|
26
|
+
/**
|
|
27
|
+
* Leave one element still on this change while still tracking where it is
|
|
28
|
+
* (e.g. the card a drag overlay is already carrying into place). Default:
|
|
29
|
+
* animate everything.
|
|
30
|
+
*/
|
|
31
|
+
shouldAnimate?: (el: HTMLElement, key: string) => boolean
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
const DEFAULT_SELECTOR = '[data-flip-key]'
|
|
@@ -54,12 +60,13 @@ export function useFlipAnimation(
|
|
|
54
60
|
duration = 'moderate',
|
|
55
61
|
easing = 'standard',
|
|
56
62
|
disabled = false,
|
|
63
|
+
shouldAnimate,
|
|
57
64
|
} = options
|
|
58
65
|
const positionsRef = useRef<Positions | null>(null)
|
|
59
66
|
const triggerRef = useRef(trigger)
|
|
60
|
-
const configRef = useRef({ selector, keyOf, scrollContainer })
|
|
67
|
+
const configRef = useRef({ selector, keyOf, scrollContainer, shouldAnimate })
|
|
61
68
|
useEffect(() => {
|
|
62
|
-
configRef.current = { selector, keyOf, scrollContainer }
|
|
69
|
+
configRef.current = { selector, keyOf, scrollContainer, shouldAnimate }
|
|
63
70
|
})
|
|
64
71
|
|
|
65
72
|
const measure = (): Positions | null => {
|
|
@@ -105,11 +112,16 @@ export function useFlipAnimation(
|
|
|
105
112
|
schedule()
|
|
106
113
|
document.addEventListener('pointerup', onInteraction, true)
|
|
107
114
|
document.addEventListener('keyup', onInteraction, true)
|
|
115
|
+
// Scrolling a list inside the root moves its items without a trigger
|
|
116
|
+
// change; without a fresh snapshot the next change would replay the scroll
|
|
117
|
+
// distance as movement. (Scroll does not bubble: listen in capture.)
|
|
118
|
+
document.addEventListener('scroll', onInteraction, true)
|
|
108
119
|
window.addEventListener('resize', schedule)
|
|
109
120
|
return () => {
|
|
110
121
|
if (timer) clearTimeout(timer)
|
|
111
122
|
document.removeEventListener('pointerup', onInteraction, true)
|
|
112
123
|
document.removeEventListener('keyup', onInteraction, true)
|
|
124
|
+
document.removeEventListener('scroll', onInteraction, true)
|
|
113
125
|
window.removeEventListener('resize', schedule)
|
|
114
126
|
}
|
|
115
127
|
}, [rootRef])
|
|
@@ -127,7 +139,7 @@ export function useFlipAnimation(
|
|
|
127
139
|
if (durationMs <= 0) return
|
|
128
140
|
const timing = easing in MOTION_EASING_KEYS ? motionEasing(easing as MotionEasing) : easing
|
|
129
141
|
|
|
130
|
-
const { selector: sel, keyOf: key } = configRef.current
|
|
142
|
+
const { selector: sel, keyOf: key, shouldAnimate: animates } = configRef.current
|
|
131
143
|
// A stale snapshot can report huge jumps; those would read as a glitch.
|
|
132
144
|
const maxJump = root.clientHeight > 0 ? root.clientHeight * 1.5 : Infinity
|
|
133
145
|
root.querySelectorAll<HTMLElement>(sel).forEach((el) => {
|
|
@@ -136,6 +148,7 @@ export function useFlipAnimation(
|
|
|
136
148
|
const from = before.get(k)
|
|
137
149
|
const to = after.get(k)
|
|
138
150
|
if (!to || typeof el.animate !== 'function') return
|
|
151
|
+
if (animates && !animates(el, k)) return
|
|
139
152
|
if (!from) {
|
|
140
153
|
// Entered with this change: fade in instead of popping.
|
|
141
154
|
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: durationMs, easing: timing })
|
|
@@ -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
|
+
|