@urbicon-ui/sveltekit-utils 7.0.1 → 8.0.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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/sveltekit-utils",
3
- "version": "7.0.1",
3
+ "version": "8.0.0",
4
4
  "description": "SvelteKit helper utilities — createCronRunner, streamSse, and URL-state runes",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,6 +51,11 @@
51
51
  "types": "./dist/table-query.d.ts",
52
52
  "import": "./dist/table-query.js",
53
53
  "default": "./dist/table-query.js"
54
+ },
55
+ "./table-view": {
56
+ "types": "./dist/table-view.d.ts",
57
+ "import": "./dist/table-view.js",
58
+ "default": "./dist/table-view.js"
54
59
  }
55
60
  },
56
61
  "files": [