@urbicon-ui/sveltekit-utils 7.0.1 → 8.1.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.
@@ -1,4 +1,4 @@
1
- import { type TableQueryParams, type TableQueryUrlOptions, type TableQueryViewState } from './table-query.js';
1
+ export { bindViewToUrl, type UrlViewBindingOptions } from './view-binding.svelte.js';
2
2
  /**
3
3
  * How {@link useUrlArrayParam} maps an array onto the URL:
4
4
  * - `repeat` — one entry per key: `?tag=a&tag=b`
@@ -134,103 +134,3 @@ export declare function useUrlArrayParam(key: string, opts: {
134
134
  strategy?: UrlArrayStrategy;
135
135
  delimiter?: string;
136
136
  }): readonly [() => string[], (next: string[]) => void];
137
- /** Options for {@link createTableQueryUrlSync}. */
138
- export interface TableQueryUrlSyncOptions extends TableQueryUrlOptions {
139
- /**
140
- * Replace the current history entry instead of pushing a new one. The
141
- * default (`true`) keeps every sort/filter/page interaction from polluting
142
- * the back button.
143
- * @default true
144
- */
145
- replaceState?: boolean;
146
- }
147
- /**
148
- * Opt-in URL sync for a table: mirrors the `TableQuery` the table emits onto
149
- * `?q=…&sort=…&page=…` query params, so the view state survives reloads, can
150
- * be shared as a link, and — unlike `localStorage` — is visible to the server.
151
- * Works in client mode as well as server mode; the table emits its query in
152
- * both.
153
- *
154
- * Two directions, both explicit:
155
- * - **URL → query**: `viewState` carries the axes the URL actually names, and
156
- * re-reads the URL on every navigation. Hand it to the table's `query` prop,
157
- * which controls exactly those axes — per field, and ahead of both
158
- * `persistenceConfig` and the `initial*` seeds. Because a controlled axis is
159
- * a derived rather than a write, it resolves during server rendering too,
160
- * which is the point: the server renders the view the link asked for instead
161
- * of a default one the client swaps out on hydration.
162
- * - **query → URL**: pass `syncQuery` the query from `onQueryChange`, or call
163
- * it inside `queryFn` (when `queryFn` is set, `onQueryChange` does not
164
- * fire). It rewrites only its own — optionally prefixed — params via
165
- * `goto` (`replaceState`, `noScroll`, `keepFocus`); unrelated params are
166
- * preserved. Values equal to `options.defaults` are elided, so a table in
167
- * its default state leaves the URL clean.
168
- *
169
- * Set `options.defaults` to the table's initial props (`itemsPerPage`,
170
- * `initialPage`, `initialGroupBy`) so the elision baseline matches the state
171
- * the table actually starts in.
172
- *
173
- * @example Client mode — the whole wiring is two props
174
- * ```svelte
175
- * <script lang="ts">
176
- * import { Table } from '@urbicon-ui/table';
177
- * import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
178
- *
179
- * const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
180
- * </script>
181
- *
182
- * <Table {items} {columns} query={sync.viewState} onQueryChange={sync.syncQuery} />
183
- * ```
184
- *
185
- * `query` is a *controlled* prop, which is what makes this work on the server:
186
- * the URL is parsed during SSR and the table renders the linked view rather
187
- * than an unfiltered one the client then replaces. It also outranks
188
- * `persistenceConfig` per axis, so there is no longer a caveat about a stored
189
- * value beating the link.
190
- *
191
- * Pass `viewState`, never `initialQuery`. `initialQuery` describes **every**
192
- * axis, so as a `query` value it claims every axis — including the ones the URL
193
- * says nothing about, whose values it filled in from the defaults. A table
194
- * wired that way ignores `persistenceConfig`, `initialSort`, `initialFilters`
195
- * and `initialGroupBy` entirely, on any URL, and says nothing about it.
196
- *
197
- * @example Server mode — the same two directions, with the fetch in between
198
- * ```svelte
199
- * <script lang="ts">
200
- * const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
201
- * </script>
202
- *
203
- * <Table
204
- * mode="server"
205
- * {columns}
206
- * itemsPerPage={25}
207
- * query={sync.viewState}
208
- * queryFn={async (query, { signal }) => {
209
- * sync.syncQuery(query);
210
- * const res = await fetch(`/api/users?${new URLSearchParams(...)}`, { signal });
211
- * const data = await res.json();
212
- * return { items: data.results, totalItems: data.total };
213
- * }}
214
- * />
215
- * ```
216
- *
217
- * @param options - Elision defaults, key prefix, history behaviour.
218
- * @returns `viewState` (the controlled axes, re-read on every navigation),
219
- * `initialQuery` (a complete snapshot for a fetch) + `syncQuery` (write a
220
- * query back to the URL).
221
- */
222
- export declare function createTableQueryUrlSync(options?: TableQueryUrlSyncOptions): {
223
- /**
224
- * The axes the URL names, re-read on every navigation.
225
- *
226
- * A getter, not a captured value: `createTableQueryUrlSync` runs once per
227
- * page component, and SvelteKit does not remount that component when only
228
- * the query string changes. A snapshot would therefore never see the back
229
- * button — the table would stay sorted after the user navigated back to a
230
- * URL with no sort param. Reading `page.url` here makes every consumer of
231
- * this getter a reader of SvelteKit's own reactive page state.
232
- */
233
- readonly viewState: TableQueryViewState;
234
- readonly initialQuery: TableQueryParams;
235
- readonly syncQuery: (query: TableQueryParams) => void;
236
- };
@@ -1,7 +1,15 @@
1
1
  import { building } from '$app/environment';
