@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.
- package/README.md +43 -41
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/table-view.d.ts +163 -0
- package/dist/table-view.js +312 -0
- package/dist/url.svelte.d.ts +1 -101
- package/dist/url.svelte.js +9 -110
- package/dist/view-binding.svelte.d.ts +73 -0
- package/dist/view-binding.svelte.js +365 -0
- package/package.json +5 -5
- package/dist/table-query.d.ts +0 -204
- package/dist/table-query.js +0 -291
package/dist/url.svelte.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
};
|
package/dist/url.svelte.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|