@trackunit/react-table 2.11.11-alpha-7afddbbe4cc.0 → 2.11.11

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