@asteby/metacore-runtime-react 23.12.2 → 24.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 CHANGED
@@ -1,5 +1,35 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 24.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 165f25c: feat(dynamic-table): cache first-page rows for instant reload paint
8
+
9
+ The table fetched rows into local state, so a full reload showed a full-table
10
+ skeleton until `/data/:model` resolved — even for the view just seen. Stash the
11
+ last first-page result in sessionStorage (org/user-scoped → must not outlive the
12
+ tab session) keyed by model+endpoint+branch+URL params, and seed the initial
13
+ rows from it so a reload paints instantly and revalidates in the background
14
+ (stale-while-revalidate).
15
+
16
+ - Updated dependencies [0704d54]
17
+ - @asteby/metacore-ui@2.10.0
18
+
19
+ ## 23.12.3
20
+
21
+ ### Patch Changes
22
+
23
+ - 5e46e61: fix(dynamic-table): don't strip a deep-linked filter on first mount (URL race)
24
+
25
+ The URL-write effect ran on the very first commit — when `initializedFromUrl`
26
+ (a ref) is already true but the init effect's `setDynamicFilters` hasn't
27
+ re-rendered yet — so it wrote a URL WITHOUT `f_status`, stripping a
28
+ deep-linked/reloaded filter and flickering the address bar until it settled
29
+ without the filter. Gate the write on a STATE flag (`urlSynced`) set once the
30
+ URL has been adopted, so the first write only happens on the render where
31
+ `dynamicFilters`/`pagination` already mirror the URL (a true no-op).
32
+
3
33
  ## 23.12.2
4
34
 
5
35
  ### Patch Changes
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAanF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,EACR,cAAsB,GACzB,EAAE,iBAAiB,+BAwmCnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAiEnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,EACR,cAAsB,GACzB,EAAE,iBAAiB,+BAupCnB"}
@@ -33,6 +33,48 @@ import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-c
33
33
  import { useDynamicRowActions } from './dynamic-row-actions';
34
34
  import { ExportDialog } from './dialogs/export';
35
35
  import { ImportDialog } from './dialogs/import';
36
+ // ---------------------------------------------------------------------------
37
+ // Row-data cache (perceived performance).
38
+ //
39
+ // The table fetches rows into local state, so a full page reload starts empty
40
+ // and shows a full skeleton until `/data/:model` resolves — even for a view the
41
+ // user just looked at. We stash the last first-page result and seed the initial
42
+ // state from it so a reload paints the previous rows instantly and the fetch
43
+ // below revalidates in the background (stale-while-revalidate).
44
+ //
45
+ // Deliberately `sessionStorage`, NOT localStorage: row data is org/user-scoped,
46
+ // so it must not outlive the tab session (a browser restart or a different login
47
+ // starts clean). The key includes model+endpoint+branch+URL params so a
48
+ // different filter / sort / page never paints the wrong rows. Only the first
49
+ // page is cached (capped) — infinite-scroll top-ups re-fetch on scroll.
50
+ const TBL_DATA_CACHE_PREFIX = 'mc:tbl:data:v1';
51
+ function tableDataCacheKey(model, endpoint, branchId, search) {
52
+ const p = new URLSearchParams(search);
53
+ const parts = [];
54
+ p.forEach((v, k) => parts.push(`${k}=${v}`));
55
+ parts.sort();
56
+ return `${TBL_DATA_CACHE_PREFIX}|${model || ''}|${endpoint || ''}|${branchId || ''}|${parts.join('&')}`;
57
+ }
58
+ function readTableDataCache(key) {
59
+ try {
60
+ const raw = sessionStorage.getItem(key);
61
+ if (!raw)
62
+ return null;
63
+ const parsed = JSON.parse(raw);
64
+ return parsed && Array.isArray(parsed.rows) ? parsed : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ function writeTableDataCache(key, rows, rowCount) {
71
+ try {
72
+ sessionStorage.setItem(key, JSON.stringify({ rows: rows.slice(0, 50), rowCount, ts: Date.now() }));
73
+ }
74
+ catch {
75
+ // quota / private mode — the cache is a nicety, never fatal
76
+ }
77
+ }
36
78
  export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, infiniteScroll = false, }) {
37
79
  const { t, i18n } = useTranslation();
38
80
  const api = useApi();
@@ -41,12 +83,22 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
41
83
  const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache();
42
84
  const cachedMeta = getMetadata(model);
43
85
  const [metadata, setMetadata] = useState(cachedMeta || null);
44
- const [data, setData] = useState([]);
86
+ // Read the row-data cache ONCE at mount (before the fetch effects run) so the
87
+ // first paint uses the previous rows for this exact view instead of skeletons.
88
+ const bootDataKey = tableDataCacheKey(model, endpoint, currentBranch?.id, typeof window !== 'undefined' ? window.location.search : '');
89
+ const bootDataRef = useRef(undefined);
90
+ if (bootDataRef.current === undefined) {
91
+ bootDataRef.current = enableUrlSync ? readTableDataCache(bootDataKey) : null;
92
+ }
93
+ const bootData = bootDataRef.current;
94
+ const [data, setData] = useState(bootData?.rows ?? []);
45
95
  // Footer totals: per-column SUM over the FILTERED set, fetched from a
46
96
  // separate /aggregate endpoint (NOT summed from the visible page).
47
97
  const [footerTotals, setFooterTotals] = useState({});
48
98
  const [loading, setLoading] = useState(!cachedMeta);
49
- const [loadingData, setLoadingData] = useState(true);
99
+ // Cached rows no full-table skeleton on reload; the background fetch still
100
+ // runs and swaps in fresh data.
101
+ const [loadingData, setLoadingData] = useState(!(bootData?.rows?.length));
50
102
  // Infinite-scroll: a top-up page is in flight (distinct from the initial
51
103
  // page load so only a small bottom spinner shows, not the whole-table one).
52
104
  const [loadingMore, setLoadingMore] = useState(false);
@@ -72,7 +124,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
72
124
  const [columnFilters, setColumnFilters] = useState([]);
73
125
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
74
126
  const [globalFilter, setGlobalFilter] = useState('');
75
- const [rowCount, setRowCount] = useState(0);
127
+ const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0);
76
128
  const [dateRange, setDateRange] = useState(undefined);
