@urbicon-ui/sveltekit-utils 7.0.0 → 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.
- package/README.md +44 -41
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/table-query.d.ts +16 -62
- package/dist/table-query.js +7 -63
- package/dist/table-view.d.ts +123 -0
- package/dist/table-view.js +241 -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 +6 -1
|
@@ -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": "
|
|
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": [
|