@trackunit/react-table 2.11.8-alpha-0ef4e15e7c1.0 → 2.11.8-alpha-775ca1816d6.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/index.esm.js CHANGED
@@ -3,7 +3,7 @@ import { registerTranslations, useNamespaceTranslation, NamespaceTrans } from '@
3
3
  import { Icon, MenuItem, Button, Tooltip, useOverflowItems, MoreMenu, MenuContent, Spacer, usePersistedState, useDebounce, Text, cvaInteractableItem, Popover, PopoverTrigger, IconButton, PopoverContent, cvaMenuList, cvaMenu, useBidirectionalScroll, noPagination, Card, HorizontalOverflowScroller, Spinner, EmptyState, SkeletonLabel } from '@trackunit/react-components';
4
4
  import { Link } from '@tanstack/react-router';
5
5
  import { objectValues, VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, objectKeys, nonNullable, objectEntries } from '@trackunit/shared-utils';
6
- import { useMemo, Children, cloneElement, useCallback, useRef, useEffect, useState, createElement } from 'react';
6
+ import { useMemo, Children, cloneElement, useEffect, useState, useCallback, useRef, createElement } from 'react';
7
7
  import { cva } from '@trackunit/css-class-variance-utilities';
8
8
  import { useCurrentUser } from '@trackunit/react-core-hooks';
9
9
  import { z } from 'zod';