77
129
  const [dynamicFilters, setDynamicFilters] = useState({});
78
130
  const [filterOptionsMap, setFilterOptionsMap] = useState(new Map());
@@ -85,6 +137,17 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
85
137
  // which reads as a URL flicker and fights the router's spelling. Starts at 10
86
138
  // to match the initial pagination state until metadata arrives.
87
139
  const defaultPerPage = useRef(10);
140
+ // Has the table finished adopting its state FROM the URL yet? The write
141
+ // effect must not run before this flips true. `initializedFromUrl` is a ref
142
+ // set synchronously at the top of the init effect, so on the very first
143
+ // commit the write effect (which runs right after, in the SAME commit) sees
144
+ // `initialized=true` but `dynamicFilters` still EMPTY — the init effect's
145
+ // setState hasn't re-rendered yet — and writes a URL WITHOUT `f_status`,
146
+ // stripping a deep-linked filter (the "recarga quita el filtro y parpadea"
147
+ // bug). A STATE flag defers the first write to the render AFTER the URL has
148
+ // been adopted, where `dynamicFilters`/`pagination` already mirror the URL,
149
+ // so the write is a no-op and the filter never gets stripped.
150
+ const [urlSynced, setUrlSynced] = useState(false);
88
151
  useEffect(() => {
89
152
  if (prevBranchId.current !== currentBranch?.id) {
90
153
  prevBranchId.current = currentBranch?.id;
@@ -181,13 +244,16 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
181
244
  });
182
245
  if (Object.keys(filters).length > 0)
183
246
  setDynamicFilters(filters);
247
+ // Adopted everything the URL carries; the write effect may now run on the
248
+ // NEXT render (where the setState above has landed) without clobbering it.
249
+ setUrlSynced(true);
184
250
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
185
251
  // The exact query string this table last wrote to the URL — lets the
186
252
  // resync effect below tell "our own replaceState" apart from an external
187
253
  // rewrite by the host router.
188
254
  const lastSelfSearch = useRef(null);
189
255
  useEffect(() => {
190
- if (!enableUrlSync || !initializedFromUrl.current)
256
+ if (!enableUrlSync || !urlSynced)
191
257
  return;
192
258
  const params = new URLSearchParams();
193
259
  // Preserve the route-owned view params. The table rebuilds the query
@@ -241,7 +307,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
241
307
  const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
242
308
  lastSelfSearch.current = search ? `?${search}` : '';
243
309
  window.history.replaceState(null, '', newUrl);
244
- }, [enableUrlSync, pagination, sorting, globalFilter, dynamicFilters, defaultFilters]);
310
+ }, [enableUrlSync, urlSynced, pagination, sorting, globalFilter, dynamicFilters, defaultFilters]);
245
311
  // The host router can rewrite the query string WITHOUT remounting the
