@viliha/vui-ui 1.13.0 → 1.14.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/AGENT.md CHANGED
@@ -173,6 +173,18 @@ scaffolded files in the repo are two separate things.
173
173
  `npx @viliha/vui-ui@latest init --dry-run`, then re-run with `--force` (commit
174
174
  or stash your work first so you can review the diff) or copy in just the files
175
175
  you want.
176
+ - **Keep-alive tab cache (1.13.1) lives in a scaffolded file**, so an upgrade
177
+ won't bring it. In `app/_components/open-tabs.tsx`, inside `KeepAliveTabs`,
178
+ make sure the cache is written **once per route**, not on every render:
179
+ ```ts
180
+ // ✅ preserves the mounted page across tab switches (no remount, no refetch)
181
+ if (!cache.current.has(active)) cache.current.set(active, children);
182
+ // ❌ old: cache.current.set(active, children) // remounts on re-activation
183
+ ```
184
+ Re-pull the file with `init --force`, or apply this one-liner by hand.
185
+ - **Server tables that shouldn't refetch on tab switch:** use RecordView's
186
+ `fetcher` + `cacheKey` + `persistKey` (all in the package, so they arrive
187
+ with the upgrade) — see the Filtering/Tables notes above.
176
188
  5. **Re-check token overrides.** If you overrode any `theme.css` tokens, confirm
177
189
  they still line up after the upgrade.
178
190
  6. **Verify** before you commit: run type-check, lint, and build.
@@ -340,7 +352,12 @@ Form controls come from the field: `options` → `Select`, `input: "combobox"`
340
352
 
341
353
  Loading from a server: pass `loading` to `RecordView` while the fetch is in flight — it shows shimmering skeleton rows (matched to the columns) instead of an empty-state flash; clear it when the data arrives. Don't build your own spinner overlay.
342
354
 
343
- Server-side (large tables): set `manual` and RecordView stops filtering/sorting/paginating `data` it renders it as the current page and reports the query via `onQueryChange({ page, pageSize, sort, search, filters })`. Pair with `rowCount` (totals) and `loading`. In your handler, fetch and set `data` + `rowCount` + `loading`; guard out-of-order responses with a request-id ref and debounce chatty keyword changes. Don't reimplement pagination/sort yourself. Reference: the Data Table demo page (shadcn/ui section) hits a simulated backend.
355
+ Server-side (large tables) — two ways:
356
+
357
+ 1. **`fetcher` (preferred).** Pass `fetcher={(query, signal) => Promise<{ rows, total }>}` and RecordView owns the read path: it fetches on every query change, manages `data`/`rowCount`/`loading`, aborts superseded requests, and — with `cacheKey` — caches responses so returning to a tab is instant with no refetch. Add `persistKey` so page/sort/filters restore on remount and hit the cache. Optional `cache={{ max, ttlMs }}` and `onError`. Mutations are optimistic + invalidate + background refetch. Don't wire `data`/`onQueryChange`/`loading` yourself in this mode.
358
+ 2. **`manual` + `onQueryChange` (lower-level).** RecordView reports the query; you fetch and set `data` + `rowCount` + `loading`, guarding out-of-order responses and caching yourself.
359
+
360
+ Either way, don't reimplement pagination/sort. Reference: the Data Table demo page (shadcn/ui section) uses `fetcher` + `cacheKey` + `persistKey` against a simulated backend.
344
361
 
345
362
  Dependent/cascading options: make `options` (form) or `filterable.options` (filter) a function — `(draft) => …` in the form, `(values) => …` in the filter — and one field's choices depend on another; RecordView clears the child when the parent change invalidates it. Keep `options` a static array for the bulk "Set {label}" action. If a field has both a function `options` and `renderInput`, guard with `Array.isArray(field.options)` before mapping (renderInput doesn't get the draft to resolve it).
346
363
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ backward-compatible features, **major** for breaking changes.
7
7
 
8
8
  To upgrade, see [Upgrading](./AGENT.md#upgrading) in the agent guide.
9
9
 
10
+ ## 1.14.0 — 2026-07-26
11
+
12
+ ### Added
13
+
14
+ - **Built-in server data source for `RecordView`: `fetcher` + query cache.** Pass
15
+ `fetcher={(query, signal) => Promise<{ rows, total }>}` and RecordView owns the
16
+ whole read path — it fetches on every query change, manages `data` / `rowCount`
17
+ / `loading`, aborts superseded requests via the `signal`, and (with `cacheKey`)
18
+ caches responses so returning to a tab is instant with no refetch. Combine with
19
+ `persistKey` to restore page/sort/filters on remount and hit the cache. New
20
+ props: `fetcher`, `cacheKey`, `cache` (`{ max, ttlMs }` LRU), `onError`.
21
+ Mutations are optimistic → invalidate → background refetch. This replaces the
22
+ hand-wired `data`/`onQueryChange`/`loading` + `Map` boilerplate (still supported
23
+ as the lower-level API). The Data Table demo now uses it.
24
+ - **Changelog on the docs site** at `/docs/changelog`, rendered straight from this
25
+ file at build time (single source, never drifts). Linked in the docs nav.
26
+
27
+ **For agents:** for a server-backed table, prefer `fetcher` + `cacheKey` +
28
+ `persistKey` over wiring `onQueryChange`/`data`/`loading` + a cache yourself.
29
+ Docs: `/docs/data-table` (Server-side data → "Let RecordView own the fetch").
30
+
31
+ ## 1.13.2 — 2026-07-26
32
+
33
+ ### Fixed
34
+
35
+ - **Data Table demo no longer reloads on tab switch.** Keep-alive keeps a page
36
+ mounted, but under the App Router an async (server-fetching) page can still
37
+ remount on tab switch and re-run its fetch. The demo now caches fetched pages
38
+ in a module-scoped `Map` keyed by the query (served synchronously, no shimmer)
39
+ and passes `persistKey`, so returning to the tab restores the exact page/sort/
40
+ filters and shows the data instantly — no round-trip.
41
+
42
+ **For agents:** for a server-backed table, cache responses (module `Map` or
43
+ your data layer) and use `persistKey`; don't rely on keep-alive alone to avoid
44
+ refetches. Docs: `/docs/data-table` (Persisting across tab switches).
45
+
46
+ ## 1.13.1 — 2026-07-26
47
+
48
+ ### Fixed
49
+
50
+ - **Keep-alive tabs now truly preserve a page's live state across tab switches**
51
+ (within `NEXT_PUBLIC_MAX_TABS`). The scaffold's `KeepAliveTabs` was overwriting
52
+ a route's cached element with Next's fresh `children` on every re-activation,
53
+ which remounted the page and threw away its state — so a server-backed table
54
+ (e.g. the Data Table demo) refetched every time you returned to its tab. It now
55
+ caches each route's element once and reuses it, so switching away and back keeps
56
+ the loaded data, scroll, and form state with no reload. Query-param routes
57
+ (`/organizations/edit?id=…`) still update via their own `useSearchParams`.
58
+
10
59
  ## 1.13.0 — 2026-07-26
11
60
 
12
61
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
5
5
  "license": "MIT",
6
6
  "author": "Suman Bonakurthi",
@@ -352,6 +352,48 @@ function resolveOptions<T>(
352
352
  return typeof opts === "function" ? opts(draft) : (opts ?? []);
353
353
  }
354
354
 
355
+ // Module-scoped response cache for RecordView's `fetcher` mode. Namespaced by
356
+ // `cacheKey` and living outside any component, so a cached page survives a
357
+ // remount / tab switch — the return is instant with no refetch. LRU per
358
+ // namespace (insertion order = recency), optional TTL.
359
+ type RvCacheEntry = { rows: unknown[]; total: number; at: number };
360
+ const RV_CACHE = new Map<string, Map<string, RvCacheEntry>>();
361
+
362
+ function rvQueryKey<T>(q: ServerQuery<T>): string {
363
+ return JSON.stringify([q.page, q.pageSize, q.sort, q.search, q.filters]);
364
+ }
365
+ function rvCacheGet(
366
+ ns: string,
367
+ key: string,
368
+ ttlMs: number,
369
+ ): RvCacheEntry | null {
370
+ const bucket = RV_CACHE.get(ns);
371
+ const hit = bucket?.get(key);
372
+ if (!hit) return null;
373
+ if (ttlMs > 0 && Date.now() - hit.at > ttlMs) {
374
+ bucket!.delete(key);
375
+ return null;
376
+ }
377
+ // Refresh recency.
378
+ bucket!.delete(key);
379
+ bucket!.set(key, hit);
380
+ return hit;
381
+ }
382
+ function rvCacheSet(ns: string, key: string, entry: RvCacheEntry, max: number) {
383
+ let bucket = RV_CACHE.get(ns);
384
+ if (!bucket) {
385
+ bucket = new Map();
386
+ RV_CACHE.set(ns, bucket);
387
+ }
388
+ bucket.delete(key);
389
+ bucket.set(key, entry);
390
+ while (bucket.size > max) {
391
+ const oldest = bucket.keys().next().value;
392
+ if (oldest === undefined) break;
393
+ bucket.delete(oldest);
394
+ }
395
+ }
396
+
355
397
  interface RecordViewProps<T extends { id: RowId }> {
356
398
  title: string;
357
399
  singular: string;
@@ -413,6 +455,25 @@ interface RecordViewProps<T extends { id: RowId }> {
413
455
  * update `data` + `rowCount` + `loading` in response. Fires once on mount for
414
456
  * the initial load; debounce inside if keyword changes are chatty. */
415
457
  onQueryChange?: (query: ServerQuery<T>) => void;
458
+ /** Server data source. Providing it turns on `manual` and hands RecordView
459
+ * ownership of the read path: it calls this on every query change and manages
460
+ * `data` / `rowCount` / `loading` + caching itself — so you don't wire those
461
+ * or `onQueryChange`. Return the current page plus the server total. The
462
+ * `signal` aborts superseded requests. Mutually exclusive with the
463
+ * consumer-managed props above (if both are set, `fetcher` wins). */
464
+ fetcher?: (
465
+ query: ServerQuery<T>,
466
+ signal: AbortSignal,
467
+ ) => Promise<{ rows: T[]; total: number }>;
468
+ /** Namespaces the `fetcher` response cache (like `persistKey`). Responses are
469
+ * cached per query and survive remounts / tab switches, so returning to a tab
470
+ * is instant with no refetch. Omit → no caching (always refetch). */
471
+ cacheKey?: string;
472
+ /** `fetcher` cache tuning. Default `{ max: 50, ttlMs: 0 }` (LRU, no expiry). */
473
+ cache?: { max?: number; ttlMs?: number };
474
+ /** Called when a `fetcher` request rejects (non-abort). RecordView keeps the
475
+ * previously loaded data and clears the loading state. */
476
+ onError?: (error: unknown, query: ServerQuery<T>) => void;
416
477
  }
417
478
 
418
479
  export function RecordView<T extends { id: RowId }>({
@@ -439,16 +500,89 @@ export function RecordView<T extends { id: RowId }>({
439
500
  manual = false,
440
501
  rowCount,
441
502
  onQueryChange,
503
+ fetcher,
504
+ cacheKey,
505
+ cache,
506
+ onError,
442
507
  }: RecordViewProps<T>) {
443
508
  const { titleLeading } = React.useContext(PageChromeContext);
444
509
  // Surface the page title/icon in the app's global top bar.
445
510
  usePageTitle(title, TitleIcon);
446
- // Rows are either controlled (data + onDataChange) or held internally.
511
+ // Rows: `fetcher`-owned (server), controlled (data + onDataChange), or held
512
+ // internally. `fetcher` implies manual mode.
513
+ const fetching = fetcher !== undefined;
514
+ const isManual = manual || fetching;
447
515
  const [internalRows, setInternalRows] = React.useState<T[]>(initialData);
448
516
  const controlled = data !== undefined;
449
- const rows = controlled ? data : internalRows;
517
+
518
+ // Fetcher-managed state (only used when `fetcher` is set).
519
+ const [fetchedData, setFetchedData] = React.useState<T[]>([]);
520
+ const [fetchedTotal, setFetchedTotal] = React.useState(0);
521
+ const [fetchedLoading, setFetchedLoading] = React.useState(fetching);
522
+ const reqIdRef = React.useRef(0);
523
+ const abortRef = React.useRef<AbortController | null>(null);
524
+ const queryRef = React.useRef<ServerQuery<T> | null>(null);
525
+ const ttlMs = cache?.ttlMs ?? 0;
526
+ const cacheMax = cache?.max ?? 50;
527
+
528
+ const runFetch = React.useCallback(
529
+ (q: ServerQuery<T>, opts?: { background?: boolean }) => {
530
+ if (!fetcher) return;
531
+ // Foreground: serve from cache if present (instant, no shimmer).
532
+ if (!opts?.background && cacheKey) {
533
+ const hit = rvCacheGet(cacheKey, rvQueryKey(q), ttlMs);
534
+ if (hit) {
535
+ setFetchedData(hit.rows as T[]);
536
+ setFetchedTotal(hit.total);
537
+ setFetchedLoading(false);
538
+ return;
539
+ }
540
+ }
541
+ const id = ++reqIdRef.current;
542
+ abortRef.current?.abort();
543
+ const controller = new AbortController();
544
+ abortRef.current = controller;
545
+ if (!opts?.background) setFetchedLoading(true);
546
+ fetcher(q, controller.signal)
547
+ .then((res) => {
548
+ if (id !== reqIdRef.current) return; // superseded
549
+ if (cacheKey)
550
+ rvCacheSet(
551
+ cacheKey,
552
+ rvQueryKey(q),
553
+ { rows: res.rows, total: res.total, at: Date.now() },
554
+ cacheMax,
555
+ );
556
+ setFetchedData(res.rows);
557
+ setFetchedTotal(res.total);
558
+ setFetchedLoading(false);
559
+ })
560
+ .catch((err) => {
561
+ if (controller.signal.aborted || id !== reqIdRef.current) return;
562
+ setFetchedLoading(false);
563
+ onError?.(err, q);
564
+ });
565
+ },
566
+ [fetcher, cacheKey, ttlMs, cacheMax, onError],
567
+ );
568
+ // Abort any in-flight request on unmount.
569
+ React.useEffect(() => () => abortRef.current?.abort(), []);
570
+
571
+ const rows = fetching ? fetchedData : controlled ? data : internalRows;
450
572
  const setRows = React.useCallback(
451
573
  (updater: React.SetStateAction<T[]>) => {
574
+ if (fetching) {
575
+ // Optimistic local update, then invalidate the cache and refetch the
576
+ // current query in the background so the table reflects server truth.
577
+ setFetchedData((prev) =>
578
+ typeof updater === "function"
579
+ ? (updater as (p: T[]) => T[])(prev)
580
+ : updater,
581
+ );
582
+ if (cacheKey) RV_CACHE.delete(cacheKey);
583
+ if (queryRef.current) runFetch(queryRef.current, { background: true });
584
+ return;
585
+ }
452
586
  if (controlled) {
453
587
  const next =
454
588
  typeof updater === "function"
@@ -459,7 +593,7 @@ export function RecordView<T extends { id: RowId }>({
459
593
  setInternalRows(updater);
460
594
  }
461
595
  },
462
- [controlled, data, onDataChange],
596
+ [fetching, cacheKey, runFetch, controlled, data, onDataChange],
463
597
  );
464
598
  const [filter, setFilter] = usePersistentState(
465
599
  persistKey ? `${persistKey}::filter` : undefined,
@@ -588,7 +722,7 @@ export function RecordView<T extends { id: RowId }>({
588
722
  const processed = React.useMemo(() => {
589
723
  // Server mode: `rows` is already the filtered/sorted current page — render
590
724
  // it verbatim (no client-side filter/sort).
591
- if (manual) return rows;
725
+ if (isManual) return rows;
592
726
  let out = rows;
593
727
  const q = filter.trim().toLowerCase();
594
728
  if (q) {
@@ -617,7 +751,7 @@ export function RecordView<T extends { id: RowId }>({
617
751
  });
618
752
  }
619
753
  return out;
620
- }, [manual, rows, filter, sort, fields, getPrimary]);
754
+ }, [isManual, rows, filter, sort, fields, getPrimary]);
621
755
 
622
756
  const activeRow = rows.find((r) => r.id === activeId) ?? null;
623
757
  const deleteTarget =
@@ -628,18 +762,32 @@ export function RecordView<T extends { id: RowId }>({
628
762
  // Pagination (derived; `page` is clamped so it never points past the last page).
629
763
  // Server mode: totals come from `rowCount`, and `data` is already this page —
630
764
  // so render it whole (no slice) and size the range to what the server returned.
631
- const total = manual ? (rowCount ?? processed.length) : processed.length;
765
+ const total = isManual
766
+ ? fetching
767
+ ? fetchedTotal
768
+ : (rowCount ?? processed.length)
769
+ : processed.length;
632
770
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
633
771
  const safePage = Math.min(Math.max(1, page), totalPages);
634
772
  const rangeStart = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
635
- const rangeEnd = manual
773
+ const rangeEnd = isManual
636
774
  ? total === 0
637
775
  ? 0
638
776
  : Math.min(rangeStart + processed.length - 1, total)
639
777
  : Math.min(safePage * pageSize, total);
640
- const paged = manual
778
+ const paged = isManual
641
779
  ? processed
642
780
  : processed.slice((safePage - 1) * pageSize, safePage * pageSize);
781
+ // Loading state comes from the fetcher when it owns the data.
782
+ const effectiveLoading = fetching ? fetchedLoading : loading;
783
+ // Keep the current query fresh for post-mutation background refetches.
784
+ queryRef.current = {
785
+ page: safePage,
786
+ pageSize,
787
+ sort,
788
+ search: filter,
789
+ filters: filterValues,
790
+ };
643
791
 
644
792
  // Reset to the first page when the filter or page size changes.
645
793
  React.useEffect(() => {
@@ -652,16 +800,19 @@ export function RecordView<T extends { id: RowId }>({
652
800
  // apply on demand, not per keystroke. `filterValues` is read fresh here but
653
801
  // deliberately left out of the deps for that reason.
654
802
  React.useEffect(() => {
655
- if (!manual) return;
656
- onQueryChange?.({
803
+ if (!isManual) return;
804
+ const query: ServerQuery<T> = {
657
805
  page: safePage,
658
806
  pageSize,
659
807
  sort,
660
808
  search: filter,
661
809
  filters: filterValues,
662
- });
810
+ };
811
+ // `fetcher` owns the fetch; otherwise hand the query to the consumer.
812
+ if (fetching) runFetch(query);
813
+ else onQueryChange?.(query);
663
814
  // eslint-disable-next-line react-hooks/exhaustive-deps
664
- }, [manual, safePage, pageSize, sort, filter, onQueryChange]);
815
+ }, [isManual, fetching, safePage, pageSize, sort, filter, onQueryChange, runFetch]);
665
816
 
666
817
  // Cascading filter options: when the values change, drop any filter value no
667
818
  // longer valid once its options recompute (e.g. changing Region invalidates a
@@ -1308,14 +1459,15 @@ export function RecordView<T extends { id: RowId }>({
1308
1459
  setFilterValues({});
1309
1460
  onFilter?.({});
1310
1461
  setPage(1);
1311
- if (manual)
1312
- onQueryChange?.({
1313
- page: 1,
1314
- pageSize,
1315
- sort,
1316
- search: filter,
1317
- filters: {},
1318
- });
1462
+ const q: ServerQuery<T> = {
1463
+ page: 1,
1464
+ pageSize,
1465
+ sort,
1466
+ search: filter,
1467
+ filters: {},
1468
+ };
1469
+ if (fetching) runFetch(q);
1470
+ else if (manual) onQueryChange?.(q);
1319
1471
  }}
1320
1472
  >
1321
1473
  Clear
@@ -1325,14 +1477,15 @@ export function RecordView<T extends { id: RowId }>({
1325
1477
  onClick={() => {
1326
1478
  onFilter?.(filterValues);
1327
1479
  setPage(1);
1328
- if (manual)
1329
- onQueryChange?.({
1330
- page: 1,
1331
- pageSize,
1332
- sort,
1333
- search: filter,
1334
- filters: filterValues,
1335
- });
1480
+ const q: ServerQuery<T> = {
1481
+ page: 1,
1482
+ pageSize,
1483
+ sort,
1484
+ search: filter,
1485
+ filters: filterValues,
1486
+ };
1487
+ if (fetching) runFetch(q);
1488
+ else if (manual) onQueryChange?.(q);
1336
1489
  }}
1337
1490
  >
1338
1491
  <Search className="size-3.5" />
@@ -1544,7 +1697,7 @@ export function RecordView<T extends { id: RowId }>({
1544
1697
  </TableRow>
1545
1698
  </TableHeader>
1546
1699
  <TableBody>
1547
- {loading ? (
1700
+ {effectiveLoading ? (
1548
1701
  // Animated skeleton rows while data loads from the server.
1549
1702
  Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
1550
1703
  <TableRow key={`skeleton-${i}`} className="hover:bg-transparent">
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useRef, useState } from "react";
4
3
  import {
5
4
  BackpackIcon as TeamIcon,
6
5
  PersonIcon as RoleIcon,
@@ -39,7 +38,7 @@ const ALL: Member[] = Array.from({ length: 213 }, (_, i) => ({
39
38
  }));
40
39
 
41
40
  // Simulated server endpoint: filter + sort + paginate, returning just the page
42
- // (plus the total). Swap this for a real `fetch` to your API.
41
+ // (plus the total). Swap this for a real `fetch(url, { signal })` to your API.
43
42
  function fetchPage(
44
43
  q: ServerQuery<Member>,
45
44
  ): Promise<{ rows: Member[]; total: number }> {
@@ -100,59 +99,36 @@ const fields: RecordField<Member>[] = [
100
99
  },
101
100
  ];
102
101
 
103
- function ServerDataTable() {
104
- // Server-side mode: RecordView reports the query; we fetch the page and feed
105
- // back `data` + `rowCount` + `loading`. It never filters/sorts/paginates here.
106
- const [data, setData] = useState<Member[]>([]);
107
- const [rowCount, setRowCount] = useState(0);
108
- const [loading, setLoading] = useState(true);
109
- const reqId = useRef(0);
110
-
111
- const handleQuery = useCallback((q: ServerQuery<Member>) => {
112
- const id = ++reqId.current;
113
- setLoading(true);
114
- void fetchPage(q).then(({ rows, total }) => {
115
- if (id !== reqId.current) return; // ignore out-of-order responses
116
- setData(rows);
117
- setRowCount(total);
118
- setLoading(false);
119
- });
120
- }, []);
121
-
122
- return (
123
- <RecordView
124
- title="Data Table"
125
- singular="Member"
126
- icon={TableIcon}
127
- manual
128
- rowCount={rowCount}
129
- loading={loading}
130
- onQueryChange={handleQuery}
131
- fields={fields}
132
- initialData={data}
133
- data={data}
134
- onDataChange={setData}
135
- getPrimary={(row) => ({
136
- title: row.name,
137
- subtitle: `${row.team} · ${row.role}`,
138
- initials: row.name.slice(0, 2).toUpperCase(),
139
- })}
140
- makeEmptyRow={() => ({
141
- id: Date.now(),
142
- name: "",
143
- team: "",
144
- role: "",
145
- location: "",
146
- commits: 0,
147
- })}
148
- />
149
- );
150
- }
151
-
152
102
  export default function DataTablePage() {
103
+ // `fetcher` gives RecordView ownership of the read path: it calls the endpoint
104
+ // on every query change, manages data / rowCount / loading, and caches
105
+ // responses under `cacheKey`. With `persistKey`, returning to the tab restores
106
+ // the same page/sort/filters and hits the cache — instant, no refetch.
153
107
  return (
154
108
  <main className="h-full">
155
- <ServerDataTable />
109
+ <RecordView
110
+ title="Data Table"
111
+ singular="Member"
112
+ icon={TableIcon}
113
+ persistKey="/data-table"
114
+ cacheKey="data-table"
115
+ fetcher={fetchPage}
116
+ fields={fields}
117
+ initialData={[]}
118
+ getPrimary={(row) => ({
119
+ title: row.name,
120
+ subtitle: `${row.team} · ${row.role}`,
121
+ initials: row.name.slice(0, 2).toUpperCase(),
122
+ })}
123
+ makeEmptyRow={() => ({
124
+ id: Date.now(),
125
+ name: "",
126
+ team: "",
127
+ role: "",
128
+ location: "",
129
+ commits: 0,
130
+ })}
131
+ />
156
132
  </main>
157
133
  );
158
134
  }
@@ -503,11 +503,14 @@ export function KeepAliveTabs({ children }: { children: React.ReactNode }) {
503
503
  const active = tabKey(usePathname());
504
504
  const { tabs } = useOpenTabs();
505
505
  const cache = React.useRef(new Map<string, React.ReactNode>());
506
- // Refresh the active route's element (so re-opening an /edit tab with a
507
- // different record shows the right data); inactive tabs keep their cached
508
- // element and stay mounted. In-progress form work survives via RecordForm's
509
- // sessionStorage draft (see persistKey), which is StrictMode-safe.
510
- cache.current.set(active, children);
506
+ // Cache each route's element ONCE, on first activation, and reuse it — never
507
+ // overwrite on re-activation. Reusing the same element reference is what lets
508
+ // React keep the mounted instance (and its loaded data / scroll / form state)
509
+ // across tab switches; overwriting with Next's fresh `children` would remount
510
+ // the subtree and reload everything. Routes that vary by query param (e.g.
511
+ // /organizations/edit?id=…) share one tab and update themselves via
512
+ // `useSearchParams`, so they don't need a refresh here.
513
+ if (!cache.current.has(active)) cache.current.set(active, children);
511
514
  // Drop entries for tabs that have been closed.
512
515
  const open = new Set(tabs.map((t) => t.href));
513
516
  for (const key of [...cache.current.keys()]) {
@@ -163,6 +163,7 @@ export const PUBLIC_ROUTES: string[] = [
163
163
  "/docs/layout",
164
164
  "/docs/components",
165
165
  "/docs/data-table",
166
+ "/docs/changelog",
166
167
  "/docs/steps",
167
168
  "/docs/charts",
168
169
  "/docs/auth",