@@ -364,142 +364,88 @@ const ActionSheet = ({ actions, dropdownActions, moreActions = [], selections, r
364
364
  };
365
365
 
366
366
  /**
367
- * Short keys used in URL-hash-encoded table state to minimize fragment
368
- * length. localStorage continues to use the full key names. The full URL
369
- * still benefits from the shorter payload — even though the hash fragment
370
- * is no longer subject to the AWS WAF query-string cap, the resulting
371
- * fragment is what shows in shared links and browser history entries.
367
+ * The URL projection of table state: everything except `expanded`.
368
+ *
369
+ * Row expansion is per-session, per-user detail and would make a shared link
370
+ * open rows the recipient has no interest in, so it lives only in
371
+ * localStorage. `usePersistedState`'s `mergeWithStorageOnRead` restores it on
372
+ * load. The remaining fields are written as-is; the gzip step in
373
+ * `useCustomEncoding` handles the size, and a value that reads back to the
374
+ * same shape it was written from cannot drift from storage.
372
375
  */
373
- const URL_KEY_MAP = {
374
- columnOrder: "co",
375
- columnVisibility: "cv",
376
- sorting: "s",
377
- columnPinning: "cp",
378
- columnSizing: "cs",
379
- };
380
- const REVERSE_URL_KEY_MAP = Object.fromEntries(Object.entries(URL_KEY_MAP).map(([full, short]) => [short, full]));
381
- const compactPinningSchema = z.object({
382
- l: z.array(z.string()).optional(),
383
- r: z.array(z.string()).optional(),
384
- left: z.array(z.string()).optional(),
385
- right: z.array(z.string()).optional(),
386
- });
376
+ const toTableUrlValue = ({ expanded: _expanded, ...shareable }) => shareable;
387
377
  /**
388
- * Compacts table state for URL encoding:
389
- * - Uses short keys (co, cv, s, cp)
390
- * - Stores columnVisibility as a visible-column array only
391
- * - Stores columnOrder as visible-column order when columnVisibility is available
392
- * - Drops empty pinning arrays
393
- * - Excludes `expanded` (not shareable)
378
+ * Short keys used by the compact URL format that was written before the hash
379
+ * carried the full state. Links and bookmarks with that format still exist,
380
+ * so reads keep accepting it.
394
381
  */
395
- const compactForUrl = (state) => {
396
- const compact = {};
397
- const columnVisibilityEntries = state.columnVisibility
398
- ? Object.entries(state.columnVisibility).filter(([key]) => key !== VISIBLE_ONLY_COLUMN_VISIBILITY_KEY)
399
- : undefined;
400
- const visibleColumns = columnVisibilityEntries?.filter(([, visible]) => visible).map(([key]) => key) ?? [];
401
- const visibleColumnSet = new Set(visibleColumns);
402
- const hasColumnVisibility = (columnVisibilityEntries?.length ?? 0) > 0;
403
- if (hasColumnVisibility) {
404
- compact[URL_KEY_MAP.columnVisibility] = visibleColumns;
405
- }
406
- if (state.columnOrder && state.columnOrder.length > 0) {
407
- compact[URL_KEY_MAP.columnOrder] = hasColumnVisibility
408
- ? [
409
- ...state.columnOrder.filter(columnId => visibleColumnSet.has(columnId)),
410
- ...visibleColumns.filter(columnId => !state.columnOrder?.includes(columnId)),
411
- ]
412
- : state.columnOrder;
413
- }
414
- if (state.sorting && state.sorting.length > 0) {
415
- compact[URL_KEY_MAP.sorting] = state.sorting;
416
- }
417
- if (state.columnSizing && Object.keys(state.columnSizing).length > 0) {
418
- compact[URL_KEY_MAP.columnSizing] = state.columnSizing;
419
- }
420
- if (state.columnPinning) {
421
- const pinning = {};
422
- if (state.columnPinning.left && state.columnPinning.left.length > 0) {
423
- pinning.l = state.columnPinning.left;
424
- }
425
- if (state.columnPinning.right && state.columnPinning.right.length > 0) {
426
- pinning.r = state.columnPinning.right;
427
- }
428
- if (Object.keys(pinning).length > 0) {
429
- compact[URL_KEY_MAP.columnPinning] = pinning;
430
- }
431
- }
432
- return compact;
382
+ const LEGACY_URL_KEY_MAP = {
383
+ co: "columnOrder",
384
+ cv: "columnVisibility",
385
+ s: "sorting",
386
+ cp: "columnPinning",
387
+ cs: "columnSizing",
433
388
  };
434
- const compactRecordSchema = z.record(z.string(), z.unknown());
435
- const visibilityArraySchema = z.array(z.string());
436
- const compactVisibilitySchema = z
389
+ const recordSchema = z.record(z.string(), z.unknown());
390
+ const legacyVisibleListSchema = z.array(z.string());
391
+ const legacyVisibilitySchema = z
437
392
  .object({
438
393
  v: z.array(z.string()).optional(),
439
394
  h: z.array(z.string()).optional(),
440
395
  })
441
396
  .strict();
397
+ const legacyPinningSchema = z.object({
398
+ l: z.array(z.string()).optional(),
399
+ r: z.array(z.string()).optional(),
400
+ left: z.array(z.string()).optional(),
401
+ right: z.array(z.string()).optional(),
402
+ });
442
403
  /**
443
- * Expands a compact URL-decoded object back into full TableColumnOptions shape.
404
+ * Normalises a decoded URL value into the full `TableColumnOptions` shape.
405
+ *
406
+ * A value written by {@link toTableUrlValue} passes through unchanged, except
407
+ * that a `columnPinning` with one side missing gets the other side filled in
408
+ * as `[]`. Values in the legacy compact format are expanded:
409
+ * - short keys (`co`, `cv`, `s`, `cp`, `cs`) become full names;
410
+ * - a `columnVisibility` array of visible column IDs becomes a visible-only
411
+ * map (see `VISIBLE_ONLY_COLUMN_VISIBILITY_KEY`);
412
+ * - a `columnVisibility` `{ v, h }` pair becomes explicit booleans;
413
+ * - `columnPinning` `{ l, r }` becomes `{ left, right }`.
414
+ *
415
+ * Non-object input is returned as-is so validation can reject it.
444
416
  */
445
- const expandFromUrl = (decoded) => {
446
- const parsed = compactRecordSchema.safeParse(decoded);
417
+ const fromTableUrlValue = (decoded) => {
418
+ const parsed = recordSchema.safeParse(decoded);
447
419
  if (!parsed.success) {
448
420
  return decoded;
449
421
  }
450
422
  const expanded = {};
451
423
  for (const [key, value] of Object.entries(parsed.data)) {
452
- const fullKey = REVERSE_URL_KEY_MAP[key] ?? key;
453
- expanded[fullKey] = value;
424
+ expanded[LEGACY_URL_KEY_MAP[key] ?? key] = value;
454
425
  }
455
- const visibilityResult = visibilityArraySchema.safeParse(expanded.columnVisibility);
456
- if (visibilityResult.success) {
426
+ const visibleList = legacyVisibleListSchema.safeParse(expanded.columnVisibility);
427
+ if (visibleList.success) {
457
428
  expanded.columnVisibility = Object.fromEntries([
458
429
  [VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, true],
459
- ...visibilityResult.data.map(col => [col, true]),
430
+ ...visibleList.data.map(col => [col, true]),
460
431
  ]);
461
432
  }
462
- const compactVisibilityResult = compactVisibilitySchema.safeParse(expanded.columnVisibility);
463
- if (compactVisibilityResult.success) {
433
+ const legacyVisibility = legacyVisibilitySchema.safeParse(expanded.columnVisibility);
434
+ if (legacyVisibility.success) {
464
435
  expanded.columnVisibility = Object.fromEntries([
465
- ...(compactVisibilityResult.data.v ?? []).map(col => [col, true]),
466
- ...(compactVisibilityResult.data.h ?? []).map(col => [col, false]),
436
+ ...(legacyVisibility.data.v ?? []).map(col => [col, true]),
437
+ ...(legacyVisibility.data.h ?? []).map(col => [col, false]),
467
438
  ]);
468
439
  }
469
- const pinningResult = compactPinningSchema.safeParse(expanded.columnPinning);
470
- if (pinningResult.success) {
440
+ const legacyPinning = legacyPinningSchema.safeParse(expanded.columnPinning);
441
+ if (legacyPinning.success) {
471
442
  expanded.columnPinning = {
472
- left: pinningResult.data.l ?? pinningResult.data.left ?? [],
473
- right: pinningResult.data.r ?? pinningResult.data.right ?? [],
443
+ left: legacyPinning.data.l ?? legacyPinning.data.left ?? [],
444
+ right: legacyPinning.data.r ?? legacyPinning.data.right ?? [],
474
445
  };
475
446
  }
476
447
  return expanded;
477
448
  };
478
- /**
479
- * Provides transform functions for compacting table state before URL encoding
480
- * and expanding it back after URL decoding.
481
- *
482
- * The compaction strategy:
483
- * - Short property keys (columnOrder → co, columnVisibility → cv, etc.)
484
- * - columnVisibility stored as an array of visible column names only
485
- * - columnOrder stored as visible-column order when visibility is available
486
- * - columnSizing stored under a short key (`cs`)
487
- * - `expanded` is excluded from the URL and restored from localStorage via
488
- * `usePersistedState`'s `mergeWithStorageOnRead`
489
- * - Empty pinning arrays are excluded
490
- *
491
- * These transforms are designed to plug into {@link usePersistedState}'s
492
- * `toUrlValue` and `fromUrlValue` options.
493
- */
494
- const useCompactTableUrl = () => {
495
- const toUrlValue = useCallback((state) => {
496
- return compactForUrl(state);
497
- }, []);
498
- const fromUrlValue = useCallback((decoded) => {
499
- return expandFromUrl(decoded);
500
- }, []);
501
- return useMemo(() => ({ toUrlValue, fromUrlValue }), [toUrlValue, fromUrlValue]);
502
- };
503
449
 
504
450
  const PERSISTENCE_KEY_MAX_LENGTH = 15;
505
451
  const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
@@ -531,68 +477,77 @@ const validateTableState = (raw) => {
531
477
  return result.success ? result.data : undefined;
532
478
  };
533
479
  /**
534
- * Changes reported before `clientSideUserId` resolves carry no owner — the user
535
- * was already editing while their id was in flight — so the identity that
536
- * resolves adopts them. Once a defined identity owns the reported state it must
537
- * not be adopted by a later identity, including a brief window where the
538
- * current id is unset: treating that gap as unowned would copy one person's
539
- * layout into the next user's scoped storage.
480
+ * Drops fields that hold nothing (empty arrays, empty maps, pinning with no
481
+ * side) so a reset column is removed from storage rather than stored as empty.
540
482
  */
541
- const isOwnedByCurrentUser = (ownerId, clientSideUserId) => ownerId === undefined || ownerId === clientSideUserId;
483
+ const pruneEmptyFields = (state) => {
484
+ const { columnOrder, columnSizing, columnVisibility, sorting, columnPinning, expanded } = state;
485
+ const pruned = {};
486
+ if (columnOrder && columnOrder.length > 0) {
487
+ pruned.columnOrder = columnOrder;
488
+ }
489
+ if (columnSizing && objectKeys(columnSizing).length > 0) {
490
+ pruned.columnSizing = columnSizing;
491
+ }
492
+ if (columnVisibility && objectKeys(columnVisibility).length > 0) {
493
+ pruned.columnVisibility = columnVisibility;
494
+ }
495
+ if (sorting && sorting.length > 0) {
496
+ pruned.sorting = sorting;
497
+ }
498
+ if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
499
+ pruned.columnPinning = columnPinning;
500
+ }
501
+ if (expanded === true || (typeof expanded === "object" && objectKeys(expanded).length > 0)) {
502
+ pruned.expanded = expanded;
503
+ }
504
+ return pruned;
505
+ };
506
+ const URL_WRITE_DEBOUNCE_MS = 300;
542
507
  /**
543
508
  * Persists and restores table column state (order, sizing, visibility, sorting, pinning, expanded) using
544
- * the URL **hash fragment** and localStorage.
509
+ * localStorage and the URL **hash fragment**.
545
510
  *
546
511
  * Storage scope:
547
- * - URL hash (shareable): `columnOrder`, `columnVisibility`, `sorting`, `columnPinning`, `columnSizing`.
548
- * - localStorage: everything above plus `expanded`.
512
+ * - localStorage: everything, scoped to the current user.
513
+ * - URL hash (shareable): everything except `expanded`.
549
514
  *
550
515
  * The hash slot is used instead of search params because some deployments
551
516
  * route through AWS WAF, whose `SizeRestrictions_QUERYSTRING` managed rule
552
517
  * caps the query string at ~5000 bytes — easily breached by wide tables.
553
518
  * The fragment is client-only and bypasses that cap entirely (subject to a
554
- * 64 KiB soft limit enforced by `useHashParamSync`).
555
- *
556
- * Legacy compatibility: shared links that still use `?<persistenceKey>Tp=`
557
- * search params from before the hash migration are decoded on first load
558
- * and the search param is stripped via a `replace` navigation so old
559
- * bookmarks and forwarded URLs continue to restore state without polluting
560
- * browser history.
561
- *
562
- * On mount, state is loaded from the hash (or legacy search param as a
563
- * one-shot fallback) and merged with localStorage so fields omitted from
564
- * the URL, such as `expanded`, still survive reloads. Opening that URL
565
- * saves the merged layout for the account that loaded the page, so a
566
- * pasted link sticks without a further edit. An account that becomes
567
- * current later, while the same URL is still open, does not receive that
568
- * write — their storage changes only when they edit the table. Switching
569
- * back to the opener does not write the hash that is on screen then.
570
- *
571
- * A layout that exists only in localStorage is not copied into the hash on
572
- * load. Doing that put the current layout into every history entry, so a
573
- * later visit via history, a bookmark, or a login redirect replayed an
574
- * older layout and saved it over the current one (GLU-2396). The hash is
575
- * written when the user changes the table, replacing the current history
576
- * entry, or when they open a URL that already carries one. A push per edit
577
- * would leave the same replayable entries behind.
578
- *
579
- * `onTableStateChange` takes a *partial* state and merges it into whatever
580
- * was reported earlier in the session, so a consumer can report a single
581
- * slice (e.g. only `expanded`) without the omitted fields falling back to
582
- * their mount-time values. A field reported empty is dropped from the stored
583
- * state, and once every field is empty the entry is removed altogether.
584
- *
585
- * Reported state is scoped to the identity that reported it: when the current
586
- * user changes, state reported by the previous user is discarded instead of
587
- * being written to the new user's storage. A layout shared through the URL
588
- * renders for anyone. It is saved for the account that opened the page, and
589
- * only becomes a later account's layout once that account changes the table.
590
- *
591
- * The hash always wins on load so a link or bookmark renders the same layout
592
- * for whoever opens it. Each hash write is stamped with the identity that
593
- * wrote it; the stamp is read only by the login redirect, which drops a hash
594
- * left behind by a *different* account that also uses this browser so
595
- * accounts sharing a PC do not hand their layouts to each other (GLU-2396).
519
+ * 64 KiB soft limit enforced by the URL sync).
520
+ *
521
+ * On load the hash wins, so a link or bookmark renders the same layout for
522
+ * whoever opens it, and localStorage fills in what the hash omits (`expanded`).
523
+ * Opening a URL that carries a layout saves it for the account that loaded the
524
+ * page, so a pasted link sticks without a further edit. A layout that exists
525
+ * only in localStorage is never copied into the hash on load: doing that put
526
+ * the current layout into every history entry, so a later visit via history,
527
+ * a bookmark, or a login redirect replayed an older layout and saved it over
528
+ * the current one (GLU-2396).
529
+ *
530
+ * `onTableStateChange` takes a *partial* state and merges it into whatever was
531
+ * reported earlier in the session, so a consumer can report a single slice
532
+ * (e.g. only `expanded`) without the omitted fields falling back to their
533
+ * mount-time values. A field reported empty is dropped from the stored state,
534
+ * and once every field is empty the entry is removed altogether.
535
+ *
536
+ * Each change is written to localStorage as soon as it is reported, so
537
+ * navigating away right after an edit cannot lose it. The hash is written
538
+ * after a {@link URL_WRITE_DEBOUNCE_MS} quiet period, replacing the current
539
+ * history entry: a drag or a run of toggles leaves one entry, not one per step.
540
+ *
541
+ * Every hash write is stamped with the identity that wrote it. The stamp is
542
+ * read only by the login redirect, which drops a hash left behind by another
543
+ * account that also uses this browser, so accounts sharing a PC do not hand
544
+ * their layouts to each other (GLU-2396).
545
+ *
546
+ * `clientSideUserId` only moves from unresolved to resolved while this hook
547
+ * is mounted; switching, impersonating, or releasing an identity reloads the
548
+ * page or navigates away from the table. Changes reported before the id
549
+ * resolves are held in React state and written to the user's scoped storage
550
+ * once it does.
596
551
  *
597
552
  * @param persistenceKey - Unique key used to store and retrieve the table state.
598
553
  * Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
@@ -602,145 +557,69 @@ const isOwnedByCurrentUser = (ownerId, clientSideUserId) => ownerId === undefine
602
557
  * @throws {Error} If persistenceKey is not camelCase or exceeds 15 characters.
603
558
  */
604
559
  const useTablePersistence = (persistenceKey) => {
605
- const parseResult = persistenceKeySchema.safeParse(persistenceKey);
606
- if (!parseResult.success) {
607
- throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
608
- }
560
+ // The key is a constant per table; validate it once rather than every render.
561
+ useMemo(() => {
562
+ const parseResult = persistenceKeySchema.safeParse(persistenceKey);
563
+ if (!parseResult.success) {
564
+ throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
565
+ }
566
+ }, [persistenceKey]);
609
567
  const { clientSideUserId } = useCurrentUser();
610
568
  const localStorageEnabled = clientSideUserId !== undefined;
611
- const { toUrlValue, fromUrlValue } = useCompactTableUrl();
612
- const { initialState, persistState, clearState, commitHydratedState } = usePersistedState({
569
+ const { initialState, persistToStorage, persistToUrl } = usePersistedState({
613
570
  key: `${persistenceKey}Tp`,
614
571
  validate: validateTableState,
615
- toUrlValue,
616
- fromUrlValue,
572
+ toUrlValue: toTableUrlValue,
573
+ fromUrlValue: fromTableUrlValue,
617
574
  // Replace, so a second edit updates this visit's history entry. A push
618
575
  // per column change left older layouts in history, and a later full load
619
576
  // of one of them saved that layout over the newer one (GLU-2396).
620
577
  replace: true,
621
578
  clientSideUserId,
622
579
  localStorageEnabled,
623
- // `toUrlValue` strips fields that should not be shareable from the URL.
624
- // The merge restores omitted fields from localStorage on read so they
625
- // survive reloads.
580
+ // `expanded` is kept out of the URL; the merge restores it from
581
+ // localStorage on read so it survives reloads.
626
582
  mergeWithStorageOnRead: true,
627
- // Move the persisted blob into the URL hash so it bypasses the AWS WAF
628
- // query-string cap. `usePersistedState` also migrates any legacy
629
- // `?<key>=...` search param into the hash on first load.
630
583
  urlLocation: "hash",
631
- // The hash outlives the session that wrote it and is carried through the
632
- // login redirect to the next account on the same browser. Stamp it with
633
- // the writing identity so the redirect can drop another account's layout
634
- // (GLU-2396). Reads are unaffected: the URL still wins for everyone.
635
584
  stampUrlWithIdentity: true,
636
585
  });
637
- // Identity that reported the state currently held in `pendingState`.
638
- const pendingOwnerIdRef = useRef(undefined);
639
- // Set after the account that opened this page has had its one commit.
640
- // A later account, and a switch back to the opener, must not write the
641
- // hash that is on screen at that moment into storage. The ref stays empty
642
- // until the commit succeeds, so a stale snapshot can still retry.
643
- const openingUserIdRef = useRef(undefined);
586
+ // Opening a shared URL saves its layout for the account that opened it.
587
+ // `persistToStorage` dedupes against what storage held, so this writes only
588
+ // when the hash changed something; a reload of the account's own link, or a
589
+ // layout that came from storage alone, writes nothing. `initialState` is
590
+ // re-read in the render where `clientSideUserId` resolves, so the write
591
+ // always sees the merge for the resolved account.
644
592
  useEffect(() => {
645
- if (clientSideUserId === undefined || openingUserIdRef.current !== undefined) {
646
- return;
647
- }
648
- if (!commitHydratedState()) {
649
- return;
650
- }
651
- openingUserIdRef.current = clientSideUserId;
652
- }, [clientSideUserId, commitHydratedState]);
653
- const [pendingState, setPendingState] = useState(null);
654
- const debouncedPending = useDebounce(pendingState, { delay: 300 });
655
- const hasReportedChange = debouncedPending !== null;
656
- const currentState = useMemo(() => {
657
- if (!debouncedPending && !initialState) {
658
- return undefined;
593
+ if (initialState !== undefined) {
594
+ persistToStorage(initialState);
659
595
  }
660
- const columnOrder = debouncedPending?.state.columnOrder ?? initialState?.columnOrder;
661
- const columnSizing = debouncedPending?.state.columnSizing ?? initialState?.columnSizing;
662
- const columnVisibility = debouncedPending?.state.columnVisibility ?? initialState?.columnVisibility;
663
- const sorting = debouncedPending?.state.sorting ?? initialState?.sorting;
664
- const columnPinning = debouncedPending?.state.columnPinning ?? initialState?.columnPinning;
665
- const expanded = debouncedPending?.state.expanded ?? initialState?.expanded;
666
- const state = {};
667
- if (columnOrder && columnOrder.length > 0) {
668
- state.columnOrder = columnOrder;
669
- }
670
- if (columnSizing && objectKeys(columnSizing).length > 0) {
671
- state.columnSizing = columnSizing;
672
- }
673
- if (columnVisibility && objectKeys(columnVisibility).length > 0) {
674
- state.columnVisibility = columnVisibility;
675
- }
676
- if (sorting && sorting.length > 0) {
677
- state.sorting = sorting;
678
- }
679
- if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
680
- state.columnPinning = columnPinning;
681
- }
682
- if (expanded === true || (typeof expanded === "object" && objectKeys(expanded).length > 0)) {
683
- state.expanded = expanded;
684
- }
685
- return objectKeys(state).length > 0 || hasReportedChange ? state : undefined;
686
- }, [debouncedPending, hasReportedChange, initialState]);
596
+ }, [initialState, persistToStorage]);
597
+ // Everything reported in this session, merged. `null` until the first report
598
+ // so a mount with no edits writes nothing.
599
+ const [reportedState, setReportedState] = useState(null);
687
600
  const onTableStateChange = useCallback((newTableState) => {
688
601
  if (!newTableState) {
689
602
  return;
690
603
  }
691
- const reporterId = clientSideUserId;
692
- const previousOwnerId = pendingOwnerIdRef.current;
693
- if (!isOwnedByCurrentUser(previousOwnerId, reporterId)) {
694
- if (reporterId === undefined) {
695
- return;
696
- }
697
- pendingOwnerIdRef.current = reporterId;
698
- // State left behind by another identity is dropped rather than merged, so
699
- // this user's change builds on their own persisted state.
700
- setPendingState({ ownerId: reporterId, state: { ...newTableState } });
701
- return;
702
- }
703
- if (reporterId !== undefined) {
704
- pendingOwnerIdRef.current = reporterId;
705
- }
706
- setPendingState(previousState => ({
707
- ownerId: reporterId,
708
- state: { ...previousState?.state, ...newTableState },
709
- }));
710
- }, [clientSideUserId]);
604
+ setReportedState(previous => ({ ...previous, ...newTableState }));
605
+ }, []);
606
+ // `undefined` before any report; afterwards the full layout to persist,
607
+ // `{}` meaning "remove the entry".
608
+ const currentState = useMemo(() => reportedState === null ? undefined : pruneEmptyFields({ ...initialState, ...reportedState }), [initialState, reportedState]);
711
609
  useEffect(() => {
712
- // User edits write the URL and storage. Opening a shared URL writes
713
- // storage on its own, via `commitHydratedState`, and must not be echoed
714
- // through here — `persistState` treats the hydrated value as already saved.
715
- if (!hasReportedChange || !currentState) {
716
- return;
717
- }
718
- // Writing here after the current user changed would copy the previous
719
- // user's reported state into this user's scoped storage — the debounced
720
- // state outlives the identity that produced it.
721
- const debouncedOwnerId = debouncedPending.ownerId;
722
- const pendingOwnerId = pendingOwnerIdRef.current;
723
- const hasCrossUserOwner = !isOwnedByCurrentUser(debouncedOwnerId, clientSideUserId) ||
724
- !isOwnedByCurrentUser(pendingOwnerId, clientSideUserId);
725
- const ownerChangedDuringDebounce = pendingOwnerId !== undefined && debouncedOwnerId !== pendingOwnerId;
726
- if (hasCrossUserOwner || ownerChangedDuringDebounce) {
610
+ if (currentState === undefined) {
727
611
  return;
728
612
  }
729
- // State reported before identity resolution is adopted by the first
730
- // identity that resolves. Hydration-only runs cannot reach this branch.
731
- if (debouncedOwnerId === undefined && clientSideUserId !== undefined) {
732
- pendingOwnerIdRef.current = clientSideUserId;
733
- }
734
- if (objectKeys(currentState).length === 0) {
735
- clearState();
613
+ persistToStorage(objectKeys(currentState).length > 0 ? currentState : undefined);
614
+ }, [currentState, persistToStorage]);
615
+ const debouncedState = useDebounce(currentState, { delay: URL_WRITE_DEBOUNCE_MS });
616
+ useEffect(() => {
617
+ if (debouncedState === undefined) {
736
618
  return;
737
619
  }
738
- persistState(currentState);
739
- }, [clearState, clientSideUserId, currentState, debouncedPending, hasReportedChange, persistState]);
740
- return useMemo(() => ({
741
- onTableStateChange,
742
- initialState,
743
- }), [initialState, onTableStateChange]);
620
+ persistToUrl(objectKeys(debouncedState).length > 0 ? debouncedState : undefined);
621
+ }, [debouncedState, persistToUrl]);
622
+ return useMemo(() => ({ onTableStateChange, initialState }), [initialState, onTableStateChange]);
744
623
  };
745
624
 
746
625
  const cvaColumnFilterGrabbable = cva({
@@ -1781,6 +1660,8 @@ const useColumnHelper = () => {
1781
1660
  }, []);
1782
1661
  };
1783
1662
 
1663
+ const areSameSequence = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
1664
+ const isSamePinning = (left, right) => areSameSequence(left.left ?? [], right.left ?? []) && areSameSequence(left.right ?? [], right.right ?? []);
1784
1665
  const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1785
1666
  const leftPinning = initialPinning?.left;
1786
1667
  const mergedLeft = leftPinning !== undefined && leftPinning.length > 0 ? [...leftPinning] : [...(columnConfigPinning.left ?? [])];
@@ -1809,6 +1690,12 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1809
1690
  *
1810
1691
  * `onTableStateChange` is called with the field that changed, not the whole state, so anything
1811
1692
  * storing it has to merge each report into what it already holds. `useTablePersistence` does.
1693
+ * Within that field only what differs from the column definitions is reported — visibility
1694
+ * entries equal to `meta.hiddenByDefault`, sizes equal to `size`, an order equal to the
1695
+ * definition order, a pinning equal to `meta.pinned` — and an emptied field is reported empty.
1696
+ * Defaults therefore belong on the column definitions; a value in `initialState` is read as
1697
+ * saved state, not as a default. Columns without an explicit `id` take no part in the
1698
+ * defaults (as before), so they are always reported once they are in the state.
1812
1699
  *
1813
1700
  * @template TData - The type representing the data model associated with the table.
1814
1701
  * @param {TableOptionsProps<TData>} props - The props object containing configuration options.
@@ -1843,42 +1730,52 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1843
1730
  */
1844
1731
  const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProps }) => {
1845
1732
  const stableColumns = useMemo(() => JSON.stringify(columns), [columns]);
1846
- const updatedInitialColumnVisibility = useMemo(() => {
1733
+ // The column definitions are the single source of defaults: `meta.hiddenByDefault`, definition
1734
+ // order, `meta.pinned`, and `size`. They seed the state when `initialState` says nothing, and
1735
+ // they are the baseline for what `onTableStateChange` reports (see `persistTableState`).
1736
+ // Consumers that want a different default should set it on the column, not in `initialState`:
1737
+ // a value in `initialState` is treated as saved state.
1738
+ const columnDefaults = useMemo(() => {
1847
1739
  const columnsList = JSON.parse(stableColumns);
1740
+ const visibilityById = {};
1741
+ const lockedVisibleIds = new Set();
1742
+ const order = [];
1743
+ columnsList.forEach(column => {
1744
+ if (column.id === undefined) {
1745
+ return;
1746
+ }
1747
+ order.push(column.id);
1748
+ const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
1749
+ if (columnCannotHide) {
1750
+ lockedVisibleIds.add(column.id);
1751
+ }
1752
+ visibilityById[column.id] = columnCannotHide ? true : column.meta?.hiddenByDefault !== true;
1753
+ });
1754
+ return { visibilityById, lockedVisibleIds, order };
1755
+ }, [stableColumns, reactTableProps.enableHiding]);
1756
+ const updatedInitialColumnVisibility = useMemo(() => {
1848
1757
  const initialStateColumnVisibility = initialState?.columnVisibility;
1849
1758
  const shouldHideColumnsMissingFromInitialVisibility = initialStateColumnVisibility?.[VISIBLE_ONLY_COLUMN_VISIBILITY_KEY] === true;
1850
- const resultFromInitialState = initialStateColumnVisibility
1851
- ? Object.fromEntries(columnsList
1852
- .map((column) => {
1853
- if (column.id && initialStateColumnVisibility[column.id] !== undefined) {
1854
- const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
1855
- return [column.id, columnCannotHide ? true : initialStateColumnVisibility[column.id]];
1856
- }
1857
- return null;
1858
- })
1859
- .filter(nonNullable))
1860
- : {};
1861
- columnsList.forEach((column) => {
1862
- if (column.id && resultFromInitialState[column.id] === undefined) {
1863
- const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
1864
- resultFromInitialState[column.id] = columnCannotHide
1865
- ? true
1866
- : shouldHideColumnsMissingFromInitialVisibility
1867
- ? false
1868
- : column.meta?.hiddenByDefault !== true;
1759
+ return Object.fromEntries(objectEntries(columnDefaults.visibilityById).map(([columnId, visibleByDefault]) => {
1760
+ if (columnDefaults.lockedVisibleIds.has(columnId)) {
1761
+ return [columnId, true];
1869
1762
  }
1870
- });
1871
- return resultFromInitialState;
1872
- }, [stableColumns, initialState?.columnVisibility, reactTableProps.enableHiding]);
1763
+ const savedVisibility = initialStateColumnVisibility?.[columnId];
1764
+ if (savedVisibility !== undefined) {
1765
+ return [columnId, savedVisibility];
1766
+ }
1767
+ return [columnId, shouldHideColumnsMissingFromInitialVisibility ? false : visibleByDefault];
1768
+ }));
1769
+ }, [columnDefaults, initialState?.columnVisibility]);
1873
1770
  const updatedInitialColumnOrder = useMemo(() => {
1874
1771
  const resultFromInitialState = initialState?.columnOrder ? [...initialState.columnOrder] : [];
1875
- JSON.parse(stableColumns).forEach((column) => {
1876
- if (column.id && !resultFromInitialState.includes(column.id)) {
1877
- resultFromInitialState.push(column.id);
1772
+ columnDefaults.order.forEach(columnId => {
1773
+ if (!resultFromInitialState.includes(columnId)) {
1774
+ resultFromInitialState.push(columnId);
1878
1775
  }
1879
1776
  });
1880
1777
  return resultFromInitialState;
1881
- }, [stableColumns, initialState?.columnOrder]);
1778
+ }, [columnDefaults, initialState?.columnOrder]);
1882
1779
  const stableUpdatedInitialColumnPinning = useMemo(() => {
1883
1780
  const pinningState = { left: [], right: [] };
1884
1781
  JSON.parse(stableColumns).forEach((column) => {
@@ -1936,23 +1833,46 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1936
1833
  // A ref exposes the latest derived state to the change callbacks without making them depend on
1937
1834
  // it (callbacks run on user events, outside render). Every managed key on `state` is always
1938
1835
  // defined — it originates from `baseState` — so the callbacks read it directly.
1836
+ //
1837
+ // `applyUpdate` resolves a TanStack updater against that ref and advances the ref at once, so
1838
+ // several updaters fired from one event (the column menu's Reset toggles every column that
1839
+ // differs from its default in a single click) build on each other instead of each starting
1840
+ // from the state as of the last render.
1939
1841
  const stateRef = useRef(state);
1940
1842
  stateRef.current = state;
1843
+ const applyUpdate = (key, value) => {
1844
+ const current = stateRef.current[key];
1845
+ const next = typeof value === "function" ? value(current) : value;
1846
+ stateRef.current = { ...stateRef.current, [key]: next };
1847
+ setUserEdits(prev => ({ ...prev, [key]: next }));
1848
+ return next;
1849
+ };
1941
1850
  // Reports only the field that changed — `useTablePersistence` merges partial reports — so a
1942
- // change can never store another field's unsettled or transient value. Column sizes matching
1943
- // their default are dropped so only meaningful overrides are stored.
1851
+ // change can never store another field's unsettled or transient value. Within a field, only
1852
+ // what differs from the column defaults is reported: visibility entries equal to
1853
+ // `hiddenByDefault`, sizes equal to the default size, an order equal to the definition order and
1854
+ // a pinning equal to `meta.pinned` all read back the same when absent, so storing them would only
1855
+ // freeze today's defaults under the user's layout and bloat what is shared. A field with nothing
1856
+ // left is reported empty so the store can drop it.
1944
1857
  const persistTableState = useCallback((nextState) => {
1945
1858
  if (!onTableStateChange) {
1946
1859
  return;
1947
1860
  }
1948
- const nextColumnSizing = nextState.columnSizing;
1949
- if (!nextColumnSizing) {
1950
- onTableStateChange(nextState);
1951
- return;
1861
+ const reported = { ...nextState };
1862
+ if (nextState.columnVisibility) {
1863
+ reported.columnVisibility = Object.fromEntries(objectEntries(nextState.columnVisibility).filter(([columnId, visible]) => columnDefaults.visibilityById[columnId] !== visible));
1864
+ }
1865
+ if (nextState.columnSizing) {
1866
+ reported.columnSizing = Object.fromEntries(objectEntries(nextState.columnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
1867
+ }
1868
+ if (nextState.columnOrder && areSameSequence(nextState.columnOrder, columnDefaults.order)) {
1869
+ reported.columnOrder = [];
1870
+ }
1871
+ if (nextState.columnPinning && isSamePinning(nextState.columnPinning, stableUpdatedInitialColumnPinning)) {
1872
+ reported.columnPinning = { left: [], right: [] };
1952
1873
  }
1953
- const columnSizingToPersist = Object.fromEntries(objectEntries(nextColumnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
1954
- onTableStateChange({ ...nextState, columnSizing: columnSizingToPersist });
1955
- }, [onTableStateChange, defaultColumnSizingById]);
1874
+ onTableStateChange(reported);
1875
+ }, [onTableStateChange, columnDefaults, defaultColumnSizingById, stableUpdatedInitialColumnPinning]);
1956
1876
  const table = useReactTable({
1957
1877
  manualSorting: true,
1958
1878
  enableSortingRemoval: true,
@@ -1961,44 +1881,27 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1961
1881
  getCoreRowModel: getCoreRowModel(),
1962
1882
  ...reactTableProps,
1963
1883
  onColumnVisibilityChange: value => {
1964
- const current = stateRef.current.columnVisibility;
1965
- const next = typeof value === "function" ? value(current) : value;
1966
- setUserEdits(prev => ({ ...prev, columnVisibility: next }));
1967
- persistTableState({ columnVisibility: next });
1884
+ persistTableState({ columnVisibility: applyUpdate("columnVisibility", value) });
1968
1885
  reactTableProps.onColumnVisibilityChange?.(value);
1969
1886
  },
1970
1887
  onColumnSizingChange: value => {
1971
- const current = stateRef.current.columnSizing;
1972
- const next = typeof value === "function" ? value(current) : value;
1973
- setUserEdits(prev => ({ ...prev, columnSizing: next }));
1974
- persistTableState({ columnSizing: next });
1888
+ persistTableState({ columnSizing: applyUpdate("columnSizing", value) });
1975
1889
  reactTableProps.onColumnSizingChange?.(value);
1976
1890
  },
1977
1891
  onColumnOrderChange: value => {
1978
- const current = stateRef.current.columnOrder;
1979
- const next = typeof value === "function" ? value(current) : value;
1980
- setUserEdits(prev => ({ ...prev, columnOrder: next }));
1981
- persistTableState({ columnOrder: next });
1892
+ persistTableState({ columnOrder: applyUpdate("columnOrder", value) });
1982
1893
  reactTableProps.onColumnOrderChange?.(value);
1983
1894
  },
1984
1895
  onSortingChange: value => {
1985
- const current = stateRef.current.sorting;
1986
- const next = typeof value === "function" ? value(current) : value;
1987
- setUserEdits(prev => ({ ...prev, sorting: next }));
1988
- persistTableState({ sorting: next });
1896
+ persistTableState({ sorting: applyUpdate("sorting", value) });
1989
1897
  reactTableProps.onSortingChange?.(value);
1990
1898
  },
1991
1899
  onColumnPinningChange: value => {
1992
- const current = stateRef.current.columnPinning;
1993
- const next = typeof value === "function" ? value(current) : value;
1994
- setUserEdits(prev => ({ ...prev, columnPinning: next }));
1995
- persistTableState({ columnPinning: next });
1900
+ persistTableState({ columnPinning: applyUpdate("columnPinning", value) });
1996
1901
  reactTableProps.onColumnPinningChange?.(value);
1997
1902
  },
1998
1903
  onExpandedChange: value => {
1999
- const current = stateRef.current.expanded;
2000
- const next = typeof value === "function" ? value(current) : value;
2001
- setUserEdits(prev => ({ ...prev, expanded: next }));
1904
+ const next = applyUpdate("expanded", value);
2002
1905
  if (reactTableProps.state?.expanded === undefined) {
2003
1906
  persistTableState({ expanded: next });
2004
1907
  }