@moku-labs/web 2.0.1 → 2.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 +45 -0
- package/dist/browser.d.mts +207 -10
- package/dist/browser.mjs +439 -26
- package/dist/index.cjs +439 -25
- package/dist/index.d.cts +207 -10
- package/dist/index.d.mts +207 -10
- package/dist/index.mjs +439 -26
- package/dist/{render-yXHc9BWI.mjs → render-CkdqOmsm.mjs} +7 -1
- package/dist/{render-DHUcHCYs.cjs → render-rf8FAg_Z.cjs} +7 -1
- package/dist/testing.d.mts +36 -1
- package/dist/testing.mjs +18 -3
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1407,6 +1407,22 @@ function route(pattern) {
|
|
|
1407
1407
|
const builder = {
|
|
1408
1408
|
pattern: carrier.pattern,
|
|
1409
1409
|
_meta: carrier._meta,
|
|
1410
|
+
/**
|
|
1411
|
+
* The named view-transition behaviour set via `.transition()` (live read).
|
|
1412
|
+
*
|
|
1413
|
+
* @returns The transition mode, or undefined when unset (app default applies).
|
|
1414
|
+
*/
|
|
1415
|
+
get _transition() {
|
|
1416
|
+
return carrier._transition;
|
|
1417
|
+
},
|
|
1418
|
+
/**
|
|
1419
|
+
* The scroll behaviour set via `.scroll()` (live read).
|
|
1420
|
+
*
|
|
1421
|
+
* @returns The scroll mode, or undefined when unset (app default applies).
|
|
1422
|
+
*/
|
|
1423
|
+
get _scroll() {
|
|
1424
|
+
return carrier._scroll;
|
|
1425
|
+
},
|
|
1410
1426
|
_handlers: carrier._handlers,
|
|
1411
1427
|
...createBuilderMethods(set),
|
|
1412
1428
|
/**
|
|
@@ -1424,6 +1440,38 @@ function route(pattern) {
|
|
|
1424
1440
|
meta(meta) {
|
|
1425
1441
|
Object.assign(carrier._meta, meta);
|
|
1426
1442
|
return builder;
|
|
1443
|
+
},
|
|
1444
|
+
/**
|
|
1445
|
+
* Declare the view-transition behaviour for navigations TO this route — a typed
|
|
1446
|
+
* framework directive read by the SPA kernel, overriding the app-wide
|
|
1447
|
+
* `spa.viewTransitions` default.
|
|
1448
|
+
*
|
|
1449
|
+
* @param mode - The {@link TransitionMode} (`none`/`crossfade`/`slide`/`morph`).
|
|
1450
|
+
* @returns The same builder for chaining.
|
|
1451
|
+
* @example
|
|
1452
|
+
* ```ts
|
|
1453
|
+
* route("/board/{id}/issue/{issueId}").transition("morph");
|
|
1454
|
+
* ```
|
|
1455
|
+
*/
|
|
1456
|
+
transition(mode) {
|
|
1457
|
+
carrier._transition = mode;
|
|
1458
|
+
return builder;
|
|
1459
|
+
},
|
|
1460
|
+
/**
|
|
1461
|
+
* Declare the scroll behaviour for navigations TO this route — a typed framework
|
|
1462
|
+
* directive read by the SPA kernel, overriding the app-wide `spa.scrollRestoration`
|
|
1463
|
+
* default.
|
|
1464
|
+
*
|
|
1465
|
+
* @param mode - The {@link ScrollMode} (`top`/`preserve`).
|
|
1466
|
+
* @returns The same builder for chaining.
|
|
1467
|
+
* @example
|
|
1468
|
+
* ```ts
|
|
1469
|
+
* route("/board/{id}/issue/{issueId}").scroll("preserve");
|
|
1470
|
+
* ```
|
|
1471
|
+
*/
|
|
1472
|
+
scroll(mode) {
|
|
1473
|
+
carrier._scroll = mode;
|
|
1474
|
+
return builder;
|
|
1427
1475
|
}
|
|
1428
1476
|
};
|
|
1429
1477
|
return builder;
|
|
@@ -9148,11 +9196,12 @@ function createApi(ctx) {
|
|
|
9148
9196
|
* Programmatically navigate to a path (client runtime; no-op without a DOM).
|
|
9149
9197
|
*
|
|
9150
9198
|
* @param path - Target path (pathname, optionally with search/hash).
|
|
9199
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
9151
9200
|
* @example
|
|
9152
9201
|
* app.spa.navigate("/about");
|
|
9153
9202
|
*/
|
|
9154
|
-
navigate(path) {
|
|
9155
|
-
ctx.state.kernel?.processNav(path);
|
|
9203
|
+
navigate(path, options) {
|
|
9204
|
+
ctx.state.kernel?.processNav(path, options);
|
|
9156
9205
|
},
|
|
9157
9206
|
/**
|
|
9158
9207
|
* Read the current resolved URL.
|
|
@@ -9343,13 +9392,15 @@ let commitVNodeFunction;
|
|
|
9343
9392
|
* await loadRenderChunk();
|
|
9344
9393
|
*/
|
|
9345
9394
|
async function loadRenderChunk() {
|
|
9346
|
-
renderChunk ??= import("./render-
|
|
9395
|
+
renderChunk ??= import("./render-CkdqOmsm.mjs");
|
|
9347
9396
|
commitVNodeFunction = (await renderChunk).commitVNode;
|
|
9348
9397
|
}
|
|
9349
9398
|
/**
|
|
9350
9399
|
* Commit a {@link RenderResult} into a host: `string` → `innerHTML`, `Node` →
|
|
9351
|
-
* `replaceChildren`, `
|
|
9352
|
-
*
|
|
9400
|
+
* `replaceChildren`, `null` → Preact unmount via the lazy gate (`render(null, host)` —
|
|
9401
|
+
* stays re-mountable, the correct "empty" for a persistent render-island), `void`/`undefined`
|
|
9402
|
+
* → no-op (the render mutated the DOM itself), and a Preact `VNode` → committed through the
|
|
9403
|
+
* lazy gate (loading it on demand if needed).
|
|
9353
9404
|
*
|
|
9354
9405
|
* @param host - The island host element to render into.
|
|
9355
9406
|
* @param result - The value returned by the island's `render`.
|
|
@@ -9476,6 +9527,18 @@ function buildContext(state, instance) {
|
|
|
9476
9527
|
return instance.route.url;
|
|
9477
9528
|
},
|
|
9478
9529
|
/**
|
|
9530
|
+
* Programmatically navigate the SPA (delegates to the kernel's navigator through
|
|
9531
|
+
* `state.navigate` — the same decoupled seam as `island`). No-op before the kernel binds.
|
|
9532
|
+
*
|
|
9533
|
+
* @param path - The internal destination path.
|
|
9534
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
9535
|
+
* @example
|
|
9536
|
+
* ctx.navigate(ctx.url("issue", { id, issueId }));
|
|
9537
|
+
*/
|
|
9538
|
+
navigate(path, options) {
|
|
9539
|
+
state.navigate?.(path, options);
|
|
9540
|
+
},
|
|
9541
|
+
/**
|
|
9479
9542
|
* The live per-instance state (`undefined` for legacy hooks-only islands).
|
|
9480
9543
|
*
|
|
9481
9544
|
* @returns The current state.
|
|
@@ -10095,20 +10158,48 @@ function prefersReducedMotion() {
|
|
|
10095
10158
|
* Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
|
|
10096
10159
|
* THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
|
|
10097
10160
|
*
|
|
10161
|
+
* Named transitions (`transition.types`, e.g. `["slide"]`) are exposed to CSS two ways
|
|
10162
|
+
* for the transition's lifetime, so a consumer can style the motion per navigation:
|
|
10163
|
+
* • a `:root[data-view-transition~="slide"]` attribute (works on EVERY View-Transitions
|
|
10164
|
+
* engine, including those without the `types` dictionary form);
|
|
10165
|
+
* • the standards-track `types` option (`:active-view-transition-type(slide)`) when the
|
|
10166
|
+
* engine supports the `startViewTransition({ update, types })` dictionary form.
|
|
10167
|
+
* Empty `types` ⇒ the default root crossfade. A shared `view-transition-name` left on an
|
|
10168
|
+
* element across the swap (e.g. a card and the panel it opens) morphs one into the other.
|
|
10169
|
+
*
|
|
10098
10170
|
* @param doSwap - The synchronous DOM mutation to perform.
|
|
10099
|
-
* @param
|
|
10171
|
+
* @param transition - The resolved {@link SwapTransition} (enabled + named types).
|
|
10100
10172
|
* @param beforeCapture - Optional hook run synchronously just before the swap/capture
|
|
10101
10173
|
* (e.g. scroll to the destination position).
|
|
10102
10174
|
* @example
|
|
10103
|
-
* runSwap(() => current.replaceWith(next),
|
|
10175
|
+
* runSwap(() => current.replaceWith(next), { enabled: true, types: ["slide"] });
|
|
10104
10176
|
*/
|
|
10105
|
-
function runSwap(doSwap,
|
|
10177
|
+
function runSwap(doSwap, transition, beforeCapture) {
|
|
10106
10178
|
const reduced = prefersReducedMotion();
|
|
10107
10179
|
const docWithVt = document;
|
|
10108
|
-
const canUseViewTransitions =
|
|
10180
|
+
const canUseViewTransitions = transition.enabled && !reduced && typeof docWithVt.startViewTransition === "function";
|
|
10109
10181
|
beforeCapture?.();
|
|
10110
|
-
if (canUseViewTransitions)
|
|
10111
|
-
|
|
10182
|
+
if (!canUseViewTransitions) {
|
|
10183
|
+
doSwap();
|
|
10184
|
+
return;
|
|
10185
|
+
}
|
|
10186
|
+
const root = typeof document === "undefined" ? void 0 : document.documentElement;
|
|
10187
|
+
const hasTypes = transition.types.length > 0;
|
|
10188
|
+
if (hasTypes && root) root.dataset.viewTransition = transition.types.join(" ");
|
|
10189
|
+
const clearMarker = () => {
|
|
10190
|
+
if (hasTypes && root) delete root.dataset.viewTransition;
|
|
10191
|
+
};
|
|
10192
|
+
let result;
|
|
10193
|
+
if (hasTypes) try {
|
|
10194
|
+
result = docWithVt.startViewTransition({
|
|
10195
|
+
update: doSwap,
|
|
10196
|
+
types: transition.types
|
|
10197
|
+
});
|
|
10198
|
+
} catch {
|
|
10199
|
+
result = docWithVt.startViewTransition(doSwap);
|
|
10200
|
+
}
|
|
10201
|
+
else result = docWithVt.startViewTransition(doSwap);
|
|
10202
|
+
Promise.resolve(result?.finished).then(clearMarker).catch(clearMarker);
|
|
10112
10203
|
}
|
|
10113
10204
|
/**
|
|
10114
10205
|
* Replace the `swapSelector` region of the live document with the matching
|
|
@@ -10122,22 +10213,22 @@ function runSwap(doSwap, viewTransitions, beforeCapture) {
|
|
|
10122
10213
|
*
|
|
10123
10214
|
* @param doc - The fetched document (DOMParser-parsed) holding the new region.
|
|
10124
10215
|
* @param swapSelector - CSS selector for the region to replace.
|
|
10125
|
-
* @param
|
|
10216
|
+
* @param transition - The resolved {@link SwapTransition} (enabled + named types).
|
|
10126
10217
|
* @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
|
|
10127
10218
|
* @param beforeCapture - Optional hook run synchronously just before the swap/capture
|
|
10128
10219
|
* (forwarded to {@link runSwap} — e.g. scroll to the destination position).
|
|
10129
10220
|
* @returns `true` when the swap was dispatched, `false` when either document lacks the region.
|
|
10130
10221
|
* @example
|
|
10131
|
-
* swapRegion(doc, "main > section", false, () => mountNew());
|
|
10222
|
+
* swapRegion(doc, "main > section", { enabled: false, types: [] }, () => mountNew());
|
|
10132
10223
|
*/
|
|
10133
|
-
function swapRegion(doc, swapSelector,
|
|
10224
|
+
function swapRegion(doc, swapSelector, transition, onSwapped, beforeCapture) {
|
|
10134
10225
|
const newContent = doc.querySelector(swapSelector);
|
|
10135
10226
|
const currentContent = document.querySelector(swapSelector);
|
|
10136
10227
|
if (!newContent || !currentContent) return false;
|
|
10137
10228
|
runSwap(() => {
|
|
10138
10229
|
currentContent.replaceWith(newContent);
|
|
10139
10230
|
onSwapped();
|
|
10140
|
-
},
|
|
10231
|
+
}, transition, beforeCapture);
|
|
10141
10232
|
return true;
|
|
10142
10233
|
}
|
|
10143
10234
|
/**
|
|
@@ -10294,10 +10385,25 @@ function attachRouter(handlers, navigate) {
|
|
|
10294
10385
|
const ERROR_PREFIX$1 = "[web]";
|
|
10295
10386
|
/** Last-resort `swapSelector` when neither config nor defaults supply one. */
|
|
10296
10387
|
const FALLBACK_SWAP_SELECTOR = "main > section";
|
|
10388
|
+
/**
|
|
10389
|
+
* Resolve the `viewTransitions` config value into the default {@link TransitionMode}:
|
|
10390
|
+
* `true` → `"crossfade"`, `false` → `"none"`, a named mode → itself.
|
|
10391
|
+
*
|
|
10392
|
+
* @param raw - The raw `viewTransitions` config value (boolean or named mode).
|
|
10393
|
+
* @returns The resolved default transition mode.
|
|
10394
|
+
* @example
|
|
10395
|
+
* resolveDefaultTransition(true); // "crossfade"
|
|
10396
|
+
*/
|
|
10397
|
+
function resolveDefaultTransition(raw) {
|
|
10398
|
+
if (raw === true) return "crossfade";
|
|
10399
|
+
if (raw === false) return "none";
|
|
10400
|
+
return raw;
|
|
10401
|
+
}
|
|
10297
10402
|
/** Default SPA config (declared as a value — no inline assertion). */
|
|
10298
10403
|
const defaultSpaConfig = {
|
|
10299
10404
|
swapSelector: "main > section",
|
|
10300
10405
|
viewTransitions: false,
|
|
10406
|
+
scrollRestoration: "top",
|
|
10301
10407
|
progressBar: true,
|
|
10302
10408
|
islands: []
|
|
10303
10409
|
};
|
|
@@ -10334,9 +10440,12 @@ function resolveSpaConfig(config) {
|
|
|
10334
10440
|
const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
|
|
10335
10441
|
if (swapSelector.trim() === "") throw new Error(`${ERROR_PREFIX$1} spa.swapSelector must be a non-empty string.\n Set a CSS selector for the page region to swap (e.g. "main > section").`);
|
|
10336
10442
|
if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
|
|
10443
|
+
const defaultTransition = resolveDefaultTransition(config.viewTransitions ?? false);
|
|
10337
10444
|
return {
|
|
10338
10445
|
swapSelector,
|
|
10339
|
-
viewTransitions:
|
|
10446
|
+
viewTransitions: defaultTransition !== "none",
|
|
10447
|
+
defaultTransition,
|
|
10448
|
+
scrollRestoration: config.scrollRestoration ?? "top",
|
|
10340
10449
|
progressBar: config.progressBar ?? true,
|
|
10341
10450
|
islands: config.islands ?? []
|
|
10342
10451
|
};
|
|
@@ -10361,6 +10470,7 @@ function createState(_ctx) {
|
|
|
10361
10470
|
currentUrl: "",
|
|
10362
10471
|
destroyRouter: null,
|
|
10363
10472
|
started: false,
|
|
10473
|
+
navigate: null,
|
|
10364
10474
|
kernel: null
|
|
10365
10475
|
};
|
|
10366
10476
|
}
|
|
@@ -10431,6 +10541,52 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10431
10541
|
const resolved = resolveSpaConfig(config);
|
|
10432
10542
|
let progress;
|
|
10433
10543
|
let pendingScrollToTop = true;
|
|
10544
|
+
let pendingTransition = resolveTransition(resolved.defaultTransition);
|
|
10545
|
+
/**
|
|
10546
|
+
* Resolve a {@link TransitionMode} into the runtime {@link SwapTransition} the swap
|
|
10547
|
+
* consumes: `"none"` ⇒ disabled; `"crossfade"` ⇒ enabled, no named types (default root
|
|
10548
|
+
* crossfade); any other mode ⇒ enabled with that name as the View-Transition type. The
|
|
10549
|
+
* app-wide `viewTransitions` enabled flag gates everything: when it is off AND the route
|
|
10550
|
+
* declares no transition, no transition runs.
|
|
10551
|
+
*
|
|
10552
|
+
* @param mode - The effective transition mode for this navigation.
|
|
10553
|
+
* @returns The resolved swap descriptor (enabled + named types).
|
|
10554
|
+
* @example
|
|
10555
|
+
* pendingTransition = resolveTransition("slide");
|
|
10556
|
+
*/
|
|
10557
|
+
function resolveTransition(mode) {
|
|
10558
|
+
if (mode === "none") return {
|
|
10559
|
+
enabled: false,
|
|
10560
|
+
types: []
|
|
10561
|
+
};
|
|
10562
|
+
if (mode === "crossfade") return {
|
|
10563
|
+
enabled: true,
|
|
10564
|
+
types: []
|
|
10565
|
+
};
|
|
10566
|
+
return {
|
|
10567
|
+
enabled: true,
|
|
10568
|
+
types: [mode]
|
|
10569
|
+
};
|
|
10570
|
+
}
|
|
10571
|
+
/**
|
|
10572
|
+
* Resolve the per-navigation scroll + transition intent from the DESTINATION route,
|
|
10573
|
+
* falling back to the app-wide defaults. A route's `.scroll()`/`.transition()` win; an
|
|
10574
|
+
* explicit per-call `scrollOverride` (from `ctx.navigate`/`app.spa.navigate`) wins over
|
|
10575
|
+
* the route. `forwardScrollToTop` carries the navigation-type intent (forward ⇒ `true`,
|
|
10576
|
+
* traverse ⇒ `false`); a `"preserve"` resolution forces no scroll-to-top either way.
|
|
10577
|
+
*
|
|
10578
|
+
* @param pathname - The destination pathname (search stripped for matching).
|
|
10579
|
+
* @param forwardScrollToTop - Whether this nav would scroll to top by default (forward vs traverse).
|
|
10580
|
+
* @param scrollOverride - An explicit per-call scroll override, if any.
|
|
10581
|
+
* @example
|
|
10582
|
+
* applyNavIntent("/board/abc/issue/1", true, undefined);
|
|
10583
|
+
*/
|
|
10584
|
+
const applyNavIntent = (pathname, forwardScrollToTop, scrollOverride) => {
|
|
10585
|
+
const route = deps.router.match(pathname.split("?")[0] ?? pathname)?.route;
|
|
10586
|
+
const scrollMode = scrollOverride ?? route?._scroll ?? resolved.scrollRestoration;
|
|
10587
|
+
pendingScrollToTop = forwardScrollToTop && scrollMode !== "preserve";
|
|
10588
|
+
pendingTransition = resolveTransition(route?._transition ?? resolved.defaultTransition);
|
|
10589
|
+
};
|
|
10434
10590
|
/**
|
|
10435
10591
|
* Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
|
|
10436
10592
|
* For a forward nav it scrolls to top BEFORE the swap (and, with view transitions on,
|
|
@@ -10496,7 +10652,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10496
10652
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
10497
10653
|
syncHead(deps.head, doc);
|
|
10498
10654
|
unmountPageSpecific(state, emit);
|
|
10499
|
-
if (!swapRegion(doc, resolved.swapSelector,
|
|
10655
|
+
if (!swapRegion(doc, resolved.swapSelector, pendingTransition, () => {
|
|
10500
10656
|
const routeSlice = islandRouteContext(pathname);
|
|
10501
10657
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10502
10658
|
notifyNavEnd(state, routeSlice);
|
|
@@ -10596,7 +10752,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10596
10752
|
const commitDataRender = async (pathname, resolvedRender, signal) => {
|
|
10597
10753
|
if (signal?.aborted) return;
|
|
10598
10754
|
const { route, vnode, routeContext, region } = resolvedRender;
|
|
10599
|
-
const { renderVNode } = await import("./render-
|
|
10755
|
+
const { renderVNode } = await import("./render-CkdqOmsm.mjs");
|
|
10600
10756
|
if (signal?.aborted) return;
|
|
10601
10757
|
syncDataHead(deps.head, route, routeContext);
|
|
10602
10758
|
unmountPageSpecific(state, emit);
|
|
@@ -10615,7 +10771,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10615
10771
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10616
10772
|
notifyNavEnd(state, routeSlice);
|
|
10617
10773
|
};
|
|
10618
|
-
runSwap(renderAndMount,
|
|
10774
|
+
runSwap(renderAndMount, pendingTransition, applyPendingScroll);
|
|
10619
10775
|
state.currentUrl = pathname;
|
|
10620
10776
|
progress?.done();
|
|
10621
10777
|
emit("spa:navigated", { url: pathname });
|
|
@@ -10665,7 +10821,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10665
10821
|
return;
|
|
10666
10822
|
}
|
|
10667
10823
|
const { vnode, region } = resolvedRender;
|
|
10668
|
-
const { renderVNode } = await import("./render-
|
|
10824
|
+
const { renderVNode } = await import("./render-CkdqOmsm.mjs");
|
|
10669
10825
|
renderVNode(vnode, region);
|
|
10670
10826
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10671
10827
|
};
|
|
@@ -10680,12 +10836,14 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10680
10836
|
* (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
|
|
10681
10837
|
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`);
|
|
10682
10838
|
* a superseded navigation never applies its swap (no stale last-write-wins).
|
|
10839
|
+
* @param scrollOverride - An explicit per-call scroll override (from `ctx.navigate`/
|
|
10840
|
+
* `app.spa.navigate`); wins over the destination route's `.scroll()` + the app default.
|
|
10683
10841
|
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
10684
10842
|
* @example
|
|
10685
10843
|
* await navigate("/en/world/");
|
|
10686
10844
|
*/
|
|
10687
|
-
const navigate = async (pathname, scrollToTop = true, signal) => {
|
|
10688
|
-
|
|
10845
|
+
const navigate = async (pathname, scrollToTop = true, signal, scrollOverride) => {
|
|
10846
|
+
applyNavIntent(pathname, scrollToTop, scrollOverride);
|
|
10689
10847
|
handleStart(pathname);
|
|
10690
10848
|
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
|
|
10691
10849
|
if (signal?.aborted) return;
|
|
@@ -10701,6 +10859,10 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10701
10859
|
init() {
|
|
10702
10860
|
for (const island of resolved.islands) registerIsland(state, island);
|
|
10703
10861
|
state.currentUrl = currentLocationUrl();
|
|
10862
|
+
state.navigate = (path, options) => {
|
|
10863
|
+
if (typeof document === "undefined") return;
|
|
10864
|
+
navigate(path, true, void 0, options?.scroll).catch(() => {});
|
|
10865
|
+
};
|
|
10704
10866
|
},
|
|
10705
10867
|
/**
|
|
10706
10868
|
* Boot navigation interception + initial scan (throws if already started).
|
|
@@ -10731,15 +10893,17 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10731
10893
|
registerIsland(state, island);
|
|
10732
10894
|
},
|
|
10733
10895
|
/**
|
|
10734
|
-
* Process a navigation to `path` (fetch then swap; full reload on error).
|
|
10896
|
+
* Process a navigation to `path` (fetch then swap; full reload on error). An optional
|
|
10897
|
+
* per-call `scroll` override flows through to the swap's scroll intent.
|
|
10735
10898
|
*
|
|
10736
10899
|
* @param path - The target path to navigate to.
|
|
10900
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
10737
10901
|
* @example
|
|
10738
10902
|
* kernel.processNav("/about");
|
|
10739
10903
|
*/
|
|
10740
|
-
processNav(path) {
|
|
10904
|
+
processNav(path, options) {
|
|
10741
10905
|
if (typeof document === "undefined") return;
|
|
10742
|
-
navigate(path).catch(() => {});
|
|
10906
|
+
navigate(path, true, void 0, options?.scroll).catch(() => {});
|
|
10743
10907
|
},
|
|
10744
10908
|
/**
|
|
10745
10909
|
* Scan the swap region and mount islands for matching elements.
|
|
@@ -10831,6 +10995,255 @@ function disposeSpa() {
|
|
|
10831
10995
|
}
|
|
10832
10996
|
}
|
|
10833
10997
|
//#endregion
|
|
10998
|
+
//#region src/plugins/spa/channel.ts
|
|
10999
|
+
/** Default keepalive interval (ms). */
|
|
11000
|
+
const DEFAULT_KEEPALIVE_MS = 3e4;
|
|
11001
|
+
/** Default first reconnect delay (ms). */
|
|
11002
|
+
const DEFAULT_RECONNECT_BASE_MS = 500;
|
|
11003
|
+
/** Default reconnect ceiling (ms). */
|
|
11004
|
+
const DEFAULT_RECONNECT_MAX_MS = 8e3;
|
|
11005
|
+
/**
|
|
11006
|
+
* Default frame parser — `JSON.parse`, dropping frames that fail to parse.
|
|
11007
|
+
*
|
|
11008
|
+
* @param raw - The raw text frame.
|
|
11009
|
+
* @returns The parsed value, or undefined on a parse error.
|
|
11010
|
+
* @example
|
|
11011
|
+
* const msg = defaultParse<BoardPatch>('{"type":"x"}');
|
|
11012
|
+
*/
|
|
11013
|
+
function defaultParse(raw) {
|
|
11014
|
+
try {
|
|
11015
|
+
return JSON.parse(raw);
|
|
11016
|
+
} catch {
|
|
11017
|
+
return;
|
|
11018
|
+
}
|
|
11019
|
+
}
|
|
11020
|
+
/**
|
|
11021
|
+
* Create a live realtime channel — a single self-healing WebSocket bound to one channel
|
|
11022
|
+
* `id` at a time, with refcounted subscription, auto-reconnect, an optional keepalive, a
|
|
11023
|
+
* pre-seed buffer, and an optimistic local-echo path. See the file header for the model.
|
|
11024
|
+
*
|
|
11025
|
+
* @param options - The channel configuration (see {@link ChannelOptions}).
|
|
11026
|
+
* @returns The {@link Channel} control surface.
|
|
11027
|
+
* @example
|
|
11028
|
+
* const board = createChannel<BoardPatch>({ url: (id) => `wss://host/ws/board/${id}` });
|
|
11029
|
+
*/
|
|
11030
|
+
function createChannel(options) {
|
|
11031
|
+
const parse = options.parse ?? defaultParse;
|
|
11032
|
+
const reconnectCfg = options.reconnect === false ? void 0 : options.reconnect ?? {};
|
|
11033
|
+
const baseMs = reconnectCfg?.baseMs ?? DEFAULT_RECONNECT_BASE_MS;
|
|
11034
|
+
const maxMs = reconnectCfg?.maxMs ?? DEFAULT_RECONNECT_MAX_MS;
|
|
11035
|
+
const keepEveryMs = options.keepAlive?.everyMs ?? DEFAULT_KEEPALIVE_MS;
|
|
11036
|
+
/** The live socket, or undefined when disconnected. */
|
|
11037
|
+
let socket;
|
|
11038
|
+
/** The id we WANT to stay subscribed to (drives reconnect); undefined ⇒ intentionally off. */
|
|
11039
|
+
let desiredId;
|
|
11040
|
+
/** Whether the consumer has seeded — gates live delivery vs. buffering. */
|
|
11041
|
+
let seeded = false;
|
|
11042
|
+
/** Frames received before {@link Channel.seed}, replayed in arrival order on seed. */
|
|
11043
|
+
let buffer = [];
|
|
11044
|
+
/** Message handlers fanned out on every delivered frame. */
|
|
11045
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
11046
|
+
/** Consecutive closes since the last clean open — drives the backoff delay. */
|
|
11047
|
+
let reconnectAttempts = 0;
|
|
11048
|
+
/** Pending reconnect timer handle, or undefined when none is scheduled. */
|
|
11049
|
+
let reconnectTimer;
|
|
11050
|
+
/** Keepalive interval handle, or undefined when off. */
|
|
11051
|
+
let keepAliveTimer;
|
|
11052
|
+
/**
|
|
11053
|
+
* Fan a message out to every handler now, or queue it when buffering before seed.
|
|
11054
|
+
*
|
|
11055
|
+
* @param message - The message to deliver or buffer.
|
|
11056
|
+
* @example
|
|
11057
|
+
* deliver(patch);
|
|
11058
|
+
*/
|
|
11059
|
+
function deliver(message) {
|
|
11060
|
+
if (options.bufferUntilSeed && !seeded) {
|
|
11061
|
+
buffer.push(message);
|
|
11062
|
+
return;
|
|
11063
|
+
}
|
|
11064
|
+
for (const handler of handlers) handler(message);
|
|
11065
|
+
}
|
|
11066
|
+
/**
|
|
11067
|
+
* Parse + route an incoming frame, dropping the keepalive reply and unparseable frames.
|
|
11068
|
+
*
|
|
11069
|
+
* @param event - The socket message event.
|
|
11070
|
+
* @example
|
|
11071
|
+
* socket.addEventListener("message", dispatch);
|
|
11072
|
+
*/
|
|
11073
|
+
function dispatch(event) {
|
|
11074
|
+
if (typeof event.data !== "string") return;
|
|
11075
|
+
if (options.keepAlive?.ignore !== void 0 && event.data === options.keepAlive.ignore) return;
|
|
11076
|
+
const message = parse(event.data);
|
|
11077
|
+
if (message !== void 0) deliver(message);
|
|
11078
|
+
}
|
|
11079
|
+
/**
|
|
11080
|
+
* Send the keepalive frame when the socket is open (no-op otherwise).
|
|
11081
|
+
*
|
|
11082
|
+
* @example
|
|
11083
|
+
* ping();
|
|
11084
|
+
*/
|
|
11085
|
+
function ping() {
|
|
11086
|
+
if (options.keepAlive && socket?.readyState === WebSocket.OPEN) socket.send(options.keepAlive.send);
|
|
11087
|
+
}
|
|
11088
|
+
/**
|
|
11089
|
+
* Open the live socket for {@link desiredId}, wiring delivery + auto-reconnect. Shared by
|
|
11090
|
+
* the first connect and every backoff retry.
|
|
11091
|
+
*
|
|
11092
|
+
* @example
|
|
11093
|
+
* openSocket();
|
|
11094
|
+
*/
|
|
11095
|
+
function openSocket() {
|
|
11096
|
+
const id = desiredId;
|
|
11097
|
+
if (id === void 0 || typeof WebSocket === "undefined") return;
|
|
11098
|
+
const ws = new WebSocket(options.url(id));
|
|
11099
|
+
socket = ws;
|
|
11100
|
+
ws.addEventListener("message", dispatch);
|
|
11101
|
+
ws.addEventListener("open", () => {
|
|
11102
|
+
reconnectAttempts = 0;
|
|
11103
|
+
});
|
|
11104
|
+
ws.addEventListener("close", () => {
|
|
11105
|
+
if (socket !== ws || desiredId === void 0) return;
|
|
11106
|
+
socket = void 0;
|
|
11107
|
+
if (reconnectCfg) scheduleReconnect();
|
|
11108
|
+
});
|
|
11109
|
+
}
|
|
11110
|
+
/**
|
|
11111
|
+
* Schedule a reconnect to {@link desiredId} with exponential backoff (capped), unless one
|
|
11112
|
+
* is already pending or reconnection is off.
|
|
11113
|
+
*
|
|
11114
|
+
* @example
|
|
11115
|
+
* scheduleReconnect();
|
|
11116
|
+
*/
|
|
11117
|
+
function scheduleReconnect() {
|
|
11118
|
+
if (reconnectTimer !== void 0 || desiredId === void 0) return;
|
|
11119
|
+
const delay = Math.min(baseMs * 2 ** reconnectAttempts, maxMs);
|
|
11120
|
+
reconnectAttempts += 1;
|
|
11121
|
+
reconnectTimer = setTimeout(() => {
|
|
11122
|
+
reconnectTimer = void 0;
|
|
11123
|
+
if (desiredId !== void 0) openSocket();
|
|
11124
|
+
}, delay);
|
|
11125
|
+
}
|
|
11126
|
+
/**
|
|
11127
|
+
* Tear down the live socket + timers and re-arm the pre-seed buffer, keeping handlers so a
|
|
11128
|
+
* later connect resumes delivering to them. Clearing {@link desiredId} first marks the drop
|
|
11129
|
+
* intentional (no reconnect).
|
|
11130
|
+
*
|
|
11131
|
+
* @example
|
|
11132
|
+
* teardown();
|
|
11133
|
+
*/
|
|
11134
|
+
function teardown() {
|
|
11135
|
+
desiredId = void 0;
|
|
11136
|
+
if (reconnectTimer !== void 0) {
|
|
11137
|
+
clearTimeout(reconnectTimer);
|
|
11138
|
+
reconnectTimer = void 0;
|
|
11139
|
+
}
|
|
11140
|
+
if (keepAliveTimer !== void 0) {
|
|
11141
|
+
clearInterval(keepAliveTimer);
|
|
11142
|
+
keepAliveTimer = void 0;
|
|
11143
|
+
}
|
|
11144
|
+
if (socket) {
|
|
11145
|
+
socket.removeEventListener("message", dispatch);
|
|
11146
|
+
socket.close();
|
|
11147
|
+
socket = void 0;
|
|
11148
|
+
}
|
|
11149
|
+
seeded = false;
|
|
11150
|
+
buffer = [];
|
|
11151
|
+
}
|
|
11152
|
+
/**
|
|
11153
|
+
* Open (or switch) the socket to `id`, re-arming the keepalive + pre-seed buffer.
|
|
11154
|
+
*
|
|
11155
|
+
* @param id - The channel id to connect to.
|
|
11156
|
+
* @example
|
|
11157
|
+
* connect("board-123");
|
|
11158
|
+
*/
|
|
11159
|
+
function connect(id) {
|
|
11160
|
+
const alive = socket?.readyState === WebSocket.CONNECTING || socket?.readyState === WebSocket.OPEN;
|
|
11161
|
+
if (desiredId === id && alive) return;
|
|
11162
|
+
teardown();
|
|
11163
|
+
desiredId = id;
|
|
11164
|
+
seeded = false;
|
|
11165
|
+
buffer = [];
|
|
11166
|
+
reconnectAttempts = 0;
|
|
11167
|
+
openSocket();
|
|
11168
|
+
if (options.keepAlive) keepAliveTimer = setInterval(ping, keepEveryMs);
|
|
11169
|
+
}
|
|
11170
|
+
return {
|
|
11171
|
+
/**
|
|
11172
|
+
* Subscribe to `id`, connecting (or switching) the single active socket. Refcounted.
|
|
11173
|
+
*
|
|
11174
|
+
* @param id - The channel id to subscribe to.
|
|
11175
|
+
* @param handler - Called with each delivered message.
|
|
11176
|
+
* @returns A function that removes this handler (last out disconnects).
|
|
11177
|
+
* @example
|
|
11178
|
+
* const off = board.subscribe(boardId, (patch) => applyPatch(ctx, patch));
|
|
11179
|
+
*/
|
|
11180
|
+
subscribe(id, handler) {
|
|
11181
|
+
handlers.add(handler);
|
|
11182
|
+
connect(id);
|
|
11183
|
+
return () => {
|
|
11184
|
+
handlers.delete(handler);
|
|
11185
|
+
if (handlers.size === 0) teardown();
|
|
11186
|
+
};
|
|
11187
|
+
},
|
|
11188
|
+
/**
|
|
11189
|
+
* Mark `id` seeded and flush the pre-seed buffer (no-op unless it is the active id).
|
|
11190
|
+
*
|
|
11191
|
+
* @param id - The channel id whose buffer to flush.
|
|
11192
|
+
* @example
|
|
11193
|
+
* board.seed(boardId);
|
|
11194
|
+
*/
|
|
11195
|
+
seed(id) {
|
|
11196
|
+
if (id !== desiredId || seeded) return;
|
|
11197
|
+
seeded = true;
|
|
11198
|
+
const queued = buffer;
|
|
11199
|
+
buffer = [];
|
|
11200
|
+
for (const message of queued) for (const handler of handlers) handler(message);
|
|
11201
|
+
},
|
|
11202
|
+
/**
|
|
11203
|
+
* Deliver a message to the local handlers immediately (optimistic echo) for the active id.
|
|
11204
|
+
*
|
|
11205
|
+
* @param id - The channel id (ignored unless it is the active id).
|
|
11206
|
+
* @param message - The message to deliver locally now.
|
|
11207
|
+
* @example
|
|
11208
|
+
* board.deliverLocal(boardId, { type: "issue.moved", issueId });
|
|
11209
|
+
*/
|
|
11210
|
+
deliverLocal(id, message) {
|
|
11211
|
+
if (id !== desiredId) return;
|
|
11212
|
+
for (const handler of handlers) handler(message);
|
|
11213
|
+
},
|
|
11214
|
+
/**
|
|
11215
|
+
* Send a raw text frame over the live socket (no-op when not open).
|
|
11216
|
+
*
|
|
11217
|
+
* @param raw - The frame to send.
|
|
11218
|
+
* @example
|
|
11219
|
+
* board.send("ping");
|
|
11220
|
+
*/
|
|
11221
|
+
send(raw) {
|
|
11222
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(raw);
|
|
11223
|
+
},
|
|
11224
|
+
/**
|
|
11225
|
+
* The currently-subscribed channel id, or undefined when disconnected.
|
|
11226
|
+
*
|
|
11227
|
+
* @returns The active id, or undefined.
|
|
11228
|
+
* @example
|
|
11229
|
+
* board.current();
|
|
11230
|
+
*/
|
|
11231
|
+
current() {
|
|
11232
|
+
return desiredId;
|
|
11233
|
+
},
|
|
11234
|
+
/**
|
|
11235
|
+
* Close the socket, cancel reconnect, and clear all handlers.
|
|
11236
|
+
*
|
|
11237
|
+
* @example
|
|
11238
|
+
* board.disconnect();
|
|
11239
|
+
*/
|
|
11240
|
+
disconnect() {
|
|
11241
|
+
handlers.clear();
|
|
11242
|
+
teardown();
|
|
11243
|
+
}
|
|
11244
|
+
};
|
|
11245
|
+
}
|
|
11246
|
+
//#endregion
|
|
10834
11247
|
//#region src/plugins/spa/lazy-embed.ts
|
|
10835
11248
|
/**
|
|
10836
11249
|
* @file `lazyEmbed` island — activates the static embed facades emitted by the
|
|
@@ -13176,4 +13589,4 @@ const createApp = core.createApp;
|
|
|
13176
13589
|
*/
|
|
13177
13590
|
const createPlugin = core.createPlugin;
|
|
13178
13591
|
//#endregion
|
|
13179
|
-
export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, EmbedFacadeButton, GalleryTrack, types_exports$5 as Head, types_exports$6 as Router, types_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
|
13592
|
+
export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, EmbedFacadeButton, GalleryTrack, types_exports$5 as Head, types_exports$6 as Router, types_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
|
@@ -39,12 +39,18 @@ function renderVNode(vnode, region) {
|
|
|
39
39
|
* scheduler in `islands.ts`), so an app whose islands never return a VNode never
|
|
40
40
|
* pulls Preact's `render` into its main bundle.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
42
|
+
* Pass `null` to render NOTHING while keeping the host mountable: Preact's
|
|
43
|
+
* `render(null, host)` unmounts the retained vdom AND resets its pointer, so a later
|
|
44
|
+
* non-empty `commitVNode` re-mounts cleanly. (A persistent render-island that instead set
|
|
45
|
+
* `host.innerHTML = ""` to "go empty" would desync Preact's retained vdom and never re-commit.)
|
|
46
|
+
*
|
|
47
|
+
* @param vnode - The VNode produced by an island's `render(state, ctx)`, or `null` to unmount.
|
|
43
48
|
* @param host - The island's host element to render into.
|
|
44
49
|
* @example
|
|
45
50
|
* ```ts
|
|
46
51
|
* const { commitVNode } = await import("./render");
|
|
47
52
|
* commitVNode(h(BoardView, { snapshot }), host);
|
|
53
|
+
* commitVNode(null, host); // close/empty: unmount but stay re-mountable
|
|
48
54
|
* ```
|
|
49
55
|
*/
|
|
50
56
|
function commitVNode(vnode, host) {
|
|
@@ -39,12 +39,18 @@ function renderVNode(vnode, region) {
|
|
|
39
39
|
* scheduler in `islands.ts`), so an app whose islands never return a VNode never
|
|
40
40
|
* pulls Preact's `render` into its main bundle.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
42
|
+
* Pass `null` to render NOTHING while keeping the host mountable: Preact's
|
|
43
|
+
* `render(null, host)` unmounts the retained vdom AND resets its pointer, so a later
|
|
44
|
+
* non-empty `commitVNode` re-mounts cleanly. (A persistent render-island that instead set
|
|
45
|
+
* `host.innerHTML = ""` to "go empty" would desync Preact's retained vdom and never re-commit.)
|
|
46
|
+
*
|
|
47
|
+
* @param vnode - The VNode produced by an island's `render(state, ctx)`, or `null` to unmount.
|
|
43
48
|
* @param host - The island's host element to render into.
|
|
44
49
|
* @example
|
|
45
50
|
* ```ts
|
|
46
51
|
* const { commitVNode } = await import("./render");
|
|
47
52
|
* commitVNode(h(BoardView, { snapshot }), host);
|
|
53
|
+
* commitVNode(null, host); // close/empty: unmount but stay re-mountable
|
|
48
54
|
* ```
|
|
49
55
|
*/
|
|
50
56
|
function commitVNode(vnode, host) {
|