@trackunit/react-table 2.11.10-alpha-e1e6a0f33ce.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.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, useRef, useEffect, useState, useCallback, createElement } from 'react';
6
+ import { useMemo, Children, cloneElement, useCallback, useRef, useEffect, useState, 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,88 +364,142 @@ const ActionSheet = ({ actions, dropdownActions, moreActions = [], selections, r
364
364
  };
365
365
 
366
366
  /**
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.
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.
375
372
  */
376
- const toTableUrlValue = ({ expanded: _expanded, ...shareable }) => shareable;
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
+ });
377
387
  /**
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.
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)
381
394
  */
382
- const LEGACY_URL_KEY_MAP = {
383
- co: "columnOrder",
384
- cv: "columnVisibility",
385
- s: "sorting",
386
- cp: "columnPinning",
387
- cs: "columnSizing",
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;
388
433
  };
389
- const recordSchema = z.record(z.string(), z.unknown());
390
- const legacyVisibleListSchema = z.array(z.string());
391
- const legacyVisibilitySchema = z
434
+ const compactRecordSchema = z.record(z.string(), z.unknown());
435
+ const visibilityArraySchema = z.array(z.string());
436
+ const compactVisibilitySchema = z
392
437
  .object({
393
438
  v: z.array(z.string()).optional(),
394
439
  h: z.array(z.string()).optional(),
395
440
  })
396
441
  .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
- });
403
442
  /**
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.
443
+ * Expands a compact URL-decoded object back into full TableColumnOptions shape.
416
444
  */