2
2
  import { goto } from '$app/navigation';
3
3
  import { page } from '$app/state';
4
- import { applyTableQueryToSearchParams, searchParamsToTableQuery, searchParamsToTableViewState } from './table-query.js';
4
+ // The v8 view-object binding lives in its own module; re-exported here so the
5
+ // documented import path (`@urbicon-ui/sveltekit-utils/url.svelte`) carries it.
6
+ // The mirror types (TableViewLike, TableViewSnapshot, …) are exported from the
7
+ // package root via `table-view` — not re-exported here, which would make the
8
+ // root's star exports ambiguous and silently drop them. The v7
9
+ // `createTableQueryUrlSync` factory is gone with the table's `query` prop —
10
+ // the URL home of a table view is `bindViewToUrl`; the load-path serializers
11
+ // (`searchParamsToViewSnapshot` & friends) live in `table-view`.
12
+ export { bindViewToUrl } from './view-binding.svelte.js';
5
13
  /**
6
14
  * Low-level escape hatch to update several params at once via `goto` (without a
7
15
  * full navigation). Starts from the current URL, applies `next`, and keeps
@@ -175,112 +183,3 @@ export function useUrlArrayParam(key, opts) {
175
183
  };
176
184
  return useUrlParam(key, { parse, serialize, initial: opts.initial });
177
185
  }
178
- /**
179
- * Opt-in URL sync for a table: mirrors the `TableQuery` the table emits onto
180
- * `?q=…&sort=…&page=…` query params, so the view state survives reloads, can
181
- * be shared as a link, and — unlike `localStorage` — is visible to the server.
182
- * Works in client mode as well as server mode; the table emits its query in
183
- * both.
184
- *
185
- * Two directions, both explicit:
186
- * - **URL → query**: `viewState` carries the axes the URL actually names, and
187
- * re-reads the URL on every navigation. Hand it to the table's `query` prop,
188
- * which controls exactly those axes — per field, and ahead of both
189
- * `persistenceConfig` and the `initial*` seeds. Because a controlled axis is
190
- * a derived rather than a write, it resolves during server rendering too,
191
- * which is the point: the server renders the view the link asked for instead
192
- * of a default one the client swaps out on hydration.
193
- * - **query → URL**: pass `syncQuery` the query from `onQueryChange`, or call
194
- * it inside `queryFn` (when `queryFn` is set, `onQueryChange` does not
195
- * fire). It rewrites only its own — optionally prefixed — params via
196
- * `goto` (`replaceState`, `noScroll`, `keepFocus`); unrelated params are
197
- * preserved. Values equal to `options.defaults` are elided, so a table in
198
- * its default state leaves the URL clean.
199
- *
200
- * Set `options.defaults` to the table's initial props (`itemsPerPage`,
201
- * `initialPage`, `initialGroupBy`) so the elision baseline matches the state
202
- * the table actually starts in.
203
- *
204
- * @example Client mode — the whole wiring is two props
205
- * ```svelte
206
- * <script lang="ts">
207
- * import { Table } from '@urbicon-ui/table';
208
- * import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
209
- *
210
- * const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
211
- * </script>
212
- *
213
- * <Table {items} {columns} query={sync.viewState} onQueryChange={sync.syncQuery} />
214
- * ```
215
- *
216
- * `query` is a *controlled* prop, which is what makes this work on the server:
217
- * the URL is parsed during SSR and the table renders the linked view rather
218
- * than an unfiltered one the client then replaces. It also outranks
219
- * `persistenceConfig` per axis, so there is no longer a caveat about a stored
220
- * value beating the link.
221
- *
222
- * Pass `viewState`, never `initialQuery`. `initialQuery` describes **every**
223
- * axis, so as a `query` value it claims every axis — including the ones the URL
224
- * says nothing about, whose values it filled in from the defaults. A table
225
- * wired that way ignores `persistenceConfig`, `initialSort`, `initialFilters`
226
- * and `initialGroupBy` entirely, on any URL, and says nothing about it.
227
- *
228
- * @example Server mode — the same two directions, with the fetch in between
229
- * ```svelte
230
- * <script lang="ts">
231
- * const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
232
- * </script>
233
- *
234
- * <Table
235
- * mode="server"
236
- * {columns}
237
- * itemsPerPage={25}
238
- * query={sync.viewState}
239
- * queryFn={async (query, { signal }) => {
240
- * sync.syncQuery(query);
241
- * const res = await fetch(`/api/users?${new URLSearchParams(...)}`, { signal });
242
- * const data = await res.json();
243
- * return { items: data.results, totalItems: data.total };
244
- * }}
245
- * />
246
- * ```
247
- *
248
- * @param options - Elision defaults, key prefix, history behaviour.
249
- * @returns `viewState` (the controlled axes, re-read on every navigation),
250
- * `initialQuery` (a complete snapshot for a fetch) + `syncQuery` (write a
251
- * query back to the URL).
252
- */
253
- export function createTableQueryUrlSync(options = {}) {
254
- // Same prerender rule as `useUrlParam`: no query string exists while
255
- // building, so both readers parse from empty params instead of throwing.
256
- const currentParams = () => (building ? new URLSearchParams() : page.url.searchParams);
257
- // Parsed once, on purpose — a snapshot to seed a fetch with, not a source of
258
- // truth. `viewState` below is the one the table binds to.
259
- const initialQuery = searchParamsToTableQuery(currentParams(), options);
260
- function syncQuery(query) {
261
- const next = applyTableQueryToSearchParams(page.url.searchParams, query, options);
262
- const qs = next.toString();
263
- goto(`${page.url.pathname}${qs ? `?${qs}` : ''}${page.url.hash}`, {
264
- replaceState: options.replaceState ?? true,
265
- noScroll: true,
266
- keepFocus: true
267
- });
268
- }
269
- return {
270
- /**
271
- * The axes the URL names, re-read on every navigation.
272
- *
273
- * A getter, not a captured value: `createTableQueryUrlSync` runs once per
274
- * page component, and SvelteKit does not remount that component when only
275
- * the query string changes. A snapshot would therefore never see the back
276
- * button — the table would stay sorted after the user navigated back to a
277
- * URL with no sort param. Reading `page.url` here makes every consumer of
278
- * this getter a reader of SvelteKit's own reactive page state.
279
- */
280
- get viewState() {
281
- return searchParamsToTableViewState(currentParams(), options);
282
- },
283
- initialQuery,
284
- syncQuery
285
- };
286
- }
@@ -0,0 +1,73 @@
1
+ import { type TableViewAxis, type TableViewLike } from './table-view.js';
2
+ /**
3
+ * Reset the module-scope writer between tests. The writer's pending-set and
4
+ * memo are keyed to a page's navigation stream; a test runner reusing the
5
+ * module across tests would otherwise leak one test's in-flight markers into
6
+ * the next.
7
+ * @internal test-only — not part of the public API.
8
+ */
9
+ export declare function __resetUrlWriterForTests(): void;
10
+ /**
11
+ * Size of the writer's live-key registry — lets the SSR suite assert that
12
+ * the module-global registry does not grow across simulated server requests.
13
+ * @internal test-only — not part of the public API.
14
+ */
15
+ export declare function __urlWriterLiveKeyCountForTests(): number;
16
+ /** Options for {@link bindViewToUrl}. */
17
+ export interface UrlViewBindingOptions {
18
+ /** Axes to bind. @default all six */
19
+ axes?: readonly TableViewAxis[];
20
+ /** Debounce for view → URL writes in ms. @default 300 */
21
+ debounceMs?: number;
22
+ /**
23
+ * Replace the current history entry instead of pushing a new one, so rapid
24
+ * sort/filter/page edits do not flood the back button.
25
+ *
26
+ * Decided for v8: this default covers **all** binding writes — v7
27
+ * continuity, keeping interactions from polluting the back button — and
28
+ * there is deliberately no per-interaction-type history mapping (a page
29
+ * turn pushing while a keystroke replaces); this one global option is the
30
+ * whole knob. Mirror-only writes (`reflectExternal`) always replace,
31
+ * whatever this is set to.
32
+ * @default true
33
+ */
34
+ replaceState?: boolean;
35
+ /**
36
+ * Key prefix (`prefix: 't_'` → `?t_q=…&t_page=…`) — namespace for a second
37
+ * bound table on the same page.
38
+ * @default ''
39
+ */
40
+ prefix?: string;
41
+ /**
42
+ * Whether *external* changes (a storage seed) are mirrored into the URL.
43
+ *
44
+ * - `false` (default): the address bar does not change without a reader
45
+ * interaction — a restored "yesterday's view" stays out of the URL until
46
+ * the reader's first edit, which then serializes the full snapshot.
47
+ * - `true`: an external application reaches the URL immediately, always
48
+ * via `replaceState` (a mirror is not a reader action, so it never
49
+ * mints a history entry) — the restored state becomes shareable without
50
+ * an interaction.
51
+ * @default false
52
+ */
53
+ reflectExternal?: boolean;
54
+ }
55
+ /**
56
+ * Bind a view to the page URL. Call during component initialisation: the
57
+ * init half runs synchronously (SSR-safe — a `?sort=…` link renders sorted
58
+ * server HTML), the runtime halves are effects.
59
+ *
60
+ * @example
61
+ * ```svelte
62
+ * <script lang="ts">
63
+ * import { Table, createTableView } from '@urbicon-ui/table';
64
+ * import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
65
+ *
66
+ * const view = createTableView({ defaults: { pageSize: 25 } });
67
+ * bindViewToUrl(view);
68
+ * </script>
69
+ *
70
+ * <Table {items} {columns} {view} />
71
+ * ```
72
+ */
73
+ export declare function bindViewToUrl(view: TableViewLike, options?: UrlViewBindingOptions): void;
@@ -0,0 +1,365 @@
1
+ /**
2
+ * The URL binding for the v8 table view object — the SvelteKit-bound half of
3
+ * the binding pair (`bindViewToStorage` lives in `@urbicon-ui/table`,
4
+ * kit-free). Decorates a view with the URL as its home: deep links apply at
5
+ * init (synchronously, during SSR too), navigations apply at runtime, and
6
+ * the reader's changes reach the URL debounced, with every axis that equals
7
+ * the view's defaults elided.
8
+ *
9
+ * Phase contract: defaults (constructor) → URL (init, synchronous) →
10
+ * storage (after hydration) → runtime (URL navigations apply; storage never
11
+ * applies again). At **init**, a missing param means *not claimed* — storage
12
+ * may seed the axis (the deep-link precedence URL > storage, the one moment
13
+ * presence matters). At **runtime**, a missing param on a bound axis means
14
+ * *apply the default* (the back-button contract).
15
+ *
16
+ * ## One URL writer per page
17
+ *
18
+ * All bindings share a module-scope, coalescing URL writer: jobs submitted
19
+ * in the same tick land in ONE `goto`, each job replacing only its own keys
20
+ * and preserving everything else. That is what makes two bindings (two
21
+ * tables with distinct `prefix`es) composable — every navigation carries
22
+ * both bindings' slices current, so neither ever sees a "foreign" URL from
23
+ * its sibling — and it is where the **self-navigation marker** lives: a
24
+ * landing URL the writer itself sent is not applied back onto the view, so
25
+ * a user edit made while the navigation was in flight survives instead of
26
+ * being overwritten by the landing (stale) URL — the measured lost-update
27
+ * window of the spike review.
28
+ */
29
+ import { untrack } from 'svelte';
30
+ import { browser, building } from '$app/environment';
31
+ import { goto } from '$app/navigation';
32
+ import { page } from '$app/state';
33
+ import { searchParamsToViewPartial, TABLE_VIEW_AXES, viewAxesNamedBy, viewAxisKeys, viewSnapshotToSearchParams } from './table-view.js';
34
+ /** Order-insensitive canonical form, for echo comparison only. */
35
+ function canonical(sp) {
36
+ return [...sp.entries()]
37
+ .map(([k, v]) => `${k}=${v}`)
38
+ .sort()
39
+ .join('&');
40
+ }
41
+ /** The binding's own slice of a search string — foreign params excluded. */
42
+ function ownSlice(search, keys) {
43
+ const all = new URLSearchParams(search);
44
+ const own = new URLSearchParams();
45
+ for (const key of keys) {
46
+ for (const value of all.getAll(key))
47
+ own.append(key, value);
48
+ }
49
+ return own;
50
+ }
51
+ /**
52
+ * The app-global coalescing URL writer (module scope — it outlives route
53
+ * changes). Safe on the server because every touch is browser-gated: flushes
54
+ * and teardowns live in effects and timer callbacks, which never run there,
55
+ * and registration is explicitly `browser`-gated in `bindViewToUrl` — an
56
+ * unconditional register leaked across requests (the map outlives the
57
+ * request, and only an effect teardown releases an entry), so request 2 of
58
+ * the same route threw the claims error below.
59
+ *
60
+ * While a navigation is in flight, `page.url` is stale — so the writer keeps
61
+ * `intendedSearch`, the last search string it sent, and uses it as BOTH the
62
+ * merge basis and the cancels-out comparison. Without it, a flush issued
63
+ * inside the in-flight window merged onto the stale URL: a revert during a
64
+ * slow navigation was swallowed as "cancels out" (permanent view↔URL
65
+ * divergence), and a sibling binding's slice was erased from the URL — the
66
+ * two red counter-examples of the adversarial review.
67
+ */
68
+ const writer = {
69
+ jobs: [],
70
+ flushQueued: false,
71
+ /** Search strings sent via `goto` and not yet acknowledged by a landing. */
72
+ sentPending: new Set(),
73
+ /** The last sent search string — the true URL basis while anything is pending. */
74
+ intendedSearch: null,
75
+ /** Memoized verdict for the most recent landing, so every binding on the
76
+ * page classifies one landing identically (the first query consumes the
77
+ * `sentPending` entry, the rest read the memo). */
78
+ lastClassified: null,
79
+ /**
80
+ * The URL keys of every live url binding, by owner. Two prefixless
81
+ * bindings on two views would silently manage the same keys (last flush
82
+ * wins, a shared link loads the wrong table) — a key intersection at
83
+ * registration is a programming error, caught here because the writer is
84
+ * the one place that sees every binding on the page.
85
+ */
86
+ liveKeys: new Map(),
87
+ register(owner, keys) {
88
+ for (const [other, otherKeys] of this.liveKeys) {
89
+ if (other === owner)
90
+ continue;
91
+ const clash = keys.find((key) => otherKeys.includes(key));
92
+ if (clash) {
93
+ throw new Error(`[bindViewToUrl] two url bindings on this page manage the URL key "${clash}" — give one of them a \`prefix\`.`);
94
+ }
95
+ }
96
+ this.liveKeys.set(owner, keys);
97
+ },
98
+ unregister(owner) {
99
+ this.liveKeys.delete(owner);
100
+ // Withdraw unflushed jobs: a debounce that fired in the same task as the
101
+ // unmount must not navigate with the dead binding's params.
102
+ this.jobs = this.jobs.filter((job) => job.owner !== owner);
103
+ },
104
+ submit(job) {
105
+ this.jobs.push(job);
106
+ if (this.flushQueued)
107
+ return;
108
+ this.flushQueued = true;
109
+ queueMicrotask(() => this.flush());
110
+ },
111
+ flush() {
112
+ this.flushQueued = false;
113
+ if (this.jobs.length === 0)
114
+ return;
115
+ const jobs = this.jobs;
116
+ this.jobs = [];
117
+ const baseline = this.intendedSearch ?? page.url.search;
118
+ const next = new URLSearchParams(baseline);
119
+ let replaceState = true;
120
+ for (const job of jobs) {
121
+ for (const key of job.keys)
122
+ next.delete(key);
123
+ for (const [key, value] of job.params)
124
+ next.append(key, value);
125
+ replaceState &&= job.replaceState;
126
+ }
127
+ const qs = next.toString();
128
+ const search = qs ? `?${qs}` : '';
129
+ if (search === baseline)
130
+ return; // coalesced jobs cancelled out
131
+ this.sentPending.add(search);
132
+ this.intendedSearch = search;
133
+ void goto(`${page.url.pathname}${search}${page.url.hash}`, {
134
+ replaceState,
135
+ noScroll: true,
136
+ keepFocus: true
137
+ });
138
+ },
139
+ /**
140
+ * Classify a landing URL: did this writer send it? Consumes the pending
141
+ * entry on first query (so a later back-navigation to the same string is
142
+ * foreign, as it should be) and memoizes the verdict for the flush so
143
+ * every binding agrees. A foreign landing invalidates everything pending —
144
+ * SvelteKit has cancelled those navigations. When the *intended* (last
145
+ * sent) navigation lands, every older pending entry is cleared too: those
146
+ * navigations were superseded and will never land, and a stale entry
147
+ * would misclassify a later back-landing on the same string as self.
148
+ */
149
+ classify(search) {
150
+ if (this.lastClassified?.search === search) {
151
+ return this.lastClassified.self ? 'self' : 'foreign';
152
+ }
153
+ const self = this.sentPending.delete(search);
154
+ if (!self) {
155
+ this.sentPending.clear();
156
+ this.intendedSearch = null;
157
+ }
158
+ else if (search === this.intendedSearch) {
159
+ this.sentPending.clear();
160
+ this.intendedSearch = null;
161
+ }
162
+ this.lastClassified = { search, self };
163
+ return self ? 'self' : 'foreign';
164
+ }
165
+ };
166
+ /**
167
+ * Reset the module-scope writer between tests. The writer's pending-set and
168
+ * memo are keyed to a page's navigation stream; a test runner reusing the
169
+ * module across tests would otherwise leak one test's in-flight markers into
170
+ * the next.
171
+ * @internal test-only — not part of the public API.
172
+ */
173
+ export function __resetUrlWriterForTests() {
174
+ writer.jobs = [];
175
+ writer.flushQueued = false;
176
+ writer.sentPending.clear();
177
+ writer.intendedSearch = null;
178
+ writer.lastClassified = null;
179
+ writer.liveKeys.clear();
180
+ }
181
+ /**
182
+ * Size of the writer's live-key registry — lets the SSR suite assert that
183
+ * the module-global registry does not grow across simulated server requests.
184
+ * @internal test-only — not part of the public API.
185
+ */
186
+ export function __urlWriterLiveKeyCountForTests() {
187
+ return writer.liveKeys.size;
188
+ }
189
+ const zeroRevisions = () => ({
190
+ search: 0,
191
+ sort: 0,
192
+ page: 0,
193
+ pageSize: 0,
194
+ filters: 0,
195
+ groupBy: 0
196
+ });
197
+ /** Read exactly the bound axes through the view's getters — tracked. */
198
+ function readAxes(view, axes) {
199
+ for (const axis of axes)
200
+ void view[axis];
201
+ }
202
+ /**
203
+ * Bind a view to the page URL. Call during component initialisation: the
204
+ * init half runs synchronously (SSR-safe — a `?sort=…` link renders sorted
205
+ * server HTML), the runtime halves are effects.
206
+ *
207
+ * @example
208
+ * ```svelte
209
+ * <script lang="ts">
210
+ * import { Table, createTableView } from '@urbicon-ui/table';
211
+ * import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
212
+ *
213
+ * const view = createTableView({ defaults: { pageSize: 25 } });
214
+ * bindViewToUrl(view);
215
+ * </script>
216
+ *
217
+ * <Table {items} {columns} {view} />
218
+ * ```
219
+ */
220
+ export function bindViewToUrl(view, options = {}) {
221
+ const axes = options.axes ?? TABLE_VIEW_AXES;
222
+ const debounceMs = options.debounceMs ?? 300;
223
+ const replaceState = options.replaceState ?? true;
224
+ const reflectExternal = options.reflectExternal ?? false;
225
+ const prefix = options.prefix ?? '';
226
+ const managedKeys = viewAxisKeys(axes, prefix);
227
+ /** Identity handle for the writer's job/key bookkeeping. */
228
+ const owner = {};
229
+ view.claimAxes('url', axes);
230
+ // The writer registry serves the CLIENT writer only. Registering during
231
+ // SSR would leak the request: the module-global map outlives the request
232
+ // and only the effect teardown below — which never runs on the server —
233
+ // releases an entry, so request 2 of the same route would throw the claims
234
+ // error (and disjoint routes would grow the map without bound). The
235
+ // fail-loud purpose — two prefixless bindings are a programming error —
236
+ // is fully preserved client-side, where the same page renders again.
237
+ if (browser)
238
+ writer.register(owner, managedKeys);
239
+ // ── Init phase: URL → view, synchronous. Absence means "not claimed" here
240
+ // (storage may seed the axis later) — the only moment presence matters.
241
+ // While prerendering there is no query string to read (SvelteKit forbids
242
+ // touching `url.searchParams`), so the defaults are the truth for that
243
+ // render; the client applies the real URL through the runtime effect.
244
+ const initialSearch = building ? '' : untrack(() => page.url.search);
245
+ if (!building) {
246
+ const initialParams = new URLSearchParams(initialSearch);
247
+ const named = viewAxesNamedBy(initialParams, prefix).filter((axis) => axes.includes(axis));
248
+ const initialPartial = searchParamsToViewPartial(initialParams, view.defaults, prefix);
249
+ const initApply = {};
250
+ for (const axis of named) {
251
+ initApply[axis] = initialPartial[axis];
252
+ }
253
+ view.applyExternal(initApply, 'external');
254
+ view.markInitApplied(named);
255
+ }
256
+ // ── Runtime: URL → view. From here on, absence on a *bound* axis means
257
+ // "apply the default" (the back-button contract). Guarded against the
258
+ // initial run so a storage seed applied between init and the first
259
+ // navigation is not flattened back to the defaults — the precise line
260
+ // where "init absence = unclaimed" turns into "runtime absence = default".
261
+ let lastSeenSearch = initialSearch;
262
+ $effect(() => {
263
+ const search = page.url.search;
264
+ if (search === lastSeenSearch)
265
+ return;
266
+ lastSeenSearch = search;
267
+ untrack(() => {
268
+ // The self-navigation marker: a landing the writer itself sent is not
269
+ // applied back. The view already holds this state — or a NEWER one,
270
+ // when the reader kept editing while the navigation was in flight, and
271
+ // applying the stale landing would overwrite their edit.
272
+ if (writer.classify(search) === 'self')
273
+ return;
274
+ lastSubmitted = null; // the URL basis changed under the binding
275
+ const params = new URLSearchParams(search);
276
+ const partial = searchParamsToViewPartial(params, view.defaults, prefix);
277
+ const full = {};
278
+ for (const axis of axes) {
279
+ full[axis] =
280
+ partial[axis] !== undefined ? partial[axis] : view.defaults[axis];
281
+ }
282
+ view.applyExternal(full, 'external');
283
+ });
284
+ });
285
+ // ── Runtime: view → URL, debounced. Echo suppression compares only the
286
+ // binding's OWN key slice (canonicalised — key order in the URL is not the
287
+ // binding's to dictate), against the slice it last *submitted* while a
288
+ // navigation is in flight — comparing against the live URL there would
289
+ // re-send (and re-race) states the writer is already carrying.
290
+ const lastSeenRevision = zeroRevisions();
291
+ for (const axis of axes)
292
+ lastSeenRevision[axis] = untrack(() => view.originOf(axis).revision);
293
+ let lastSubmitted = null;
294
+ let timer = null;
295
+ const currentBaseline = () => lastSubmitted ?? canonical(ownSlice(page.url.search, managedKeys));
296
+ // Whether the pending debounce window saw a reader (or system) change, as
297
+ // opposed to a pure external mirror (`reflectExternal`): a storage seed
298
+ // reaching the URL must never mint a history entry the reader did not
299
+ // cause, so a mirror-only submission always replaces.
300
+ let pendingHasUserChange = false;
301
+ $effect(() => {
302
+ readAxes(view, axes); // track exactly the bound axes
303
+ untrack(() => {
304
+ let shouldMirror = reflectExternal;
305
+ let sawUserChange = false;
306
+ for (const axis of axes) {
307
+ const { revision, origin } = view.originOf(axis);
308
+ if (revision > lastSeenRevision[axis]) {
309
+ lastSeenRevision[axis] = revision;
310
+ // `system` mirrors too: the table cleaning a value may clean the
311
+ // URL (virtualized × grouping). Only `external` (a binding
312
+ // applying) stays silent.
313
+ if (origin === 'user' || origin === 'system') {
314
+ shouldMirror = true;
315
+ sawUserChange = true;
316
+ }
317
+ }
318
+ }
319
+ if (!shouldMirror)
320
+ return;
321
+ if (sawUserChange)
322
+ pendingHasUserChange = true;
323
+ const serialized = viewSnapshotToSearchParams(view.snapshot(), view.defaults, axes, prefix);
324
+ if (canonical(serialized) === currentBaseline())
325
+ return;
326
+ if (timer)
327
+ clearTimeout(timer);
328
+ timer = setTimeout(() => {
329
+ timer = null;
330
+ // Serialize from the *live* view — the debounce window may have seen
331
+ // further changes; the last state is the one worth navigating to.
332
+ const latest = viewSnapshotToSearchParams(view.snapshot(), view.defaults, axes, prefix);
333
+ const latestCanonical = canonical(latest);
334
+ const mirrorOnly = !pendingHasUserChange;
335
+ pendingHasUserChange = false;
336
+ if (latestCanonical === currentBaseline())
337
+ return;
338
+ lastSubmitted = latestCanonical;
339
+ writer.submit({
340
+ owner,
341
+ keys: managedKeys,
342
+ params: latest,
343
+ replaceState: mirrorOnly ? true : replaceState
344
+ });
345
+ }, debounceMs);
346
+ });
347
+ // No per-run teardown — the timer must survive unrelated re-runs, or
348
+ // every keystroke would cancel the pending write.
349
+ });
350
+ // Destroy-only teardown: a dependency-free effect runs once; its teardown
351
+ // fires when the owning scope is destroyed. Without it, a pending debounce
352
+ // outlives the component and navigates with the dead table's params onto
353
+ // whatever page comes next — and the claims would block a remounting child
354
+ // (`{#if}`) on a longer-lived view from binding again.
355
+ $effect(() => {
356
+ return () => {
357
+ if (timer) {
358
+ clearTimeout(timer);
359
+ timer = null;
360
+ }
361
+ view.releaseAxes('url', axes);
362
+ writer.unregister(owner);
363
+ };
364
+ });
365
+ }