246
312
  // table — e.g. sidebar sibling entries deep-link different `f_` filters
247
313
  // into the same model route. The mount-time init above never re-runs, so
@@ -461,9 +527,15 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
461
527
  };
462
528
  const res = await api.get(endpoint || `/data/${model}`, { params });
463
529
  if (res.data.success) {
464
- setData(res.data.data || []);
530
+ const rows = res.data.data || [];
531
+ setData(rows);
465
532
  if (res.data.meta)
466
533
  setRowCount(res.data.meta.total);
534
+ // Cache the first page for an instant reload paint (see the cache
535
+ // helpers). Keyed off the live URL so it matches the next mount.
536
+ if (enableUrlSync && pagination.pageIndex === 0) {
537
+ writeTableDataCache(tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search), rows, res.data.meta?.total ?? rows.length);
538
+ }
467
539
  }
468
540
  }
469
541
  catch (error) {
@@ -472,7 +544,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
472
544
  finally {
473
545
  setLoadingData(false);
474
546
  }
475
- }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api]);
547
+ }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync]);
476
548
  // Columns whose metadata opts into a footer total (display_config.aggregate
477
549
  // → styleConfig.aggregate). When empty, no footer row is rendered and no
478
550
  // aggregate request is made.
@@ -518,6 +590,10 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
518
590
  setData((prev) => (append ? dedupeById(prev, rows) : rows));
519
591
  if (res.data.meta)
520
592
  setRowCount(res.data.meta.total);
593
+ // Cache the first (replace) page for an instant reload paint.
594
+ if (!append && enableUrlSync) {
595
+ writeTableDataCache(tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search), rows, res.data.meta?.total ?? rows.length);
596
+ }
521
597
  // A short page means the backend has no more rows, even if
522
598
  // meta.total disagrees with the visible count (count query
523
599
  // vs list query drift, dedupe). Without this the sentinel
@@ -534,7 +610,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
534
610
  else
535
611
  setLoadingData(false);
536
612
  }
537
- }, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id]);
613
+ }, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync]);
538
614
  // Signature of everything that must reset the incremental list to page 1:
539
615
  // the filters/search AND the sort (both live in buildFilterParams).