417
- const fromTableUrlValue = (decoded) => {
418
- const parsed = recordSchema.safeParse(decoded);
445
+ const expandFromUrl = (decoded) => {
446
+ const parsed = compactRecordSchema.safeParse(decoded);
419
447
  if (!parsed.success) {
420
448
  return decoded;
421
449
  }
422
450
  const expanded = {};
423
451
  for (const [key, value] of Object.entries(parsed.data)) {
424
- expanded[LEGACY_URL_KEY_MAP[key] ?? key] = value;
452
+ const fullKey = REVERSE_URL_KEY_MAP[key] ?? key;
453
+ expanded[fullKey] = value;
425
454
  }
426
- const visibleList = legacyVisibleListSchema.safeParse(expanded.columnVisibility);
427
- if (visibleList.success) {
455
+ const visibilityResult = visibilityArraySchema.safeParse(expanded.columnVisibility);
456
+ if (visibilityResult.success) {
428
457
  expanded.columnVisibility = Object.fromEntries([
429
458
  [VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, true],
430
- ...visibleList.data.map(col => [col, true]),
459
+ ...visibilityResult.data.map(col => [col, true]),
431
460
  ]);
432
461
  }
433
- const legacyVisibility = legacyVisibilitySchema.safeParse(expanded.columnVisibility);
434
- if (legacyVisibility.success) {
462
+ const compactVisibilityResult = compactVisibilitySchema.safeParse(expanded.columnVisibility);
463
+ if (compactVisibilityResult.success) {
435
464
  expanded.columnVisibility = Object.fromEntries([
436
- ...(legacyVisibility.data.v ?? []).map(col => [col, true]),
437
- ...(legacyVisibility.data.h ?? []).map(col => [col, false]),
465
+ ...(compactVisibilityResult.data.v ?? []).map(col => [col, true]),
466
+ ...(compactVisibilityResult.data.h ?? []).map(col => [col, false]),
438
467
  ]);
439
468
  }
440
- const legacyPinning = legacyPinningSchema.safeParse(expanded.columnPinning);
441
- if (legacyPinning.success) {
469
+ const pinningResult = compactPinningSchema.safeParse(expanded.columnPinning);
470
+ if (pinningResult.success) {
442
471
  expanded.columnPinning = {
443
- left: legacyPinning.data.l ?? legacyPinning.data.left ?? [],
444
- right: legacyPinning.data.r ?? legacyPinning.data.right ?? [],
472
+ left: pinningResult.data.l ?? pinningResult.data.left ?? [],
473
+ right: pinningResult.data.r ?? pinningResult.data.right ?? [],
445
474
  };
446
475
  }
447
476
  return expanded;
448
477
  };
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
+ };
449
503
 
450
504
  const PERSISTENCE_KEY_MAX_LENGTH = 15;
451
505
  const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
@@ -476,96 +530,69 @@ const validateTableState = (raw) => {
476
530
  const result = tableStateSchema.safeParse(raw);
477
531
  return result.success ? result.data : undefined;
478
532
  };
479
- /**
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.
482
- */
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;
507
533
  /**
508
534
  * Changes reported before `clientSideUserId` resolves carry no owner — the user
509
535
  * was already editing while their id was in flight — so the identity that
510
536
  * resolves adopts them. Once a defined identity owns the reported state it must
511
- * not be adopted by a later identity, including through a gap where the current
512
- * id is unset: treating that gap as unowned would copy one person's layout into
513
- * the next user's scoped storage.
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.
514
540
  */
515
- const isOwnedBy = (ownerId, clientSideUserId) => ownerId === undefined || ownerId === clientSideUserId;
516
- /** Whether anything of `state` would reach the URL; a layout of only `expanded` has nothing to share. */
517
- const hasShareableFields = (state) => objectKeys(toTableUrlValue(state)).length > 0;
541
+ const isOwnedByCurrentUser = (ownerId, clientSideUserId) => ownerId === undefined || ownerId === clientSideUserId;
518
542
  /**
519
543
  * Persists and restores table column state (order, sizing, visibility, sorting, pinning, expanded) using
520
- * localStorage and the URL **hash fragment**.
544
+ * the URL **hash fragment** and localStorage.
521
545
  *
522
546
  * Storage scope:
523
- * - localStorage: everything, scoped to the current user.
524
- * - URL hash (shareable): everything except `expanded`.
547
+ * - URL hash (shareable): `columnOrder`, `columnVisibility`, `sorting`, `columnPinning`, `columnSizing`.
548
+ * - localStorage: everything above plus `expanded`.
525
549
  *
526
550
  * The hash slot is used instead of search params because some deployments
527
551
  * route through AWS WAF, whose `SizeRestrictions_QUERYSTRING` managed rule
528
552
  * caps the query string at ~5000 bytes — easily breached by wide tables.
529
553
  * The fragment is client-only and bypasses that cap entirely (subject to a
530
- * 64 KiB soft limit enforced by the URL sync).
531
- *
532
- * On load the hash wins, so a link or bookmark renders the same layout for
533
- * whoever opens it, and localStorage fills in what the hash omits (`expanded`).
534
- * Opening a URL that carries a layout saves it for the account that loaded the
535
- * page, so a pasted link sticks without a further edit. A layout that exists
536
- * only in localStorage is never copied into the hash on load: doing that put
537
- * the current layout into every history entry, so a later visit via history,
538
- * a bookmark, or a login redirect replayed an older layout and saved it over
539
- * the current one (GLU-2396).
540
- *
541
- * `onTableStateChange` takes a *partial* state and merges it into whatever was
542
- * reported earlier in the session, so a consumer can report a single slice
543
- * (e.g. only `expanded`) without the omitted fields falling back to their
544
- * mount-time values. A field reported empty is dropped from the stored state,
545
- * and once every field is empty the entry is removed altogether.
546
- *
547
- * Each change is written to localStorage as soon as it is reported, so
548
- * navigating away right after an edit cannot lose it. The hash is written
549
- * after a {@link URL_WRITE_DEBOUNCE_MS} quiet period, replacing the current
550
- * history entry: a drag or a run of toggles leaves one entry, not one per step.
551
- *
552
- * Every hash write is stamped with the identity that wrote it. The stamp is
553
- * read only by the login redirect, which drops a hash left behind by another
554
- * account that also uses this browser, so accounts sharing a PC do not hand
555
- * their layouts to each other (GLU-2396).
556
- *
557
- * Changes reported before `clientSideUserId` resolves are held in React state
558
- * and written to the user's scoped storage and stamped into the hash once it
559
- * does. The id can also change while the table is mounted: releasing an
560
- * impersonated identity renews the token, and the new claims are published
561
- * before the page navigates away. Reported state is therefore tagged with the
562
- * identity that produced it and persisted only while that identity is current,
563
- * and the layout read on load is committed to storage only for the account
564
- * that opened the page, so a layout never crosses from one account into
565
- * another's scoped storage or into their hash on its own. An identity that
566
- * takes over and then edits saves what it sees on screen — the opener's hash
567
- * over its own stored layout — under its own key, the same as editing an
568
- * opened link.
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).
569
596
  *
570
597
  * @param persistenceKey - Unique key used to store and retrieve the table state.
571
598
  * Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
@@ -575,104 +602,145 @@ const hasShareableFields = (state) => objectKeys(toTableUrlValue(state)).length
575
602
  * @throws {Error} If persistenceKey is not camelCase or exceeds 15 characters.
576
603
  */
577
604
  const useTablePersistence = (persistenceKey) => {
578
- // The key is a constant per table; validate it once rather than every render.
579
- useMemo(() => {
580
- const parseResult = persistenceKeySchema.safeParse(persistenceKey);
581
- if (!parseResult.success) {
582
- throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
583
- }
584
- }, [persistenceKey]);
605
+ const parseResult = persistenceKeySchema.safeParse(persistenceKey);
606
+ if (!parseResult.success) {
607
+ throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
608
+ }
585
609
  const { clientSideUserId } = useCurrentUser();
586
610
  const localStorageEnabled = clientSideUserId !== undefined;
587
- const { initialState, persistToStorage, persistToUrl } = usePersistedState({
611
+ const { toUrlValue, fromUrlValue } = useCompactTableUrl();
612
+ const { initialState, persistState, clearState, commitHydratedState } = usePersistedState({
588
613
  key: `${persistenceKey}Tp`,
589
614
  validate: validateTableState,
590
- toUrlValue: toTableUrlValue,
591
- fromUrlValue: fromTableUrlValue,
615
+ toUrlValue,
616
+ fromUrlValue,
592
617
  // Replace, so a second edit updates this visit's history entry. A push
593
618
  // per column change left older layouts in history, and a later full load
594
619
  // of one of them saved that layout over the newer one (GLU-2396).
595
620
  replace: true,
596
621
  clientSideUserId,
597
622
  localStorageEnabled,
598
- // `expanded` is kept out of the URL; the merge restores it from
599
- // localStorage on read so it survives reloads.
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.
600
626
  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.
601
630
  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.
602
635
  stampUrlWithIdentity: true,
603
636
  });
604
- // Opening a shared URL saves its layout for the account that opened it: the
605
- // first identity to resolve while the table is mounted. `persistToStorage`
606
- // dedupes against what storage held, so this writes only when the hash
607
- // changed something; a reload of the account's own link, or a layout that
608
- // came from storage alone, writes nothing. `initialState` is re-read in the
609
- // render where `clientSideUserId` resolves, so the write sees the merge for
610
- // the resolved account. An identity that takes over later does not commit:
611
- // the hash on screen is the opener's layout, not theirs.
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.
612
643
  const openingUserIdRef = useRef(undefined);
613
644
  useEffect(() => {
614
- if (clientSideUserId === undefined) {
645
+ if (clientSideUserId === undefined || openingUserIdRef.current !== undefined) {
646
+ return;
647
+ }
648
+ if (!commitHydratedState()) {
615
649
  return;
616
650
  }
617
- openingUserIdRef.current ?? (openingUserIdRef.current = clientSideUserId);
618
- if (initialState !== undefined && openingUserIdRef.current === clientSideUserId) {
619
- persistToStorage(initialState);
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;
659
+ }
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;
620
672
  }
621
- }, [clientSideUserId, initialState, persistToStorage]);
622
- // Everything reported in this session, merged, with the identity it belongs
623
- // to. `null` until the first report so a mount with no edits writes nothing.
624
- // Reports merge into whatever is held, whoever holds it; which identity that
625
- // is gets settled in render below, where `clientSideUserId` is current. The
626
- // callback stays stable so consumers can hold on to it.
627
- const [reported, setReported] = useState(null);
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]);
628
687
  const onTableStateChange = useCallback((newTableState) => {
629
688
  if (!newTableState) {
630
689
  return;
631
690
  }
632
- setReported(previous => ({ ownerId: previous?.ownerId, state: { ...previous?.state, ...newTableState } }));
633
- }, []);
634
- // Settle ownership in the render where an identity is current: reports made
635
- // while no identity was resolved belong to the one that resolves; reports
636
- // owned by a different identity are discarded so the current one starts
637
- // over from what it sees on screen. While the id is unset nothing is
638
- // settled, so edits in the gap stay with their owner and are kept if that
639
- // owner comes back.
640
- if (reported !== null && clientSideUserId !== undefined && reported.ownerId !== clientSideUserId) {
641
- setReported(previous => {
642
- if (previous === null || previous.ownerId === clientSideUserId) {
643
- return previous;
691
+ const reporterId = clientSideUserId;
692
+ const previousOwnerId = pendingOwnerIdRef.current;
693
+ if (!isOwnedByCurrentUser(previousOwnerId, reporterId)) {
694
+ if (reporterId === undefined) {
695
+ return;
644
696
  }
645
- return previous.ownerId === undefined ? { ...previous, ownerId: clientSideUserId } : null;
646
- });
647
- }
648
- // `undefined` before any report or while the report belongs to another
649
- // identity; afterwards the full layout to persist, `{}` meaning "remove the
650
- // entry", tagged with the identity it is persisted for.
651
- const currentState = useMemo(() => {
652
- if (reported === null || !isOwnedBy(reported.ownerId, clientSideUserId)) {
653
- return undefined;
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;
654
702
  }
655
- return { ownerId: clientSideUserId, state: pruneEmptyFields({ ...initialState, ...reported.state }) };
656
- }, [clientSideUserId, initialState, reported]);
703
+ if (reporterId !== undefined) {
704
+ pendingOwnerIdRef.current = reporterId;
705
+ }
706
+ setPendingState(previousState => ({
707
+ ownerId: reporterId,
708
+ state: { ...previousState?.state, ...newTableState },
709
+ }));
710
+ }, [clientSideUserId]);
657
711
  useEffect(() => {
658
- if (currentState === undefined) {
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) {
659
716
  return;
660
717
  }
661
- persistToStorage(objectKeys(currentState.state).length > 0 ? currentState.state : undefined);
662
- }, [currentState, persistToStorage]);
663
- // The debounced value carries the identity it was computed for, so a change
664
- // of identity during the quiet period discards the pending write instead of
665
- // stamping the previous identity's layout into the new one's hash. A layout
666
- // whose shareable part is empty removes the hash entry even when a
667
- // storage-only field such as `expanded` remains.
668
- const debouncedState = useDebounce(currentState, { delay: URL_WRITE_DEBOUNCE_MS });
669
- useEffect(() => {
670
- if (debouncedState === undefined || !isOwnedBy(debouncedState.ownerId, clientSideUserId)) {
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) {
727
+ return;
728
+ }
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();
671
736
  return;
672
737
  }
673
- persistToUrl(hasShareableFields(debouncedState.state) ? debouncedState.state : undefined);
674
- }, [clientSideUserId, debouncedState, persistToUrl]);
675
- return useMemo(() => ({ onTableStateChange, initialState }), [initialState, onTableStateChange]);
738
+ persistState(currentState);
739
+ }, [clearState, clientSideUserId, currentState, debouncedPending, hasReportedChange, persistState]);
740
+ return useMemo(() => ({
741
+ onTableStateChange,
742
+ initialState,
743
+ }), [initialState, onTableStateChange]);
676
744
  };
677
745
 
678
746
  const cvaColumnFilterGrabbable = cva({
@@ -1713,8 +1781,6 @@ const useColumnHelper = () => {
1713
1781
  }, []);
1714
1782
  };
1715
1783
 
1716
- const areSameSequence = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
1717
- const isSamePinning = (left, right) => areSameSequence(left.left ?? [], right.left ?? []) && areSameSequence(left.right ?? [], right.right ?? []);
1718
1784
  const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1719
1785
  const leftPinning = initialPinning?.left;
1720
1786
  const mergedLeft = leftPinning !== undefined && leftPinning.length > 0 ? [...leftPinning] : [...(columnConfigPinning.left ?? [])];
@@ -1743,12 +1809,6 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1743
1809
  *
1744
1810
  * `onTableStateChange` is called with the field that changed, not the whole state, so anything
1745
1811
  * storing it has to merge each report into what it already holds. `useTablePersistence` does.
1746
- * Within that field only what differs from the column definitions is reported — visibility
1747
- * entries equal to `meta.hiddenByDefault`, sizes equal to `size`, an order equal to the
1748
- * definition order, a pinning equal to `meta.pinned` — and an emptied field is reported empty.
1749
- * Defaults therefore belong on the column definitions; a value in `initialState` is read as
1750
- * saved state, not as a default. Columns without an explicit `id` take no part in the
1751
- * defaults (as before), so they are always reported once they are in the state.
1752
1812
  *
