@jielga/tmdatagrid 2.2.0 → 2.3.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 (38) hide show
  1. package/dist/index.d.ts +41 -4
  2. package/dist/index.js +2087 -1968
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/docs/columns.md +2 -1
  6. package/docs/components.md +1 -0
  7. package/docs/filtering.md +3 -0
  8. package/docs/persistence.md +101 -2
  9. package/docs/row-details.md +5 -0
  10. package/docs/row-interaction.md +1 -1
  11. package/docs/row-styling.md +17 -0
  12. package/docs/use-tm-data-grid.md +6 -2
  13. package/package.json +1 -1
  14. package/skills/appearance/SKILL.md +1 -1
  15. package/skills/cell-selection/SKILL.md +1 -1
  16. package/skills/columns/SKILL.md +3 -3
  17. package/skills/columns/references/columns-api.md +1 -0
  18. package/skills/data/SKILL.md +1 -1
  19. package/skills/editing/SKILL.md +1 -1
  20. package/skills/filtering/SKILL.md +24 -12
  21. package/skills/getting-started/SKILL.md +1 -1
  22. package/skills/grouping/SKILL.md +1 -1
  23. package/skills/migrating-to-2/SKILL.md +1 -1
  24. package/skills/options/SKILL.md +81 -10
  25. package/skills/rows/SKILL.md +18 -9
  26. package/skills/server-side/SKILL.md +1 -1
  27. package/skills/testing/SKILL.md +1 -1
  28. package/skills/testing-components/SKILL.md +1 -1
  29. package/skills/testing-editing/SKILL.md +1 -1
  30. package/src/components/TMDataGridFilterPills.module.css +9 -7
  31. package/src/components/TMDataGridTable.module.css +15 -6
  32. package/src/components/TMDataGridTable.tsx +23 -11
  33. package/src/components/sticky.module.css +27 -7
  34. package/src/core/filterControls.ts +8 -0
  35. package/src/core/filterOperators.ts +13 -0
  36. package/src/core/persistence.ts +322 -26
  37. package/src/index.ts +1 -0
  38. package/src/useTMDataGrid.tsx +95 -14
@@ -1,5 +1,7 @@
1
1
  import type { TableState } from "@tanstack/react-table";
2
2
  import type { TMDataGridFeatures } from "../useTMDataGrid";
3
+ import { sameStateValue } from "./controlledState";
4
+ import { filterNarrowsRows } from "./filterOperators";
3
5
 
4
6
  type GridState = TableState<TMDataGridFeatures>;
5
7
 
@@ -65,11 +67,32 @@ export type TMDataGridPersistKey<TSlice extends string> =
65
67
  /** Same values as Mantine's `StorageType`, which the package does not re-export. */
66
68
  export type TMDataGridStorageMode = "localStorage" | "sessionStorage";
67
69
 
70
+ /**
71
+ * Reads and writes the one URL search param that `urlKey` names. Replaces the
72
+ * default, which reads `window.location.search` and writes with
73
+ * `history.replaceState`, for a router that owns the URL.
74
+ */
75
+ export type TMDataGridUrlStorage = {
76
+ /** The param's current value, or `null` when the URL has none. */
77
+ get(name: string): string | null;
78
+ /** Sets the param to `value`, or removes it when `value` is `null`. */
79
+ set(name: string, value: string | null): void;
80
+ };
81
+
68
82
  export type TMDataGridPersistence = {
69
83
  /** Key for filters, global filter, sorting and pagination. */
70
84
  dataKey?: TMDataGridPersistKey<TMDataGridDataSlice>;
71
85
  /** Key for column visibility, sizing, order and pinning. */
72
86
  settingsKey?: TMDataGridPersistKey<TMDataGridSettingsSlice>;
87
+ /**
88
+ * Search param for data slices kept in the URL, so a link opens the grid on
89
+ * the same state. Takes the slices it lists away from `dataKey`. The param
90
+ * holds only the slices that differ from the initial state, and carries no
91
+ * version stamp.
92
+ */
93
+ urlKey?: TMDataGridPersistKey<TMDataGridDataSlice>;
94
+ /** Where `urlKey` reads and writes. Defaults to the browser's URL. */
95
+ urlStorage?: TMDataGridUrlStorage;
73
96
  /** Which storage area to use. Defaults to `"localStorage"`. */
74
97
  storageMode?: TMDataGridStorageMode;
75
98
  /** Serializes a payload before it is stored. Defaults to `JSON.stringify`. */
@@ -104,6 +127,59 @@ function resolveKey<TSlice extends string>(
104
127
  return { key, slices: slices as ReadonlyArray<keyof GridState> };
105
128
  }
106
129
 
130
+ /**
131
+ * The browser's URL. Writes replace the current history entry and never push
132
+ * one: a filter change is not a navigation, and Back should leave the page
133
+ * rather than undo filters one at a time. Path, hash, every other param and
134
+ * `history.state` are kept - a router keeps its own bookkeeping there.
135
+ */
136
+ const defaultUrlStorage: TMDataGridUrlStorage = {
137
+ get: (name) => new URLSearchParams(window.location.search).get(name),
138
+ set: (name, value) => {
139
+ // Spliced by hand rather than through `URLSearchParams`, whose serializer
140
+ // re-encodes every param (`/a%20b` comes back as `%2Fa+b`). Only the
141
+ // grid's own pair changes; every other entry is kept byte for byte.
142
+ const { pathname, search, hash, href } = window.location;
143
+ const entries = search
144
+ .replace(/^\?/, "")
145
+ .split("&")
146
+ .filter((entry) => entry !== "");
147
+ const kept = entries.filter((entry) => paramEntryKey(entry) !== name);
148
+ if (value === null && kept.length === entries.length) return;
149
+ if (value !== null) {
150
+ kept.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
151
+ }
152
+ const next = `${pathname}${kept.length === 0 ? "" : `?${kept.join("&")}`}${hash}`;
153
+ if (new URL(next, href).href === href) return;
154
+ window.history.replaceState(window.history.state, "", next);
155
+ },
156
+ };
157
+
158
+ /** The decoded key of one `key=value` query entry, `+` read as a space. */
159
+ function paramEntryKey(entry: string): string {
160
+ const separator = entry.indexOf("=");
161
+ const raw = (separator === -1 ? entry : entry.slice(0, separator)).replaceAll(
162
+ "+",
163
+ " ",
164
+ );
165
+ try {
166
+ return decodeURIComponent(raw);
167
+ } catch {
168
+ // A malformed escape cannot be the grid's own key, which it encoded.
169
+ return raw;
170
+ }
171
+ }
172
+
173
+ function resolveUrlStorage(
174
+ persistence: TMDataGridPersistence | undefined,
175
+ ): TMDataGridUrlStorage | null {
176
+ if (!persistence) return null;
177
+ if (persistence.urlStorage) return persistence.urlStorage;
178
+ // Absent under SSR, like web storage. Access that throws is caught where the
179
+ // param is read or written.
180
+ return typeof window === "undefined" ? null : defaultUrlStorage;
181
+ }
182
+
107
183
  function resolveStorage(
108
184
  persistence: TMDataGridPersistence | undefined,
109
185
  ): Storage | null {
@@ -213,30 +289,44 @@ function isValidSlice(slice: keyof GridState, value: unknown): boolean {
213
289
  return guard === undefined || guard(value);
214
290
  }
215
291
 
216
- function readSlices(
217
- storage: Storage | null,
218
- resolved: ResolvedKey | null,
219
- deserialize: (value: string) => unknown,
220
- ): Partial<GridState> {
221
- if (!storage || !resolved) return {};
292
+ function readSlices({
293
+ read,
294
+ resolved,
295
+ deserialize,
296
+ versioned,
297
+ }: {
298
+ /** Returns the raw payload stored under a key, or `null`. */
299
+ read: ((key: string) => string | null) | null;
300
+ resolved: ResolvedKey | null;
301
+ deserialize: (value: string) => unknown;
302
+ /** Whether the payload must carry the current version stamp. */
303
+ versioned: boolean;
304
+ }): Partial<GridState> {
305
+ if (!read || !resolved) return {};
222
306
  try {
223
- const raw = storage.getItem(resolved.key);
307
+ const raw = read(resolved.key);
224
308
  if (!raw) return {};
225
309
  const parsed = deserialize(raw);
226
310
  if (typeof parsed !== "object" || parsed === null) return {};
227
311
 
228
312
  const record = parsed as Record<string, unknown>;
229
- if (record[VERSION_FIELD] !== PERSIST_PAYLOAD_VERSION) return {};
313
+ if (versioned && record[VERSION_FIELD] !== PERSIST_PAYLOAD_VERSION) {
314
+ return {};
315
+ }
230
316
  const restored: Record<string, unknown> = {};
231
317
  // Only copy the selected slices. A payload written before the selection was
232
318
  // narrowed cannot reintroduce slices the caller has since opted out of.
233
319
  for (const slice of resolved.slices) {
234
320
  if (!(slice in record)) continue;
235
- if (!isValidSlice(slice, record[slice])) continue;
321
+ // A `null` slice stands for `undefined` - see the writers. An own key
322
+ // holding `undefined` still overrides `initialState` in the merge, which
323
+ // is the point: the user cleared the slice. The slice's guard then
324
+ // judges `undefined`, so only a slice that may be `undefined` (the
325
+ // untyped global filter) restores that way.
326
+ const value = record[slice] === null ? undefined : record[slice];
327
+ if (!isValidSlice(slice, value)) continue;
236
328
  restored[slice] =
237
- slice === "columnPinning"
238
- ? normalizeColumnPinning(record[slice])
239
- : record[slice];
329
+ slice === "columnPinning" ? normalizeColumnPinning(value) : value;
240
330
  }
241
331
  return restored as Partial<GridState>;
242
332
  } catch {
@@ -252,7 +342,13 @@ function writeSlices(
252
342
  ): void {
253
343
  if (!storage || !resolved) return;
254
344
  const payload: Record<string, unknown> = { [VERSION_FIELD]: PERSIST_PAYLOAD_VERSION };
255
- for (const slice of resolved.slices) payload[slice] = state[slice];
345
+ for (const slice of resolved.slices) {
346
+ // `null` for a slice the user cleared to `undefined` - the global filter
347
+ // over an `initialState.globalFilter`. JSON drops an `undefined` key, and
348
+ // a missing slice means the initial state, so a reload would come back on
349
+ // the very value the user cleared. readSlices turns it back.
350
+ payload[slice] = state[slice] === undefined ? null : state[slice];
351
+ }
256
352
  try {
257
353
  storage.setItem(resolved.key, serialize(payload as Partial<GridState>));
258
354
  } catch {
@@ -260,9 +356,100 @@ function writeSlices(
260
356
  }
261
357
  }
262
358
 
359
+ /**
360
+ * Writes the URL payload: the selected slices that differ from `baseline`,
361
+ * with no version stamp, or no param at all when none differs.
362
+ *
363
+ * Only the difference goes in, so a link names what the user changed and not
364
+ * what the page starts with anyway: the address bar stays short, and an old
365
+ * link does not pin a default the page has since changed. No stamp, because a
366
+ * bookmarked link has to survive a release; the shape guards and the
367
+ * realignment on read are what keep an old link safe.
368
+ */
369
+ function writeUrlSlices({
370
+ urlStorage,
371
+ resolved,
372
+ state,
373
+ baseline,
374
+ excludedFilterColumnIds,
375
+ serialize,
376
+ }: {
377
+ urlStorage: TMDataGridUrlStorage | null;
378
+ resolved: ResolvedKey | null;
379
+ state: GridState;
380
+ baseline: Partial<GridState>;
381
+ excludedFilterColumnIds: ReadonlySet<string>;
382
+ serialize: (value: Partial<GridState>) => string;
383
+ }): void {
384
+ if (!urlStorage || !resolved) return;
385
+ const payload: Record<string, unknown> = {};
386
+ for (const slice of resolved.slices) {
387
+ // Both sides reduced the same way, so a change to an opted-out column or
388
+ // to a filter that narrows nothing does not put the param in the URL.
389
+ const value =
390
+ slice === "columnFilters"
391
+ ? linkableColumnFilters(state.columnFilters, excludedFilterColumnIds)
392
+ : state[slice];
393
+ const initial =
394
+ slice === "columnFilters"
395
+ ? linkableColumnFilters(baseline.columnFilters, excludedFilterColumnIds)
396
+ : baseline[slice];
397
+ if (sameStateValue(value, initial)) continue;
398
+ // `null` for a slice the user cleared to `undefined` - the global filter
399
+ // over an `initialState.globalFilter`. JSON drops an `undefined` key, and
400
+ // a missing slice means the initial state, so the link would reopen on
401
+ // the very value the user cleared. readSlices turns it back.
402
+ payload[slice] = value === undefined ? null : value;
403
+ }
404
+ try {
405
+ const next =
406
+ Object.keys(payload).length === 0
407
+ ? null
408
+ : serialize(payload as Partial<GridState>);
409
+ // Unchanged: no write. A router-backed storage may navigate on every
410
+ // `set`, and the debounced write runs after a resize or a selection too.
411
+ if (urlStorage.get(resolved.key) === next) return;
412
+ urlStorage.set(resolved.key, next);
413
+ } catch {
414
+ // History access refused, or a router's own storage threw. Best effort,
415
+ // like storage.
416
+ }
417
+ }
418
+
419
+ function withoutColumnFilters(
420
+ filters: GridState["columnFilters"] | undefined,
421
+ excluded: ReadonlySet<string>,
422
+ ): GridState["columnFilters"] | undefined {
423
+ if (filters === undefined || excluded.size === 0) return filters;
424
+ return filters.filter((filter) => !excluded.has(filter.id));
425
+ }
426
+
427
+ /**
428
+ * The column filters a link carries: not on an opted-out column, and narrowing
429
+ * the rows. A filter that narrows nothing is a row the filter panel seeded or
430
+ * the user has not filled in yet; in a link it would only reopen an empty row.
431
+ */
432
+ function linkableColumnFilters(
433
+ filters: GridState["columnFilters"] | undefined,
434
+ excluded: ReadonlySet<string>,
435
+ ): GridState["columnFilters"] | undefined {
436
+ return withoutColumnFilters(filters, excluded)?.filter(filterNarrowsRows);
437
+ }
438
+
263
439
  function resolveKeys(persistence: TMDataGridPersistence | undefined) {
440
+ const url = resolveKey(persistence?.urlKey, DATA_STATE_SLICES);
441
+ const data = resolveKey(persistence?.dataKey, DATA_STATE_SLICES);
264
442
  return {
265
- data: resolveKey(persistence?.dataKey, DATA_STATE_SLICES),
443
+ url,
444
+ // A slice lives in one group. The URL takes what both keys list, so a
445
+ // stored copy can never override the link that was opened.
446
+ data:
447
+ data !== null && url !== null
448
+ ? {
449
+ key: data.key,
450
+ slices: data.slices.filter((slice) => !url.slices.includes(slice)),
451
+ }
452
+ : data,
266
453
  settings: resolveKey(persistence?.settingsKey, SETTINGS_STATE_SLICES),
267
454
  };
268
455
  }
@@ -271,8 +458,27 @@ function resolveKeys(persistence: TMDataGridPersistence | undefined) {
271
458
  export function hasPersistenceKeys(
272
459
  persistence: TMDataGridPersistence | undefined,
273
460
  ): boolean {
274
- const { data, settings } = resolveKeys(persistence);
275
- return data !== null || settings !== null;
461
+ const { url, data, settings } = resolveKeys(persistence);
462
+ return url !== null || data !== null || settings !== null;
463
+ }
464
+
465
+ /**
466
+ * The data slices that a `dataKey` tuple names and `urlKey` also lists;
467
+ * `urlKey` takes them. A bare `dataKey` names no slice, so it reports nothing:
468
+ * "every data slice" plus a `urlKey` is the ordinary setup, and resolveKeys
469
+ * hands the URL its slices without a word. Only a slice named twice is a
470
+ * contradiction worth reporting.
471
+ */
472
+ export function findOverlappingSlices(
473
+ persistence: TMDataGridPersistence | undefined,
474
+ ): TMDataGridDataSlice[] {
475
+ if (typeof persistence?.dataKey === "string") return [];
476
+ const url = resolveKey(persistence?.urlKey, DATA_STATE_SLICES);
477
+ const data = resolveKey(persistence?.dataKey, DATA_STATE_SLICES);
478
+ if (url === null || data === null) return [];
479
+ return DATA_STATE_SLICES.filter(
480
+ (slice) => url.slices.includes(slice) && data.slices.includes(slice),
481
+ );
276
482
  }
277
483
 
278
484
  const defaultDeserialize = (value: string): unknown => JSON.parse(value);
@@ -290,11 +496,34 @@ export function collectLeafColumnIds(
290
496
  defs: ReadonlyArray<unknown>,
291
497
  into: string[] = [],
292
498
  ): string[] {
499
+ forEachLeafColumnDef(defs, (id) => into.push(id));
500
+ return into;
501
+ }
502
+
503
+ /**
504
+ * The leaf column ids whose `meta.filter.url` is `false`, by the same id
505
+ * derivation as {@link collectLeafColumnIds}. Their filters stay out of the
506
+ * `urlKey` param, on read and on write.
507
+ */
508
+ export function collectUrlExcludedColumnIds(
509
+ defs: ReadonlyArray<unknown>,
510
+ into: string[] = [],
511
+ ): string[] {
512
+ forEachLeafColumnDef(defs, (id, record) => {
513
+ if (prop(prop(record.meta, "filter"), "url") === false) into.push(id);
514
+ });
515
+ return into;
516
+ }
517
+
518
+ function forEachLeafColumnDef(
519
+ defs: ReadonlyArray<unknown>,
520
+ visit: (id: string, record: Record<string, unknown>) => void,
521
+ ): void {
293
522
  for (const def of defs) {
294
523
  if (typeof def !== "object" || def === null) continue;
295
524
  const record = def as Record<string, unknown>;
296
525
  if (Array.isArray(record.columns) && record.columns.length > 0) {
297
- collectLeafColumnIds(record.columns, into);
526
+ forEachLeafColumnDef(record.columns, visit);
298
527
  continue;
299
528
  }
300
529
  const accessorKey =
@@ -303,9 +532,8 @@ export function collectLeafColumnIds(
303
532
  (typeof record.id === "string" ? record.id : undefined) ??
304
533
  accessorKey?.replaceAll(".", "_") ??
305
534
  (typeof record.header === "string" ? record.header : undefined);
306
- if (id !== undefined) into.push(id);
535
+ if (id !== undefined) visit(id, record);
307
536
  }
308
- return into;
309
537
  }
310
538
 
311
539
  /**
@@ -351,8 +579,25 @@ function realignToColumns(
351
579
  return realigned;
352
580
  }
353
581
 
582
+ /** What the `urlKey` group needs beyond the persistence config itself. */
583
+ export type TMDataGridUrlPersistInputs = {
584
+ /**
585
+ * The state the table starts with when nothing is restored: the grid's
586
+ * defaults merged with `initialState`. The URL carries only the slices that
587
+ * differ from it. Without it, every slice that has a value is written. On
588
+ * read, a column opted out of the URL keeps its filter from it.
589
+ */
590
+ baseline?: Partial<GridState>;
591
+ /** See {@link collectUrlExcludedColumnIds}. */
592
+ excludedFilterColumnIds?: ReadonlyArray<string>;
593
+ /** Whether a write touches the URL. Defaults to `true`; ignored on read. */
594
+ writeUrl?: boolean;
595
+ };
596
+
354
597
  /**
355
- * State to merge into `initialState`, read once on mount.
598
+ * State to merge into `initialState`, read once on mount: settings, then data,
599
+ * then the URL. The groups are disjoint, since the URL takes the slices it
600
+ * shares with `dataKey`.
356
601
  *
357
602
  * With `knownColumnIds` - every id the grid is about to construct, generated
358
603
  * lanes included - the column-shaped slices are realigned first; see
@@ -361,28 +606,79 @@ function realignToColumns(
361
606
  export function readPersistedState(
362
607
  persistence?: TMDataGridPersistence,
363
608
  knownColumnIds?: ReadonlyArray<string>,
609
+ {
610
+ baseline = {},
611
+ excludedFilterColumnIds = [],
612
+ }: TMDataGridUrlPersistInputs = {},
364
613
  ): Partial<GridState> {
365
614
  const storage = resolveStorage(persistence);
366
- const { data, settings } = resolveKeys(persistence);
615
+ const readStorage = storage ? (key: string) => storage.getItem(key) : null;
616
+ const urlStorage = resolveUrlStorage(persistence);
617
+ const readUrl = urlStorage ? (key: string) => urlStorage.get(key) : null;
618
+ const { url, data, settings } = resolveKeys(persistence);
367
619
  const deserialize = persistence?.deserialize ?? defaultDeserialize;
620
+ const fromUrl = readSlices({
621
+ read: readUrl,
622
+ resolved: url,
623
+ deserialize,
624
+ versioned: false,
625
+ });
626
+ if (fromUrl.columnFilters) {
627
+ // The link never speaks for an opted-out column - the write leaves those
628
+ // out of both sides - so such a column keeps its initial filter, which
629
+ // the restored slice would otherwise replace along with the rest.
630
+ const excluded = new Set(excludedFilterColumnIds);
631
+ fromUrl.columnFilters = [
632
+ ...(baseline.columnFilters ?? []).filter((filter) =>
633
+ excluded.has(filter.id),
634
+ ),
635
+ ...(withoutColumnFilters(fromUrl.columnFilters, excluded) ?? []),
636
+ ];
637
+ }
368
638
  const restored = {
369
- ...readSlices(storage, settings, deserialize),
370
- ...readSlices(storage, data, deserialize),
639
+ ...readSlices({
640
+ read: readStorage,
641
+ resolved: settings,
642
+ deserialize,
643
+ versioned: true,
644
+ }),
645
+ ...readSlices({
646
+ read: readStorage,
647
+ resolved: data,
648
+ deserialize,
649
+ versioned: true,
650
+ }),
651
+ ...fromUrl,
371
652
  };
372
653
  return knownColumnIds === undefined
373
654
  ? restored
374
655
  : realignToColumns(restored, new Set(knownColumnIds));
375
656
  }
376
657
 
377
- /** Writes both payloads. Called on every table state change. */
658
+ /** Writes every configured payload. Called on every table state change. */
378
659
  export function writePersistedState(
379
660
  state: GridState,
380
661
  persistence?: TMDataGridPersistence,
662
+ {
663
+ baseline = {},
664
+ excludedFilterColumnIds = [],
665
+ writeUrl = true,
666
+ }: TMDataGridUrlPersistInputs = {},
381
667
  ): void {
668
+ const { url, data, settings } = resolveKeys(persistence);
669
+ const serialize = persistence?.serialize ?? defaultSerialize;
670
+ if (writeUrl) {
671
+ writeUrlSlices({
672
+ urlStorage: resolveUrlStorage(persistence),
673
+ resolved: url,
674
+ state,
675
+ baseline,
676
+ excludedFilterColumnIds: new Set(excludedFilterColumnIds),
677
+ serialize,
678
+ });
679
+ }
382
680
  const storage = resolveStorage(persistence);
383
681
  if (!storage) return;
384
- const { data, settings } = resolveKeys(persistence);
385
- const serialize = persistence?.serialize ?? defaultSerialize;
386
682
  writeSlices(storage, data, state, serialize);
387
683
  writeSlices(storage, settings, state, serialize);
388
684
  }
package/src/index.ts CHANGED
@@ -262,6 +262,7 @@ export {
262
262
  type TMDataGridPersistKey,
263
263
  type TMDataGridSettingsSlice,
264
264
  type TMDataGridStorageMode,
265
+ type TMDataGridUrlStorage,
265
266
  } from "./core/persistence";
266
267
  export type {
267
268
  TMDataGridCellEventArgs,
@@ -28,6 +28,7 @@ import {
28
28
  type DeepValue,
29
29
  type DisplayColumnDef,
30
30
  filterFns,
31
+ getInitialTableState,
31
32
  type GroupColumnDef,
32
33
  globalFilteringFeature,
33
34
  type IdentifiedColumnDef,
@@ -103,6 +104,8 @@ import {
103
104
  } from "./core/labels";
104
105
  import {
105
106
  collectLeafColumnIds,
107
+ collectUrlExcludedColumnIds,
108
+ findOverlappingSlices,
106
109
  hasPersistenceKeys,
107
110
  readPersistedState,
108
111
  type TMDataGridPersistence,
@@ -819,8 +822,9 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
819
822
  }
820
823
  ) & {
821
824
  /**
822
- * Restore and persist table state across mounts. Two keys, because the two
823
- * kinds of state have different lifetimes - see {@link TMDataGridPersistence}.
825
+ * Restore and persist table state across mounts. Two storage keys, because
826
+ * the two kinds of state have different lifetimes, and a URL search param
827
+ * for the data slices a link should carry - see {@link TMDataGridPersistence}.
824
828
  *
825
829
  * Keep the object referentially stable (module scope or `useMemo`); it is a
826
830
  * dependency of the subscription that writes back.
@@ -1132,6 +1136,17 @@ function withoutControlColumnVisibility(
1132
1136
  return scrubbed;
1133
1137
  }
1134
1138
 
1139
+ /**
1140
+ * The pagination the table starts with before anything is restored. Shared by
1141
+ * the table's `initialState` and the persistence baseline, so the URL param
1142
+ * compares against the page size the table really starts on.
1143
+ */
1144
+ function startingPagination(
1145
+ initial: Partial<TableState<TMDataGridFeatures>> | undefined,
1146
+ ): TableState<TMDataGridFeatures>["pagination"] {
1147
+ return { pageIndex: 0, pageSize: 25, ...initial?.pagination };
1148
+ }
1149
+
1135
1150
  /**
1136
1151
  * Point every column at the operator-dispatching filter function, and take
1137
1152
  * the aggregation defaults back off, unless the column opted into its own.
@@ -1377,13 +1392,34 @@ export function useTMDataGrid<TData extends RowData>({
1377
1392
  editColumnLabel,
1378
1393
  ]);
1379
1394
 
1395
+ // Leaf columns whose filter stays out of the `urlKey` param. Follows the
1396
+ // columns, since the write subscription below outlives a column change.
1397
+ const urlExcludedColumnIds = useMemo(
1398
+ () => collectUrlExcludedColumnIds(columns),
1399
+ [columns],
1400
+ );
1401
+ // What the table would start with if nothing were restored: TanStack's
1402
+ // feature defaults under the consumer's `initialState`, with the grid's own
1403
+ // page size. The `urlKey` param carries only the slices that differ from
1404
+ // it, and an opted-out column keeps its filter from it on read. Only the
1405
+ // data slices are used, so the layout part of the `initialState` recipe
1406
+ // below is not repeated here. Read once on mount, like `initialState`.
1407
+ const [persistBaseline] = useState(() =>
1408
+ getInitialTableState(tmDataGridFeatures, {
1409
+ ...options.initialState,
1410
+ pagination: startingPagination(options.initialState),
1411
+ }),
1412
+ );
1380
1413
  // Read once on mount: `initialState` is only consumed on the first render,
1381
1414
  // and re-reading later would fight the user's live edits.
1382
1415
  // Realigned against the ids this render is about to construct - lanes
1383
1416
  // included - so a column removed between deploys does not leave a ghost
1384
1417
  // sort, filter or width behind.
1385
1418
  const [persistedState] = useState(() =>
1386
- readPersistedState(persist, collectLeafColumnIds(columns)),
1419
+ readPersistedState(persist, collectLeafColumnIds(columns), {
1420
+ baseline: persistBaseline,
1421
+ excludedFilterColumnIds: urlExcludedColumnIds,
1422
+ }),
1387
1423
  );
1388
1424
 
1389
1425
  // Restored grouping decides whether the tree column starts out visible, so a
@@ -1731,9 +1767,7 @@ export function useTMDataGrid<TData extends RowData>({
1731
1767
  options.initialState?.columnPinning?.end,
1732
1768
  }),
1733
1769
  pagination: {
1734
- pageIndex: 0,
1735
- pageSize: 25,
1736
- ...options.initialState?.pagination,
1770
+ ...startingPagination(options.initialState),
1737
1771
  ...persistedState.pagination,
1738
1772
  },
1739
1773
  },
@@ -1826,6 +1860,19 @@ export function useTMDataGrid<TData extends RowData>({
1826
1860
  // eslint-disable-next-line react-hooks/exhaustive-deps
1827
1861
  }, []);
1828
1862
 
1863
+ // A slice listed under both `dataKey` and `urlKey` is handled by `urlKey`
1864
+ // alone - see resolveKeys in persistence.ts. Reported once, because the
1865
+ // consumer most likely expects the stored copy to still apply.
1866
+ useEffect(() => {
1867
+ for (const slice of findOverlappingSlices(persist)) {
1868
+ console.error(
1869
+ `TMDataGrid: persist.dataKey and persist.urlKey both list ${slice} - a slice lives in one group; urlKey takes it.`,
1870
+ );
1871
+ }
1872
+ // A mount-time contract, like the checks above.
1873
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1874
+ }, []);
1875
+
1829
1876
  // The tree column appears with the first grouped column and goes away with
1830
1877
  // the last, so an ungrouped grid looks exactly as it did before grouping
1831
1878
  // existed. Driven from a subscription rather than by rebuilding the column
@@ -1892,33 +1939,67 @@ export function useTMDataGrid<TData extends RowData>({
1892
1939
  // selection - and `setItem` serialises synchronously on the main thread. The
1893
1940
  // trailing edge is enough: storage only has to agree with the table once the
1894
1941
  // user stops.
1942
+ //
1943
+ // The `urlKey` inputs are read through a ref: the excluded columns follow
1944
+ // the column defs, and re-subscribing on a column change would write once
1945
+ // more for nothing.
1946
+ const urlPersistInputsRef = useRef({
1947
+ baseline: persistBaseline,
1948
+ excludedFilterColumnIds: urlExcludedColumnIds,
1949
+ });
1950
+ urlPersistInputsRef.current = {
1951
+ baseline: persistBaseline,
1952
+ excludedFilterColumnIds: urlExcludedColumnIds,
1953
+ };
1954
+ // Keyed on the store, not the table: `useTable` returns a new copy of the
1955
+ // table on every render (it memoizes on the options object, built inline
1956
+ // above), while every copy shares the one store. Keyed on the table, each
1957
+ // render re-ran this effect, whose cleanup flushed and whose setup wrote
1958
+ // again at once - no debounce at all.
1959
+ const tableStore = table.store;
1895
1960
  useEffect(() => {
1896
1961
  if (!hasPersistenceKeys(persist)) return;
1897
- writePersistedState(table.store.state, persist);
1962
+ writePersistedState(tableStore.state, persist, urlPersistInputsRef.current);
1898
1963
 
1899
1964
  let timeout: ReturnType<typeof setTimeout> | undefined;
1900
1965
  let pending: TableState<TMDataGridFeatures> | null = null;
1901
1966
 
1902
- const flush = () => {
1967
+ const flush = ({ writeUrl }: { writeUrl: boolean }) => {
1903
1968
  timeout = undefined;
1904
1969
  if (pending === null) return;
1905
- writePersistedState(pending, persist);
1970
+ writePersistedState(pending, persist, {
1971
+ ...urlPersistInputsRef.current,
1972
+ writeUrl,
1973
+ });
1906
1974
  pending = null;
1907
1975
  };
1908
1976
 
1909
- const subscription = table.store.subscribe((state) => {
1977
+ // Where the grid was when it subscribed, to tell an unmount by navigation
1978
+ // from an in-page one - see the cleanup.
1979
+ const subscribedPathname =
1980
+ typeof window === "undefined" ? null : window.location.pathname;
1981
+
1982
+ const subscription = tableStore.subscribe((state) => {
1910
1983
  pending = state;
1911
1984
  if (timeout !== undefined) clearTimeout(timeout);
1912
- timeout = setTimeout(flush, PERSIST_DEBOUNCE_MS);
1985
+ timeout = setTimeout(() => flush({ writeUrl: true }), PERSIST_DEBOUNCE_MS);
1913
1986
  });
1914
1987
 
1915
1988
  return () => {
1916
1989
  subscription.unsubscribe();
1917
1990
  if (timeout !== undefined) clearTimeout(timeout);
1918
- // An unmount mid-debounce would otherwise drop the last change.
1919
- flush();
1991
+ // An unmount mid-debounce would otherwise drop the last change. The URL
1992
+ // only while still on the page: an SPA navigation changes the pathname
1993
+ // before React unmounts the grid, so the param would land on the next
1994
+ // page's URL. An in-page unmount - a modal closing, a tab panel
1995
+ // switching - leaves the pathname alone and keeps its last filter.
1996
+ flush({
1997
+ writeUrl:
1998
+ subscribedPathname !== null &&
1999
+ subscribedPathname === window.location.pathname,
2000
+ });
1920
2001
  };
1921
- }, [persist, table]);
2002
+ }, [persist, tableStore]);
1922
2003
 
1923
2004
  const ui = useCreateStore<TMDataGridUiState, TMDataGridUiActions>(
1924
2005
  {