540
616
  const filterSignature = useMemo(() => JSON.stringify(buildFilterParams()), [buildFilterParams]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.12.2",
3
+ "version": "24.0.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "sonner": ">=1.7",
39
39
  "zustand": ">=5",
40
40
  "@asteby/metacore-sdk": "^3.2.0",
41
- "@asteby/metacore-ui": "^2.9.3"
41
+ "@asteby/metacore-ui": "^2.10.0"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@tanstack/react-router": {
@@ -68,7 +68,7 @@
68
68
  "vitest": "^4.0.0",
69
69
  "zustand": "^5.0.0",
70
70
  "@asteby/metacore-sdk": "3.2.0",
71
- "@asteby/metacore-ui": "2.9.3"
71
+ "@asteby/metacore-ui": "2.10.0"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsc -p tsconfig.json",
@@ -77,6 +77,58 @@ import { useDynamicRowActions } from './dynamic-row-actions'
77
77
  import { ExportDialog } from './dialogs/export'
78
78
  import { ImportDialog } from './dialogs/import'
79
79
 
80
+ // ---------------------------------------------------------------------------
81
+ // Row-data cache (perceived performance).
82
+ //
83
+ // The table fetches rows into local state, so a full page reload starts empty
84
+ // and shows a full skeleton until `/data/:model` resolves — even for a view the
85
+ // user just looked at. We stash the last first-page result and seed the initial
86
+ // state from it so a reload paints the previous rows instantly and the fetch
87
+ // below revalidates in the background (stale-while-revalidate).
88
+ //
89
+ // Deliberately `sessionStorage`, NOT localStorage: row data is org/user-scoped,
90
+ // so it must not outlive the tab session (a browser restart or a different login
91
+ // starts clean). The key includes model+endpoint+branch+URL params so a
92
+ // different filter / sort / page never paints the wrong rows. Only the first
93
+ // page is cached (capped) — infinite-scroll top-ups re-fetch on scroll.
94
+ const TBL_DATA_CACHE_PREFIX = 'mc:tbl:data:v1'
95
+ interface TableDataCacheEntry { rows: any[]; rowCount: number; ts: number }
96
+
97
+ function tableDataCacheKey(
98
+ model: string | undefined,
99
+ endpoint: string | undefined,
100
+ branchId: string | number | null | undefined,
101
+ search: string,
102
+ ): string {
103
+ const p = new URLSearchParams(search)
104
+ const parts: string[] = []
105
+ p.forEach((v, k) => parts.push(`${k}=${v}`))
106
+ parts.sort()
107
+ return `${TBL_DATA_CACHE_PREFIX}|${model || ''}|${endpoint || ''}|${branchId || ''}|${parts.join('&')}`
108
+ }
109
+
110
+ function readTableDataCache(key: string): TableDataCacheEntry | null {
111
+ try {
112
+ const raw = sessionStorage.getItem(key)
113
+ if (!raw) return null
114
+ const parsed = JSON.parse(raw)
115
+ return parsed && Array.isArray(parsed.rows) ? parsed : null
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ function writeTableDataCache(key: string, rows: any[], rowCount: number): void {
122
+ try {
123
+ sessionStorage.setItem(
124
+ key,
125
+ JSON.stringify({ rows: rows.slice(0, 50), rowCount, ts: Date.now() }),
126
+ )
127
+ } catch {
128
+ // quota / private mode — the cache is a nicety, never fatal
129
+ }
130
+ }
131
+
80
132
  export interface DynamicTableProps {
81
133
  model: string
82
134
  endpoint?: string
@@ -148,12 +200,27 @@ export function DynamicTable({
148
200
  const cachedMeta = getMetadata(model)
149
201
 
150
202
  const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
151
- const [data, setData] = useState<any[]>([])
203
+ // Read the row-data cache ONCE at mount (before the fetch effects run) so the
204
+ // first paint uses the previous rows for this exact view instead of skeletons.
205
+ const bootDataKey = tableDataCacheKey(
206
+ model,
207
+ endpoint,
208
+ currentBranch?.id,
209
+ typeof window !== 'undefined' ? window.location.search : '',
210
+ )
211
+ const bootDataRef = useRef<TableDataCacheEntry | null | undefined>(undefined)
212
+ if (bootDataRef.current === undefined) {
213
+ bootDataRef.current = enableUrlSync ? readTableDataCache(bootDataKey) : null
214
+ }
215
+ const bootData = bootDataRef.current
216
+ const [data, setData] = useState<any[]>(bootData?.rows ?? [])
152
217
  // Footer totals: per-column SUM over the FILTERED set, fetched from a
153
218
  // separate /aggregate endpoint (NOT summed from the visible page).
154
219
  const [footerTotals, setFooterTotals] = useState<Record<string, any>>({})
155
220
  const [loading, setLoading] = useState(!cachedMeta)
156
- const [loadingData, setLoadingData] = useState(true)
221
+ // Cached rows no full-table skeleton on reload; the background fetch still
222
+ // runs and swaps in fresh data.
223
+ const [loadingData, setLoadingData] = useState(!(bootData?.rows?.length))
157
224
  // Infinite-scroll: a top-up page is in flight (distinct from the initial
158
225
  // page load so only a small bottom spinner shows, not the whole-table one).
159
226
  const [loadingMore, setLoadingMore] = useState(false)
@@ -182,7 +249,7 @@ export function DynamicTable({
182
249
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
183
250
  const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
184
251
  const [globalFilter, setGlobalFilter] = useState('')
185
- const [rowCount, setRowCount] = useState(0)
252
+ const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0)
186
253
 
187
254
  const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined)
188
255
  const [dynamicFilters, setDynamicFilters] = useState<Record<string, string[]>>({})
@@ -197,6 +264,17 @@ export function DynamicTable({
197
264
  // which reads as a URL flicker and fights the router's spelling. Starts at 10
198
265
  // to match the initial pagination state until metadata arrives.
199
266
  const defaultPerPage = useRef(10)
267
+ // Has the table finished adopting its state FROM the URL yet? The write
268
+ // effect must not run before this flips true. `initializedFromUrl` is a ref
269
+ // set synchronously at the top of the init effect, so on the very first
270
+ // commit the write effect (which runs right after, in the SAME commit) sees
271
+ // `initialized=true` but `dynamicFilters` still EMPTY — the init effect's
272
+ // setState hasn't re-rendered yet — and writes a URL WITHOUT `f_status`,
273
+ // stripping a deep-linked filter (the "recarga quita el filtro y parpadea"
274
+ // bug). A STATE flag defers the first write to the render AFTER the URL has
275
+ // been adopted, where `dynamicFilters`/`pagination` already mirror the URL,
276
+ // so the write is a no-op and the filter never gets stripped.
277
+ const [urlSynced, setUrlSynced] = useState(false)
200
278
 
201
279
  useEffect(() => {
202
280
  if (prevBranchId.current !== currentBranch?.id) {
@@ -289,6 +367,9 @@ export function DynamicTable({
289
367
  }
290
368
  })
291
369
  if (Object.keys(filters).length > 0) setDynamicFilters(filters)
370
+ // Adopted everything the URL carries; the write effect may now run on the
371
+ // NEXT render (where the setState above has landed) without clobbering it.
372
+ setUrlSynced(true)
292
373
  }, []) // eslint-disable-line react-hooks/exhaustive-deps
293
374
 
294
375
  // The exact query string this table last wrote to the URL — lets the
@@ -297,7 +378,7 @@ export function DynamicTable({
297
378
  const lastSelfSearch = useRef<string | null>(null)
298
379
 
299
380
  useEffect(() => {
300
- if (!enableUrlSync || !initializedFromUrl.current) return
381
+ if (!enableUrlSync || !urlSynced) return
301
382
  const params = new URLSearchParams()
302
383
  // Preserve the route-owned view params. The table rebuilds the query
303
384
  // string from scratch (it only knows its own page/sort/filter keys), but
@@ -343,7 +424,7 @@ export function DynamicTable({
343
424
  const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname
344
425
  lastSelfSearch.current = search ? `?${search}` : ''
345
426
  window.history.replaceState(null, '', newUrl)
346
- }, [enableUrlSync, pagination, sorting, globalFilter, dynamicFilters, defaultFilters])
427
+ }, [enableUrlSync, urlSynced, pagination, sorting, globalFilter, dynamicFilters, defaultFilters])
347
428
 
348
429
  // The host router can rewrite the query string WITHOUT remounting the
349
430
  // table — e.g. sidebar sibling entries deep-link different `f_` filters
@@ -543,15 +624,25 @@ export function DynamicTable({
543
624
  }
544
625
  const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
545
626
  if (res.data.success) {
546
- setData(res.data.data || [])
627
+ const rows = res.data.data || []
628
+ setData(rows)
547
629
  if (res.data.meta) setRowCount(res.data.meta.total)
630
+ // Cache the first page for an instant reload paint (see the cache
631
+ // helpers). Keyed off the live URL so it matches the next mount.
632
+ if (enableUrlSync && pagination.pageIndex === 0) {
633
+ writeTableDataCache(
634
+ tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search),
635
+ rows,
636
+ res.data.meta?.total ?? rows.length,
637
+ )
638
+ }
548
639
  }
549
640
  } catch (error) {
550
641
  console.error('Error al cargar los datos', error)
551
642
  } finally {
552
643
  setLoadingData(false)
553
644
  }
554
- }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api])
645
+ }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync])
555
646
 
556
647
  // Columns whose metadata opts into a footer total (display_config.aggregate
557
648
  // → styleConfig.aggregate). When empty, no footer row is rendered and no
@@ -599,6 +690,14 @@ export function DynamicTable({
599
690
  const rows = res.data.data || []
600
691
  setData((prev) => (append ? dedupeById(prev, rows) : rows))
601
692
  if (res.data.meta) setRowCount(res.data.meta.total)
693
+ // Cache the first (replace) page for an instant reload paint.
694
+ if (!append && enableUrlSync) {
695
+ writeTableDataCache(
696
+ tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search),
697
+ rows,
698
+ res.data.meta?.total ?? rows.length,
699
+ )
700
+ }
602
701
  // A short page means the backend has no more rows, even if
603
702
  // meta.total disagrees with the visible count (count query
604
703
  // vs list query drift, dedupe). Without this the sentinel
@@ -612,7 +711,7 @@ export function DynamicTable({
612
711
  else setLoadingData(false)
613
712
  }
614
713
  },
615
- [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id],
714
+ [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync],
616
715
  )
617
716
 
618
717
  // Signature of everything that must reset the incremental list to page 1: