@trackunit/react-components 1.25.4 → 1.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "1.25.4",
3
+ "version": "1.26.0",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "migrations": "./migrations.json",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure utilities for reading and writing single named entries inside a URL
3
+ * fragment (the part after `#`).
4
+ *
5
+ * # Format contract
6
+ *
7
+ * The fragment body (after the leading `#`) is treated as a sequence of
8
+ * `&`-separated tokens. A token may be:
9
+ * - **A named entry** — `<key>=<value>` (the first `=` separates key from
10
+ * value; later `=` chars are part of the value).
11
+ * - **Foreign content** — anything else (bare scroll anchors like
12
+ * `section`, hash routes, third-party data, malformed tokens). Foreign
13
+ * tokens are preserved byte-for-byte, in their original positions.
14
+ *
15
+ * # Single-key ownership
16
+ *
17
+ * Callers operate on **one** named key per invocation. Every token whose
18
+ * key does not match the supplied key is foreign — the utility never
19
+ * categorises or rewrites it. This mirrors how `useSearchParamSync`
20
+ * treats a single named search param.
21
+ *
22
+ * # Value-encoding invariant
23
+ *
24
+ * Values passed to {@link writeHashKey} must not contain `&`, `=`, or `#`.
25
+ * The standard producer for this codebase is `useCustomEncoding`, whose
26
+ * base64url alphabet (`[A-Za-z0-9_-]`) satisfies the invariant by
27
+ * construction. Keys are restricted by the existing persistence-key
28
+ * schema (camelCase, ≤15 chars), so they are likewise safe.
29
+ *
30
+ * # Anchor-scroll caveat
31
+ *
32
+ * Browsers locate scroll targets by matching the entire fragment string
33
+ * against element IDs. Appending a named entry to a fragment containing
34
+ * a bare anchor (e.g. `#section` becoming `#section&userTableTp=...`)
35
+ * therefore breaks anchor scrolling on that page. This is unavoidable
36
+ * for any hash-backed shared state. Consumers that rely on scroll
37
+ * anchors should not also persist state to the hash on the same route.
38
+ */
39
+ /**
40
+ * Reads the value of a single named entry from a URL fragment.
41
+ *
42
+ * @param hash - The fragment string, with or without the leading `#`.
43
+ * @param key - The entry key to look up.
44
+ * @returns {string | undefined} The raw value (everything after the first
45
+ * `=` in the matching token), or `undefined` when the key is absent. An
46
+ * entry whose token has no `=` is treated as foreign content and ignored.
47
+ */
48
+ export declare const readHashKey: (hash: string, key: string) => string | undefined;
49
+ /**
50
+ * Returns a new fragment string with the named entry written, replaced,
51
+ * or removed. Foreign tokens are preserved in their original order; the
52
+ * named entry retains its position if it already exists, and is appended
53
+ * to the end when newly inserted. Empty tokens (e.g. produced by
54
+ * consecutive `&`) are dropped on the way out.
55
+ *
56
+ * @param hash - The current fragment string, with or without the leading `#`.
57
+ * @param key - The entry key to write.
58
+ * @param value - The value to set, or `undefined` to remove the entry.
59
+ * @returns {string} The new fragment string, including a leading `#` when
60
+ * non-empty, or an empty string when the result has no tokens left.
61
+ */
62
+ export declare const writeHashKey: (hash: string, key: string, value: string | undefined) => string;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Default maximum length of the resulting URL fragment after adding one
3
+ * persisted entry. Writes are skipped (localStorage continues to hold the
4
+ * full state) when adding the entry would push the fragment past this cap.
5
+ *
6
+ * The fragment is never sent to the server, so it is not subject to the
7
+ * AWS WAF managed-rule `SizeRestrictions_QUERYSTRING` cap that applies to
8
+ * search params (5000 bytes). 64 KB stays well below typical browser URL
9
+ * limits (Chromium ≳ 100 KB, Firefox ≳ 64 KB on the address bar) while
10
+ * still leaving generous headroom for any realistic table state.
11
+ */
12
+ export declare const MAX_HASH_LENGTH: number;
13
+ type UseHashParamSyncOptions = {
14
+ readonly key: string;
15
+ readonly enabled?: boolean;
16
+ readonly onExternalChange?: () => void;
17
+ readonly replace?: boolean;
18
+ };
19
+ type UseHashParamSyncReturn = {
20
+ /**
21
+ * Mirrors the field name on `useSearchParamSync` so this hook is a
22
+ * drop-in replacement when only the URL location changes (search ↔ hash).
23
+ * Despite the name, this value is read from `location.hash`.
24
+ */
25
+ readonly searchValue: string | undefined;
26
+ /**
27
+ * Mirrors the function name on `useSearchParamSync`. Writes to
28
+ * `location.hash`, preserving any foreign fragment content untouched.
29
+ */
30
+ readonly updateSearchParam: (encodedValue: string | undefined, options?: {
31
+ readonly replace?: boolean;
32
+ }) => void;
33
+ };
34
+ /**
35
+ * Syncs an encoded string value with a single named entry inside the URL
36
+ * fragment (`location.hash`) via Tanstack Router.
37
+ *
38
+ * Behaviour mirrors `useSearchParamSync`, with two differences:
39
+ * - Reads from and writes to `location.hash` instead of `location.search`.
40
+ * - Writes are guarded against {@link MAX_HASH_LENGTH} rather than the
41
+ * AWS-WAF-driven search-param cap.
42
+ *
43
+ * The fragment is treated as a sequence of `&`-separated tokens via the
44
+ * pure utilities in `./hashFragment`. Foreign tokens (bare anchors, other
45
+ * named entries) are preserved byte-for-byte on every write.
46
+ *
47
+ * @param options - Configuration for the hash entry sync.
48
+ * @param options.key - The named entry inside the fragment.
49
+ * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
50
+ * @param options.onExternalChange - Called when the entry changes externally
51
+ * (e.g. browser back/forward, pasted link, foreign router push).
52
+ * @param options.replace - `true` to always use `replaceState` instead of pushState.
53
+ */
54
+ export declare const useHashParamSync: ({ key, enabled, onExternalChange, replace: replaceOption, }: UseHashParamSyncOptions) => UseHashParamSyncReturn;
55
+ export {};
@@ -0,0 +1,62 @@
1
+ type UseMigrateLegacySearchParamOptions<TState> = {
2
+ readonly key: string;
3
+ readonly enabled: boolean;
4
+ /**
5
+ * Pure decoder for the raw search-param string. Should not throw — return
6
+ * `undefined` when the input can't be decoded.
7
+ */
8
+ readonly decode: (rawSearchValue: string) => unknown;
9
+ /**
10
+ * Optional transform applied to the decoded payload before validation
11
+ * (mirrors `usePersistedState`'s `fromUrlValue`).
12
+ */
13
+ readonly fromUrlValue?: (decoded: unknown) => unknown;
14
+ /**
15
+ * Schema validator. Return the parsed state on success, `undefined`
16
+ * otherwise.
17
+ */
18
+ readonly validate: (raw: unknown) => TState | undefined;
19
+ /**
20
+ * When `true`, the hash already carries a valid value for this key. The
21
+ * hook still strips the legacy search param (so the URL is clean) but
22
+ * does NOT fire `onMigrated` — the hash is canonical and we must not
23
+ * stomp on it.
24
+ */
25
+ readonly hashAlreadyHasValue: boolean;
26
+ /**
27
+ * Called at most once when a valid legacy search-param value is found
28
+ * and the hash is currently empty. Pipe through your normal
29
+ * `persistState` so the value ends up in both the hash and localStorage.
30
+ */
31
+ readonly onMigrated: (state: TState) => void;
32
+ };
33
+ /**
34
+ * One-shot migration: if the URL still carries a legacy `?<key>=...`
35
+ * search param from before this hook moved table-style state to the
36
+ * fragment, decode it, hand it to the caller via `onMigrated`, then strip
37
+ * the search param via a `replace` navigation so back/forward history
38
+ * isn't polluted.
39
+ *
40
+ * Invariants:
41
+ * - The strip navigation fires at most once per mount (guarded by
42
+ * `hasStrippedRef`) so we don't loop on the navigation we ourselves
43
+ * triggered.
44
+ * - `onMigrated` is also fired at most once per mount (guarded by
45
+ * `hasMigratedRef`), but its gating is independent of the strip:
46
+ * if `hashAlreadyHasValue` flips `true → false` after the strip
47
+ * (e.g. an external actor clears the canonical hash mid-mount), the
48
+ * next render still gets a chance to migrate the legacy value into
49
+ * the hash. Conflating the two flags would have permanently lost
50
+ * the legacy state in that race.
51
+ * - Never throws — a decode/validate failure silently drops the value
52
+ * and still strips the search param. The legacy key is owned by this
53
+ * codebase's persistence convention (camelCase + `Tp` suffix), so
54
+ * stripping unparseable values cannot destroy third-party data.
55
+ * - When `hashAlreadyHasValue` is `true` the hash wins: the search
56
+ * param is stripped but `onMigrated` is not fired.
57
+ * - The `location.hash` is preserved verbatim across the strip
58
+ * navigation so any concurrent hash content (including a freshly
59
+ * written value) survives the cleanup.
60
+ */
61
+ export declare const useMigrateLegacySearchParam: <TState>({ key, enabled, decode, fromUrlValue, validate, hashAlreadyHasValue, onMigrated, }: UseMigrateLegacySearchParamOptions<TState>) => void;
62
+ export {};
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Selects which part of the URL backs the persisted state.
3
+ *
4
+ * - `"search"` (default): `?<key>=<encoded>`. Subject to the AWS WAF
5
+ * managed-rule cap (~5000 bytes) — large states may be silently
6
+ * truncated before they hit the browser bar.
7
+ * - `"hash"`: `#<key>=<encoded>`. Stays on the client, so the cap is
8
+ * driven only by browser URL limits. When this mode is selected,
9
+ * `usePersistedState` also performs a one-shot migration from any
10
+ * legacy `?<key>=...` value so previously shared links don't lose state.
11
+ */
12
+ export type UrlPersistenceLocation = "search" | "hash";
1
13
  type UsePersistedStateOptions<TState> = {
2
14
  readonly key: string;
3
15
  readonly validate: (raw: unknown) => TState | undefined;
@@ -22,17 +34,28 @@ type UsePersistedStateOptions<TState> = {
22
34
  * fully replaces localStorage state on read.
23
35
  */
24
36
  readonly mergeWithStorageOnRead?: boolean;
37
+ /**
38
+ * Which slot of the URL backs the persisted value. Default `"search"`.
39
+ * See {@link UrlPersistenceLocation} for the trade-offs.
40
+ */
41
+ readonly urlLocation?: UrlPersistenceLocation;
25
42
  };
26
43
  type UsePersistedStateReturn<TState> = {
27
44
  readonly initialState: TState | undefined;
28
45
  readonly persistState: (state: TState) => void;
29
46
  };
30
47
  /**
31
- * Generic persistence hook that loads state from URL search params (with
32
- * localStorage fallback) and writes changes to both.
48
+ * Generic persistence hook that loads state from a URL slot (search param
49
+ * or hash fragment, depending on `urlLocation`) with localStorage fallback,
50
+ * and writes changes to both.
33
51
  *
34
52
  * On mount the hook resolves the initial state as follows:
35
- * - If a URL search param exists, decode it and pass through `validate`.
53
+ * - If the canonical URL slot (search OR hash, per `urlLocation`) holds a
54
+ * value, decode it and pass through `validate`.
55
+ * - In `urlLocation: "hash"` mode, if the canonical hash slot is empty
56
+ * but a **legacy** `?<key>=...` search param is present, decode and
57
+ * validate that as a one-shot fallback. A migration effect then strips
58
+ * the legacy search param via a `replace` navigation.
36
59
  * - If localStorage is enabled, read and validate its value.
37
60
  * - When `mergeWithStorageOnRead` is `false` (default): URL state wins
38
61
  * entirely when present, otherwise fall back to localStorage.
@@ -40,23 +63,25 @@ type UsePersistedStateReturn<TState> = {
40
63
  * localStorage so fields stripped by `toUrlValue` survive via storage.
41
64
  *
42
65
  * `persistState` writes the state to localStorage (via `serialize`, default
43
- * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
44
- * Duplicate writes where the state has not changed (deep equality) are skipped.
66
+ * `storageSerializer.serialize`) and syncs to the canonical URL slot (via
67
+ * `toUrlValue` + encoding). Duplicate writes where the state has not
68
+ * changed (deep equality) are skipped.
45
69
  *
46
70
  * @param options - Configuration for the persisted state.
47
- * @param options.key - Unique identifier used for both the URL search param and the localStorage key.
71
+ * @param options.key - Unique identifier used for both the URL slot and the localStorage key.
48
72
  * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
49
73
  * @param options.serialize - Custom localStorage serialiser (default `storageSerializer.serialize`).
50
74
  * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
51
75
  * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
52
76
  * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
53
77
  * @param options.localStorageEnabled - When `false` localStorage is neither read nor written (default `true`).
54
- * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
55
- * @param options.replace - Forwarded to `useSearchParamSync`.
78
+ * @param options.onExternalChange - Fired when the URL value changes externally (e.g. browser back).
79
+ * @param options.replace - Forwarded to the underlying URL sync hook.
56
80
  * @param options.clientSideUserId - The user ID to use for the localStorage key.
57
81
  * @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
58
82
  * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
59
83
  * omits fields so they still survive via localStorage. Default `false`.
84
+ * @param options.urlLocation - Which URL slot backs the value (`"search"` or `"hash"`). Default `"search"`.
60
85
  */
61
- export declare const usePersistedState: <TState extends object>({ key, validate, serialize, toUrlValue, fromUrlValue, enabled, localStorageEnabled, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead, }: UsePersistedStateOptions<TState>) => UsePersistedStateReturn<TState>;
86
+ export declare const usePersistedState: <TState extends object>({ key, validate, serialize, toUrlValue, fromUrlValue, enabled, localStorageEnabled, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead, urlLocation, }: UsePersistedStateOptions<TState>) => UsePersistedStateReturn<TState>;
62
87
  export {};
package/src/index.d.ts CHANGED
@@ -117,6 +117,7 @@ export * from "./hooks/localStorage/useLocalStorageReducer";
117
117
  export * from "./hooks/localStorage/useSessionStorage";
118
118
  export * from "./hooks/localStorage/useSessionStorageReducer";
119
119
  export * from "./hooks/noPagination";
120
+ export * from "./hooks/persistence/useHashParamSync";
120
121
  export * from "./hooks/persistence/usePersistedState";
121
122
  export * from "./hooks/persistence/useSearchParamSync";
122
123
  export * from "./hooks/persistence/useStorageKey";