1753
1813
  * @template TData - The type representing the data model associated with the table.
1754
1814
  * @param {TableOptionsProps<TData>} props - The props object containing configuration options.
@@ -1783,52 +1843,42 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1783
1843
  */
1784
1844
  const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProps }) => {
1785
1845
  const stableColumns = useMemo(() => JSON.stringify(columns), [columns]);
1786
- // The column definitions are the single source of defaults: `meta.hiddenByDefault`, definition
1787
- // order, `meta.pinned`, and `size`. They seed the state when `initialState` says nothing, and
1788
- // they are the baseline for what `onTableStateChange` reports (see `persistTableState`).
1789
- // Consumers that want a different default should set it on the column, not in `initialState`:
1790
- // a value in `initialState` is treated as saved state.
1791
- const columnDefaults = useMemo(() => {
1792
- const columnsList = JSON.parse(stableColumns);
1793
- const visibilityById = {};
1794
- const lockedVisibleIds = new Set();
1795
- const order = [];
1796
- columnsList.forEach(column => {
1797
- if (column.id === undefined) {
1798
- return;
1799
- }
1800
- order.push(column.id);
1801
- const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
1802
- if (columnCannotHide) {
1803
- lockedVisibleIds.add(column.id);
1804
- }
1805
- visibilityById[column.id] = columnCannotHide ? true : column.meta?.hiddenByDefault !== true;
1806
- });
1807
- return { visibilityById, lockedVisibleIds, order };
1808
- }, [stableColumns, reactTableProps.enableHiding]);
1809
1846
  const updatedInitialColumnVisibility = useMemo(() => {
1847
+ const columnsList = JSON.parse(stableColumns);
1810
1848
  const initialStateColumnVisibility = initialState?.columnVisibility;
1811
1849
  const shouldHideColumnsMissingFromInitialVisibility = initialStateColumnVisibility?.[VISIBLE_ONLY_COLUMN_VISIBILITY_KEY] === true;
1812
- return Object.fromEntries(objectEntries(columnDefaults.visibilityById).map(([columnId, visibleByDefault]) => {
1813
- if (columnDefaults.lockedVisibleIds.has(columnId)) {
1814
- return [columnId, true];
1815
- }
1816
- const savedVisibility = initialStateColumnVisibility?.[columnId];
1817
- if (savedVisibility !== undefined) {
1818
- return [columnId, savedVisibility];
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;
1819
1869
  }
1820
- return [columnId, shouldHideColumnsMissingFromInitialVisibility ? false : visibleByDefault];
1821
- }));
1822
- }, [columnDefaults, initialState?.columnVisibility]);
1870
+ });
1871
+ return resultFromInitialState;
1872
+ }, [stableColumns, initialState?.columnVisibility, reactTableProps.enableHiding]);
1823
1873
  const updatedInitialColumnOrder = useMemo(() => {
1824
1874
  const resultFromInitialState = initialState?.columnOrder ? [...initialState.columnOrder] : [];
1825
- columnDefaults.order.forEach(columnId => {
1826
- if (!resultFromInitialState.includes(columnId)) {
1827
- resultFromInitialState.push(columnId);
1875
+ JSON.parse(stableColumns).forEach((column) => {
1876
+ if (column.id && !resultFromInitialState.includes(column.id)) {
1877
+ resultFromInitialState.push(column.id);
1828
1878
  }
1829
1879
  });
1830
1880
  return resultFromInitialState;
1831
- }, [columnDefaults, initialState?.columnOrder]);
1881
+ }, [stableColumns, initialState?.columnOrder]);
1832
1882
  const stableUpdatedInitialColumnPinning = useMemo(() => {
1833
1883
  const pinningState = { left: [], right: [] };
1834
1884
  JSON.parse(stableColumns).forEach((column) => {
@@ -1886,51 +1936,23 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1886
1936
  // A ref exposes the latest derived state to the change callbacks without making them depend on
1887
1937
  // it (callbacks run on user events, outside render). Every managed key on `state` is always
1888
1938
  // defined — it originates from `baseState` — so the callbacks read it directly.
1889
- //
1890
- // `applyUpdate` resolves a TanStack updater against that ref and advances the ref at once, so
1891
- // several updaters fired from one event (the column menu's Reset toggles every column that
1892
- // differs from its default in a single click) build on each other instead of each starting
1893
- // from the state as of the last render.
1894
1939
  const stateRef = useRef(state);
1895
1940
  stateRef.current = state;
1896
- const applyUpdate = (key, value) => {
1897
- const current = stateRef.current[key];
1898
- const next = typeof value === "function" ? value(current) : value;
1899
- stateRef.current = { ...stateRef.current, [key]: next };
1900
- setUserEdits(prev => ({ ...prev, [key]: next }));
1901
- return next;
1902
- };
1903
1941
  // Reports only the field that changed — `useTablePersistence` merges partial reports — so a
1904
- // change can never store another field's unsettled or transient value. Within a field, only
1905
- // what differs from the column defaults is reported: visibility entries equal to
1906
- // `hiddenByDefault`, sizes equal to the default size, an order equal to the definition order and
1907
- // a pinning equal to `meta.pinned` all read back the same when absent, so storing them would only
1908
- // freeze today's defaults under the user's layout and bloat what is shared. A field with nothing
1909
- // left is reported empty so the store can drop it.
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.
1910
1944
  const persistTableState = useCallback((nextState) => {
1911
1945
  if (!onTableStateChange) {
1912
1946
  return;
1913
1947
  }
1914
- const reported = { ...nextState };
1915
- if (nextState.columnVisibility) {
1916
- reported.columnVisibility = Object.fromEntries(objectEntries(nextState.columnVisibility).filter(([columnId, visible]) => columnDefaults.visibilityById[columnId] !== visible));
1917
- }
1918
- if (nextState.columnSizing) {
1919
- reported.columnSizing = Object.fromEntries(objectEntries(nextState.columnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
1920
- }
1921
- if (nextState.columnOrder && areSameSequence(nextState.columnOrder, columnDefaults.order)) {
1922
- reported.columnOrder = [];
1923
- }
1924
- if (nextState.columnPinning && isSamePinning(nextState.columnPinning, stableUpdatedInitialColumnPinning)) {
1925
- reported.columnPinning = { left: [], right: [] };
1948
+ const nextColumnSizing = nextState.columnSizing;
1949
+ if (!nextColumnSizing) {
1950
+ onTableStateChange(nextState);
1951
+ return;
1926
1952
  }
1927
- onTableStateChange(reported);
1928
- }, [onTableStateChange, columnDefaults, defaultColumnSizingById, stableUpdatedInitialColumnPinning]);
1929
- // Whether a column-resize drag is in progress, as of the last render (TanStack
1930
- // flags it at pointer-down, before the first move arrives), and whether a
1931
- // sizing change was held back during one.
1932
- const isResizingColumnRef = useRef(false);
1933
- const sizingReportPendingRef = useRef(false);
1953
+ const columnSizingToPersist = Object.fromEntries(objectEntries(nextColumnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
1954
+ onTableStateChange({ ...nextState, columnSizing: columnSizingToPersist });
1955
+ }, [onTableStateChange, defaultColumnSizingById]);
1934
1956
  const table = useReactTable({
1935
1957
  manualSorting: true,
1936
1958
  enableSortingRemoval: true,
@@ -1939,37 +1961,44 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1939
1961
  getCoreRowModel: getCoreRowModel(),
1940
1962
  ...reactTableProps,
1941
1963
  onColumnVisibilityChange: value => {
1942
- persistTableState({ columnVisibility: applyUpdate("columnVisibility", 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 });
1943
1968
  reactTableProps.onColumnVisibilityChange?.(value);
1944
1969
  },
1945
1970
  onColumnSizingChange: value => {
1946
- const next = applyUpdate("columnSizing", value);
1947
- // A drag updates the sizing on every pointer move (`columnResizeMode:
1948
- // "onChange"`); the store would write every move. The report waits for
1949
- // the drag to end (see the effect below). Anything else — `resetSize`,
1950
- // `setColumnSizing` — is reported at once.
1951
- if (isResizingColumnRef.current) {
1952
- sizingReportPendingRef.current = true;
1953
- }
1954
- else {
1955
- persistTableState({ columnSizing: next });
1956
- }
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 });
1957
1975
  reactTableProps.onColumnSizingChange?.(value);
1958
1976
  },
1959
1977
  onColumnOrderChange: value => {
1960
- persistTableState({ columnOrder: applyUpdate("columnOrder", 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 });
1961
1982
  reactTableProps.onColumnOrderChange?.(value);
1962
1983
  },
1963
1984
  onSortingChange: value => {
1964
- persistTableState({ sorting: applyUpdate("sorting", 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 });
1965
1989
  reactTableProps.onSortingChange?.(value);
1966
1990
  },
1967
1991
  onColumnPinningChange: value => {
1968
- persistTableState({ columnPinning: applyUpdate("columnPinning", 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 });
1969
1996
  reactTableProps.onColumnPinningChange?.(value);
1970
1997
  },
1971
1998
  onExpandedChange: value => {
1972
- const next = applyUpdate("expanded", value);
1999
+ const current = stateRef.current.expanded;
2000
+ const next = typeof value === "function" ? value(current) : value;
2001
+ setUserEdits(prev => ({ ...prev, expanded: next }));
1973
2002
  if (reactTableProps.state?.expanded === undefined) {
1974
2003
  persistTableState({ expanded: next });
1975
2004
  }
@@ -1978,16 +2007,6 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1978
2007
  columns,
1979
2008
  state,
1980
2009
  });
1981
- const isResizingColumn = table.getState().columnSizingInfo.isResizingColumn !== false;
1982
- isResizingColumnRef.current = isResizingColumn;
1983
- // The drag has ended: report the sizing it left behind, once. TanStack
1984
- // applies the final size before it clears the flag, so the ref holds it.
1985
- useEffect(() => {
1986
- if (!isResizingColumn && sizingReportPendingRef.current) {
1987
- sizingReportPendingRef.current = false;
1988
- persistTableState({ columnSizing: stateRef.current.columnSizing });
1989
- }
1990
- }, [isResizingColumn, persistTableState]);
1991
2010
  return useMemo(() => {
1992
2011
  return { table };
1993
2012
  }, [table]);