@moku-labs/web 1.13.2 → 1.14.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.
@@ -287,7 +287,10 @@ interface RouteState<P extends string = string, D = unknown> {
287
287
  /** Loaded data type produced by `.load()` (widened only by `.load()`). */
288
288
  readonly data: D;
289
289
  }
290
- /** Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return. */
290
+ /**
291
+ * Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return,
292
+ * `meta` the route's `.meta()` bag.
293
+ */
291
294
  interface RouteContext<S extends RouteState> {
292
295
  /** Resolved path params. */
293
296
  readonly params: S["params"];
@@ -295,6 +298,13 @@ interface RouteContext<S extends RouteState> {
295
298
  readonly data: S["data"];
296
299
  /** Active locale for this render. */
297
300
  readonly locale: string;
301
+ /**
302
+ * The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). Available in `.render()` and
303
+ * `.head()`, identically at build and on the client — `meta` is compiled into the route and
304
+ * shipped in the client manifest, so a client-only route (dynamic, no `.generate()`, whose
305
+ * `.load()` data is `{}` on the client) can feed static per-route config into its render.
306
+ */
307
+ readonly meta: Record<string, unknown>;
298
308
  /**
299
309
  * Build a link to a named route by pattern substitution — the framework delivers
300
310
  * this on the context (same output as `app.router.toUrl`), so render/head build
@@ -375,21 +385,16 @@ interface GenerateContext {
375
385
  readonly has: (name: string) => boolean;
376
386
  }
377
387
  /**
378
- * Context handed to a route's `.layout()` wrapper: the render-time
379
- * {@link RouteContext} plus the route's `.meta()` bag, so persistent chrome (e.g. a
380
- * TopBar/TabNav) can read `locale` and `meta.activeTab`. Distinct from
381
- * `RouteContext` because the layout is the only handler that needs `meta`; keeping
382
- * it on its own type leaves `.render()`/`.head()` contexts unchanged.
388
+ * Context handed to a route's `.layout()` wrapper identical to {@link RouteContext}
389
+ * (which now carries `meta` for every handler). Retained as a named alias so existing
390
+ * `.layout((ctx, children) => …)` typings keep compiling.
383
391
  *
384
392
  * @remarks
385
393
  * The layout is applied in the SSG render path ONLY. On client (SPA) navigation the
386
- * chrome is persistent and the layout is intentionally NOT re-applied — only the
387
- * inner swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
394
+ * chrome is persistent and the layout is intentionally NOT re-applied — only the inner
395
+ * swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
388
396
  */
389
- interface LayoutContext<S extends RouteState> extends RouteContext<S> {
390
- /** The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). */
391
- readonly meta: Record<string, unknown>;
392
- }
397
+ type LayoutContext<S extends RouteState> = RouteContext<S>;
393
398
  /** Head metadata produced by a route's `.head()` handler. */
394
399
  interface HeadConfig$1 {
395
400
  /** Document title. */
@@ -944,12 +949,25 @@ interface ResolvedSpaConfig {
944
949
  /** Pre-registered components. */
945
950
  components: ComponentDef[];
946
951
  }
947
- /** Context handed to every component lifecycle hook. */
952
+ /**
953
+ * Context handed to every component lifecycle hook — the bound element + page data,
954
+ * plus the matched route's `params`/`meta`/`locale` and a link builder, so an island
955
+ * can read its route context (e.g. a `card` route's `ctx.meta.focus` + `ctx.params.id`)
956
+ * directly, without the page bridging it through `data-*` attributes.
957
+ */
948
958
  interface ComponentContext {
949
959
  /** The element the component instance is bound to. */
950
960
  el: Element;
951
961
  /** Page data extracted from the `script#__DATA__` payload. */
952
962
  data: PageData;
963
+ /** Resolved path params of the route matched for the current URL (empty if unmatched). */
964
+ readonly params: Record<string, string | undefined>;
965
+ /** The matched route's `.meta()` bag (empty if unmatched). */
966
+ readonly meta: Record<string, unknown>;
967
+ /** Active locale for the current route (empty string if unknown). */
968
+ readonly locale: string;
969
+ /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
970
+ readonly url: (name: string, params?: Record<string, string>) => string;
953
971
  }
954
972
  /** Lifecycle hooks a component may implement. */
955
973
  interface ComponentHooks {
package/dist/browser.mjs CHANGED
@@ -2473,6 +2473,23 @@ const ERROR_PREFIX$2 = "[web]";
2473
2473
  /** The set of legal hook names, frozen for O(1) membership checks. */
2474
2474
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
2475
2475
  /**
2476
+ * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
2477
+ *
2478
+ * @returns An empty string.
2479
+ * @example
2480
+ * const href = noUrl();
2481
+ */
2482
+ function noUrl() {
2483
+ return "";
2484
+ }
2485
+ /** Empty route slice — used for mounts with no matched route (headless, tests, public `scan()`). */
2486
+ const EMPTY_ROUTE = {
2487
+ params: {},
2488
+ meta: {},
2489
+ locale: "",
2490
+ url: noUrl
2491
+ };
2492
+ /**
2476
2493
  * Validate a single hook entry: its key must be a known hook name and its value
2477
2494
  * must be a function. Throws fail-fast on the first violation.
2478
2495
  *
@@ -2560,18 +2577,25 @@ function runHook(instance, hook, ctx) {
2560
2577
  instance.def.hooks[hook]?.(ctx);
2561
2578
  }
2562
2579
  /**
2563
- * Builds the component context handed to a hook (the bound element + page data).
2580
+ * Builds the component context handed to a hook: the bound element + page data, merged
2581
+ * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
2582
+ * when no route is supplied (headless, tests, public `scan()`).
2564
2583
  *
2565
2584
  * @param element - The element the instance is bound to.
2566
2585
  * @param data - The current page data payload.
2586
+ * @param route - The matched-route slice for the current URL.
2567
2587
  * @returns The hook context.
2568
2588
  * @example
2569
- * const ctx = makeContext(element, data);
2589
+ * const ctx = makeContext(element, data, route);
2570
2590
  */
2571
- function makeContext(element, data) {
2591
+ function makeContext(element, data, route = EMPTY_ROUTE) {
2572
2592
  return {
2573
2593
  el: element,
2574
- data
2594
+ data,
2595
+ params: route.params,
2596
+ meta: route.meta,
2597
+ locale: route.locale,
2598
+ url: route.url
2575
2599
  };
2576
2600
  }
2577
2601
  /**
@@ -2585,17 +2609,18 @@ function makeContext(element, data) {
2585
2609
  * @param swapArea - The swap-region element, or null when none was found.
2586
2610
  * @param data - The current page data payload.
2587
2611
  * @param element - The candidate element carrying a `data-component` attribute.
2612
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
2588
2613
  * @example
2589
- * mountElement(state, emit, swapArea, data, element);
2614
+ * mountElement(state, emit, swapArea, data, element, route);
2590
2615
  */
2591
- function mountElement(state, emit, swapArea, data, element) {
2616
+ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
2592
2617
  if (state.instances.has(element)) return;
2593
2618
  const name = element.dataset.component;
2594
2619
  if (!name) return;
2595
2620
  const definition = state.registeredComponents.get(name);
2596
2621
  if (!definition) return;
2597
2622
  const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
2598
- const ctx = makeContext(element, data);
2623
+ const ctx = makeContext(element, data, route);
2599
2624
  runHook(instance, "onCreate", ctx);
2600
2625
  runHook(instance, "onMount", ctx);
2601
2626
  state.instances.set(element, instance);
@@ -2613,14 +2638,15 @@ function mountElement(state, emit, swapArea, data, element) {
2613
2638
  * @param state - The plugin state (registeredComponents + instances).
2614
2639
  * @param emit - The event emitter for spa:component-mount.
2615
2640
  * @param swapSelector - CSS selector bounding page-specific components.
2641
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
2616
2642
  * @example
2617
- * scanAndMount(state, emit, "main > section");
2643
+ * scanAndMount(state, emit, "main > section", route);
2618
2644
  */
2619
- function scanAndMount(state, emit, swapSelector) {
2645
+ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
2620
2646
  if (typeof document === "undefined") return;
2621
2647
  const swapArea = document.querySelector(swapSelector);
2622
2648
  const data = extractPageData(document);
2623
- for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
2649
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
2624
2650
  }
2625
2651
  /**
2626
2652
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -2686,12 +2712,13 @@ function notifyNavStart(state) {
2686
2712
  * instances were already destroyed and re-created by the swap).
2687
2713
  *
2688
2714
  * @param state - The plugin state holding live instances.
2715
+ * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
2689
2716
  * @example
2690
- * notifyNavEnd(state);
2717
+ * notifyNavEnd(state, route);
2691
2718
  */
2692
- function notifyNavEnd(state) {
2719
+ function notifyNavEnd(state, route = EMPTY_ROUTE) {
2693
2720
  const data = typeof document === "undefined" ? {} : extractPageData(document);
2694
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data));
2721
+ for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
2695
2722
  }
2696
2723
  //#endregion
2697
2724
  //#region src/plugins/spa/head.ts
@@ -3334,6 +3361,26 @@ function createSpaKernel(state, config, emit, deps) {
3334
3361
  });
3335
3362
  };
3336
3363
  /**
3364
+ * Build the matched-route slice (params/meta/locale/url) for the component context at `path`,
3365
+ * so islands read their route's params/meta directly. An unmatched path yields an empty slice.
3366
+ *
3367
+ * @param path - The URL (pathname + search) to match.
3368
+ * @returns The route slice for the matched route.
3369
+ * @example
3370
+ * scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(pathname));
3371
+ */
3372
+ const componentRouteContext = (path) => {
3373
+ const matchPath = path.split("?")[0] ?? path;
3374
+ const hit = deps.router.match(matchPath);
3375
+ const locale = hit?.params.lang ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
3376
+ return {
3377
+ params: hit?.params ?? {},
3378
+ meta: hit?.route._meta ?? {},
3379
+ locale,
3380
+ url: (name, params = {}) => deps.router.toUrl(name, params)
3381
+ };
3382
+ };
3383
+ /**
3337
3384
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
3338
3385
  * When the region cannot be swapped (either document lacks the swap selector)
3339
3386
  * the SPA nav cannot complete — the head is already synced and the islands torn
@@ -3351,8 +3398,9 @@ function createSpaKernel(state, config, emit, deps) {
3351
3398
  syncHead(deps.head, doc);
3352
3399
  unmountPageSpecific(state, emit);
3353
3400
  if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
3354
- scanAndMount(state, emit, resolved.swapSelector);
3355
- notifyNavEnd(state);
3401
+ const routeSlice = componentRouteContext(pathname);
3402
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3403
+ notifyNavEnd(state, routeSlice);
3356
3404
  }, applyPendingScroll)) {
3357
3405
  handleError();
3358
3406
  location.href = pathname;
@@ -3421,6 +3469,7 @@ function createSpaKernel(state, config, emit, deps) {
3421
3469
  params: hit.params,
3422
3470
  data,
3423
3471
  locale,
3472
+ meta: hit.route._meta,
3424
3473
  url: (routeName, routeParams = {}) => deps.router.toUrl(routeName, routeParams)
3425
3474
  };
3426
3475
  const vnode = hit.route._handlers.render(routeContext);
@@ -3452,6 +3501,7 @@ function createSpaKernel(state, config, emit, deps) {
3452
3501
  if (signal?.aborted) return;
3453
3502
  syncDataHead(deps.head, route, routeContext);
3454
3503
  unmountPageSpecific(state, emit);
3504
+ const routeSlice = componentRouteContext(pathname);
3455
3505
  /**
3456
3506
  * Render the VNode into the region and re-mount its islands in one paint — the
3457
3507
  * swap body handed to `runSwap` (optionally wrapped in a View Transition).
@@ -3463,8 +3513,8 @@ function createSpaKernel(state, config, emit, deps) {
3463
3513
  */
3464
3514
  const renderAndMount = () => {
3465
3515
  renderVNode(vnode, region);
3466
- scanAndMount(state, emit, resolved.swapSelector);
3467
- notifyNavEnd(state);
3516
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3517
+ notifyNavEnd(state, routeSlice);
3468
3518
  };
3469
3519
  runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
3470
3520
  state.currentUrl = pathname;
@@ -3509,15 +3559,16 @@ function createSpaKernel(state, config, emit, deps) {
3509
3559
  * await bootRender("/b/abc123");
3510
3560
  */
3511
3561
  const bootRender = async (pathname) => {
3562
+ const routeSlice = componentRouteContext(pathname);
3512
3563
  const resolvedRender = await resolveDataRender(pathname);
3513
3564
  if (resolvedRender === false) {
3514
- scanAndMount(state, emit, resolved.swapSelector);
3565
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3515
3566
  return;
3516
3567
  }
3517
3568
  const { vnode, region } = resolvedRender;
3518
3569
  const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3519
3570
  renderVNode(vnode, region);
3520
- scanAndMount(state, emit, resolved.swapSelector);
3571
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3521
3572
  };
3522
3573
  /**
3523
3574
  * Unified navigation: try the client DATA path first (only when the `data`
@@ -3567,7 +3618,7 @@ function createSpaKernel(state, config, emit, deps) {
3567
3618
  const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
3568
3619
  const hit = deps.router.match(matchPath);
3569
3620
  if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
3570
- else scanAndMount(state, emit, resolved.swapSelector);
3621
+ else scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
3571
3622
  state.started = true;
3572
3623
  },
3573
3624
  /**
@@ -3598,7 +3649,7 @@ function createSpaKernel(state, config, emit, deps) {
3598
3649
  * kernel.scan();
3599
3650
  */
3600
3651
  scan() {
3601
- scanAndMount(state, emit, resolved.swapSelector);
3652
+ scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
3602
3653
  },
3603
3654
  /**
3604
3655
  * Tear down router listeners, dispose all instances, reset boot state.
package/dist/index.cjs CHANGED
@@ -4738,8 +4738,8 @@ function composeHeadHtml(ctx, instance, url, routeContext, data) {
4738
4738
  * layout is NOT re-applied on navigation), then serialize with
4739
4739
  * preact-render-to-string. Returns `""` when the route has no `.render()`.
4740
4740
  *
4741
- * @param definition - The route definition (provides `.render()`/`.layout()`/`._meta`).
4742
- * @param routeContext - The route context (params/data/locale/url) — extended with `meta` for the layout.
4741
+ * @param definition - The route definition (provides `.render()`/`.layout()`).
4742
+ * @param routeContext - The route context (params/data/locale/meta/url); `meta` flows to the layout.
4743
4743
  * @returns The SSR-rendered body HTML, or `""` when the route has no `.render()`.
4744
4744
  * @example
4745
4745
  * ```ts
@@ -4749,11 +4749,7 @@ function composeHeadHtml(ctx, instance, url, routeContext, data) {
4749
4749
  function renderBody(definition, routeContext) {
4750
4750
  const vnode = definition._handlers.render?.(routeContext);
4751
4751
  if (!vnode) return "";
4752
- const layoutContext = {
4753
- ...routeContext,
4754
- meta: definition._meta
4755
- };
4756
- return (0, preact_render_to_string.renderToString)(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
4752
+ return (0, preact_render_to_string.renderToString)(definition._handlers.layout ? definition._handlers.layout(routeContext, vnode) : vnode);
4757
4753
  }
4758
4754
  /**
4759
4755
  * Hash a page's render inputs (its loaded data) for the render cache. `null` when the
@@ -4887,6 +4883,7 @@ async function renderInstance(ctx, instance, shell, reuse) {
4887
4883
  params,
4888
4884
  data,
4889
4885
  locale,
4886
+ meta: definition._meta,
4890
4887
  url: (routeName, routeParams = {}) => router.toUrl(routeName, routeParams)
4891
4888
  };
4892
4889
  const parts = {
@@ -9098,6 +9095,23 @@ const ERROR_PREFIX$2 = "[web]";
9098
9095
  /** The set of legal hook names, frozen for O(1) membership checks. */
9099
9096
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
9100
9097
  /**
9098
+ * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
9099
+ *
9100
+ * @returns An empty string.
9101
+ * @example
9102
+ * const href = noUrl();
9103
+ */
9104
+ function noUrl() {
9105
+ return "";
9106
+ }
9107
+ /** Empty route slice — used for mounts with no matched route (headless, tests, public `scan()`). */
9108
+ const EMPTY_ROUTE = {
9109
+ params: {},
9110
+ meta: {},
9111
+ locale: "",
9112
+ url: noUrl
9113
+ };
9114
+ /**
9101
9115
  * Validate a single hook entry: its key must be a known hook name and its value
9102
9116
  * must be a function. Throws fail-fast on the first violation.
9103
9117
  *
@@ -9185,18 +9199,25 @@ function runHook(instance, hook, ctx) {
9185
9199
  instance.def.hooks[hook]?.(ctx);
9186
9200
  }
9187
9201
  /**
9188
- * Builds the component context handed to a hook (the bound element + page data).
9202
+ * Builds the component context handed to a hook: the bound element + page data, merged
9203
+ * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
9204
+ * when no route is supplied (headless, tests, public `scan()`).
9189
9205
  *
9190
9206
  * @param element - The element the instance is bound to.
9191
9207
  * @param data - The current page data payload.
9208
+ * @param route - The matched-route slice for the current URL.
9192
9209
  * @returns The hook context.
9193
9210
  * @example
9194
- * const ctx = makeContext(element, data);
9211
+ * const ctx = makeContext(element, data, route);
9195
9212
  */
9196
- function makeContext(element, data) {
9213
+ function makeContext(element, data, route = EMPTY_ROUTE) {
9197
9214
  return {
9198
9215
  el: element,
9199
- data
9216
+ data,
9217
+ params: route.params,
9218
+ meta: route.meta,
9219
+ locale: route.locale,
9220
+ url: route.url
9200
9221
  };
9201
9222
  }
9202
9223
  /**
@@ -9210,17 +9231,18 @@ function makeContext(element, data) {
9210
9231
  * @param swapArea - The swap-region element, or null when none was found.
9211
9232
  * @param data - The current page data payload.
9212
9233
  * @param element - The candidate element carrying a `data-component` attribute.
9234
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
9213
9235
  * @example
9214
- * mountElement(state, emit, swapArea, data, element);
9236
+ * mountElement(state, emit, swapArea, data, element, route);
9215
9237
  */
9216
- function mountElement(state, emit, swapArea, data, element) {
9238
+ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
9217
9239
  if (state.instances.has(element)) return;
9218
9240
  const name = element.dataset.component;
9219
9241
  if (!name) return;
9220
9242
  const definition = state.registeredComponents.get(name);
9221
9243
  if (!definition) return;
9222
9244
  const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
9223
- const ctx = makeContext(element, data);
9245
+ const ctx = makeContext(element, data, route);
9224
9246
  runHook(instance, "onCreate", ctx);
9225
9247
  runHook(instance, "onMount", ctx);
9226
9248
  state.instances.set(element, instance);
@@ -9238,14 +9260,15 @@ function mountElement(state, emit, swapArea, data, element) {
9238
9260
  * @param state - The plugin state (registeredComponents + instances).
9239
9261
  * @param emit - The event emitter for spa:component-mount.
9240
9262
  * @param swapSelector - CSS selector bounding page-specific components.
9263
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
9241
9264
  * @example
9242
- * scanAndMount(state, emit, "main > section");
9265
+ * scanAndMount(state, emit, "main > section", route);
9243
9266
  */
9244
- function scanAndMount(state, emit, swapSelector) {
9267
+ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
9245
9268
  if (typeof document === "undefined") return;
9246
9269
  const swapArea = document.querySelector(swapSelector);
9247
9270
  const data = extractPageData(document);
9248
- for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
9271
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
9249
9272
  }
9250
9273
  /**
9251
9274
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -9311,12 +9334,13 @@ function notifyNavStart(state) {
9311
9334
  * instances were already destroyed and re-created by the swap).
9312
9335
  *
9313
9336
  * @param state - The plugin state holding live instances.
9337
+ * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
9314
9338
  * @example
9315
- * notifyNavEnd(state);
9339
+ * notifyNavEnd(state, route);
9316
9340
  */
9317
- function notifyNavEnd(state) {
9341
+ function notifyNavEnd(state, route = EMPTY_ROUTE) {
9318
9342
  const data = typeof document === "undefined" ? {} : extractPageData(document);
9319
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data));
9343
+ for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
9320
9344
  }
9321
9345
  //#endregion
9322
9346
  //#region src/plugins/spa/head.ts
@@ -9959,6 +9983,26 @@ function createSpaKernel(state, config, emit, deps) {
9959
9983
  });
9960
9984
  };
9961
9985
  /**
9986
+ * Build the matched-route slice (params/meta/locale/url) for the component context at `path`,
9987
+ * so islands read their route's params/meta directly. An unmatched path yields an empty slice.
9988
+ *
9989
+ * @param path - The URL (pathname + search) to match.
9990
+ * @returns The route slice for the matched route.
9991
+ * @example
9992
+ * scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(pathname));
9993
+ */
9994
+ const componentRouteContext = (path) => {
9995
+ const matchPath = path.split("?")[0] ?? path;
9996
+ const hit = deps.router.match(matchPath);
9997
+ const locale = hit?.params.lang ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
9998
+ return {
9999
+ params: hit?.params ?? {},
10000
+ meta: hit?.route._meta ?? {},
10001
+ locale,
10002
+ url: (name, params = {}) => deps.router.toUrl(name, params)
10003
+ };
10004
+ };
10005
+ /**
9962
10006
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
9963
10007
  * When the region cannot be swapped (either document lacks the swap selector)
9964
10008
  * the SPA nav cannot complete — the head is already synced and the islands torn
@@ -9976,8 +10020,9 @@ function createSpaKernel(state, config, emit, deps) {
9976
10020
  syncHead(deps.head, doc);
9977
10021
  unmountPageSpecific(state, emit);
9978
10022
  if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
9979
- scanAndMount(state, emit, resolved.swapSelector);
9980
- notifyNavEnd(state);
10023
+ const routeSlice = componentRouteContext(pathname);
10024
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10025
+ notifyNavEnd(state, routeSlice);
9981
10026
  }, applyPendingScroll)) {
9982
10027
  handleError();
9983
10028
  location.href = pathname;
@@ -10046,6 +10091,7 @@ function createSpaKernel(state, config, emit, deps) {
10046
10091
  params: hit.params,
10047
10092
  data,
10048
10093
  locale,
10094
+ meta: hit.route._meta,
10049
10095
  url: (routeName, routeParams = {}) => deps.router.toUrl(routeName, routeParams)
10050
10096
  };
10051
10097
  const vnode = hit.route._handlers.render(routeContext);
@@ -10077,6 +10123,7 @@ function createSpaKernel(state, config, emit, deps) {
10077
10123
  if (signal?.aborted) return;
10078
10124
  syncDataHead(deps.head, route, routeContext);
10079
10125
  unmountPageSpecific(state, emit);
10126
+ const routeSlice = componentRouteContext(pathname);
10080
10127
  /**
10081
10128
  * Render the VNode into the region and re-mount its islands in one paint — the
10082
10129
  * swap body handed to `runSwap` (optionally wrapped in a View Transition).
@@ -10088,8 +10135,8 @@ function createSpaKernel(state, config, emit, deps) {
10088
10135
  */
10089
10136
  const renderAndMount = () => {
10090
10137
  renderVNode(vnode, region);
10091
- scanAndMount(state, emit, resolved.swapSelector);
10092
- notifyNavEnd(state);
10138
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10139
+ notifyNavEnd(state, routeSlice);
10093
10140
  };
10094
10141
  runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10095
10142
  state.currentUrl = pathname;
@@ -10134,15 +10181,16 @@ function createSpaKernel(state, config, emit, deps) {
10134
10181
  * await bootRender("/b/abc123");
10135
10182
  */
10136
10183
  const bootRender = async (pathname) => {
10184
+ const routeSlice = componentRouteContext(pathname);
10137
10185
  const resolvedRender = await resolveDataRender(pathname);
10138
10186
  if (resolvedRender === false) {
10139
- scanAndMount(state, emit, resolved.swapSelector);
10187
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10140
10188
  return;
10141
10189
  }
10142
10190
  const { vnode, region } = resolvedRender;
10143
10191
  const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
10144
10192
  renderVNode(vnode, region);
10145
- scanAndMount(state, emit, resolved.swapSelector);
10193
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10146
10194
  };
10147
10195
  /**
10148
10196
  * Unified navigation: try the client DATA path first (only when the `data`
@@ -10192,7 +10240,7 @@ function createSpaKernel(state, config, emit, deps) {
10192
10240
  const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
10193
10241
  const hit = deps.router.match(matchPath);
10194
10242
  if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
10195
- else scanAndMount(state, emit, resolved.swapSelector);
10243
+ else scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
10196
10244
  state.started = true;
10197
10245
  },
10198
10246
  /**
@@ -10223,7 +10271,7 @@ function createSpaKernel(state, config, emit, deps) {
10223
10271
  * kernel.scan();
10224
10272
  */
10225
10273
  scan() {
10226
- scanAndMount(state, emit, resolved.swapSelector);
10274
+ scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
10227
10275
  },
10228
10276
  /**
10229
10277
  * Tear down router listeners, dispose all instances, reset boot state.
package/dist/index.d.cts CHANGED
@@ -288,7 +288,10 @@ interface RouteState<P extends string = string, D = unknown> {
288
288
  /** Loaded data type produced by `.load()` (widened only by `.load()`). */
289
289
  readonly data: D;
290
290
  }
291
- /** Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return. */
291
+ /**
292
+ * Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return,
293
+ * `meta` the route's `.meta()` bag.
294
+ */
292
295
  interface RouteContext<S extends RouteState> {
293
296
  /** Resolved path params. */
294
297
  readonly params: S["params"];
@@ -296,6 +299,13 @@ interface RouteContext<S extends RouteState> {
296
299
  readonly data: S["data"];
297
300
  /** Active locale for this render. */
298
301
  readonly locale: string;
302
+ /**
303
+ * The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). Available in `.render()` and
304
+ * `.head()`, identically at build and on the client — `meta` is compiled into the route and
305
+ * shipped in the client manifest, so a client-only route (dynamic, no `.generate()`, whose
306
+ * `.load()` data is `{}` on the client) can feed static per-route config into its render.
307
+ */
308
+ readonly meta: Record<string, unknown>;
299
309
  /**
300
310
  * Build a link to a named route by pattern substitution — the framework delivers
301
311
  * this on the context (same output as `app.router.toUrl`), so render/head build
@@ -376,21 +386,16 @@ interface GenerateContext {
376
386
  readonly has: (name: string) => boolean;
377
387
  }
378
388
  /**
379
- * Context handed to a route's `.layout()` wrapper: the render-time
380
- * {@link RouteContext} plus the route's `.meta()` bag, so persistent chrome (e.g. a
381
- * TopBar/TabNav) can read `locale` and `meta.activeTab`. Distinct from
382
- * `RouteContext` because the layout is the only handler that needs `meta`; keeping
383
- * it on its own type leaves `.render()`/`.head()` contexts unchanged.
389
+ * Context handed to a route's `.layout()` wrapper identical to {@link RouteContext}
390
+ * (which now carries `meta` for every handler). Retained as a named alias so existing
391
+ * `.layout((ctx, children) => …)` typings keep compiling.
384
392
  *
385
393
  * @remarks
386
394
  * The layout is applied in the SSG render path ONLY. On client (SPA) navigation the
387
- * chrome is persistent and the layout is intentionally NOT re-applied — only the
388
- * inner swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
395
+ * chrome is persistent and the layout is intentionally NOT re-applied — only the inner
396
+ * swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
389
397
  */
390
- interface LayoutContext<S extends RouteState> extends RouteContext<S> {
391
- /** The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). */
392
- readonly meta: Record<string, unknown>;
393
- }
398
+ type LayoutContext<S extends RouteState> = RouteContext<S>;
394
399
  /** Head metadata produced by a route's `.head()` handler. */
395
400
  interface HeadConfig$1 {
396
401
  /** Document title. */
@@ -945,12 +950,25 @@ interface ResolvedSpaConfig {
945
950
  /** Pre-registered components. */
946
951
  components: ComponentDef[];
947
952
  }
948
- /** Context handed to every component lifecycle hook. */
953
+ /**
954
+ * Context handed to every component lifecycle hook — the bound element + page data,
955
+ * plus the matched route's `params`/`meta`/`locale` and a link builder, so an island
956
+ * can read its route context (e.g. a `card` route's `ctx.meta.focus` + `ctx.params.id`)
957
+ * directly, without the page bridging it through `data-*` attributes.
958
+ */
949
959
  interface ComponentContext {
950
960
  /** The element the component instance is bound to. */
951
961
  el: Element;
952
962
  /** Page data extracted from the `script#__DATA__` payload. */
953
963
  data: PageData;
964
+ /** Resolved path params of the route matched for the current URL (empty if unmatched). */
965
+ readonly params: Record<string, string | undefined>;
966
+ /** The matched route's `.meta()` bag (empty if unmatched). */
967
+ readonly meta: Record<string, unknown>;
968
+ /** Active locale for the current route (empty string if unknown). */
969
+ readonly locale: string;
970
+ /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
971
+ readonly url: (name: string, params?: Record<string, string>) => string;
954
972
  }
955
973
  /** Lifecycle hooks a component may implement. */
956
974
  interface ComponentHooks {
package/dist/index.d.mts CHANGED
@@ -286,7 +286,10 @@ interface RouteState<P extends string = string, D = unknown> {
286
286
  /** Loaded data type produced by `.load()` (widened only by `.load()`). */
287
287
  readonly data: D;
288
288
  }
289
- /** Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return. */
289
+ /**
290
+ * Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return,
291
+ * `meta` the route's `.meta()` bag.
292
+ */
290
293
  interface RouteContext<S extends RouteState> {
291
294
  /** Resolved path params. */
292
295
  readonly params: S["params"];
@@ -294,6 +297,13 @@ interface RouteContext<S extends RouteState> {
294
297
  readonly data: S["data"];
295
298
  /** Active locale for this render. */
296
299
  readonly locale: string;
300
+ /**
301
+ * The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). Available in `.render()` and
302
+ * `.head()`, identically at build and on the client — `meta` is compiled into the route and
303
+ * shipped in the client manifest, so a client-only route (dynamic, no `.generate()`, whose
304
+ * `.load()` data is `{}` on the client) can feed static per-route config into its render.
305
+ */
306
+ readonly meta: Record<string, unknown>;
297
307
  /**
298
308
  * Build a link to a named route by pattern substitution — the framework delivers
299
309
  * this on the context (same output as `app.router.toUrl`), so render/head build
@@ -374,21 +384,16 @@ interface GenerateContext {
374
384
  readonly has: (name: string) => boolean;
375
385
  }
376
386
  /**
377
- * Context handed to a route's `.layout()` wrapper: the render-time
378
- * {@link RouteContext} plus the route's `.meta()` bag, so persistent chrome (e.g. a
379
- * TopBar/TabNav) can read `locale` and `meta.activeTab`. Distinct from
380
- * `RouteContext` because the layout is the only handler that needs `meta`; keeping
381
- * it on its own type leaves `.render()`/`.head()` contexts unchanged.
387
+ * Context handed to a route's `.layout()` wrapper identical to {@link RouteContext}
388
+ * (which now carries `meta` for every handler). Retained as a named alias so existing
389
+ * `.layout((ctx, children) => …)` typings keep compiling.
382
390
  *
383
391
  * @remarks
384
392
  * The layout is applied in the SSG render path ONLY. On client (SPA) navigation the
385
- * chrome is persistent and the layout is intentionally NOT re-applied — only the
386
- * inner swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
393
+ * chrome is persistent and the layout is intentionally NOT re-applied — only the inner
394
+ * swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
387
395
  */
388
- interface LayoutContext<S extends RouteState> extends RouteContext<S> {
389
- /** The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). */
390
- readonly meta: Record<string, unknown>;
391
- }
396
+ type LayoutContext<S extends RouteState> = RouteContext<S>;
392
397
  /** Head metadata produced by a route's `.head()` handler. */
393
398
  interface HeadConfig$1 {
394
399
  /** Document title. */
@@ -943,12 +948,25 @@ interface ResolvedSpaConfig {
943
948
  /** Pre-registered components. */
944
949
  components: ComponentDef[];
945
950
  }
946
- /** Context handed to every component lifecycle hook. */
951
+ /**
952
+ * Context handed to every component lifecycle hook — the bound element + page data,
953
+ * plus the matched route's `params`/`meta`/`locale` and a link builder, so an island
954
+ * can read its route context (e.g. a `card` route's `ctx.meta.focus` + `ctx.params.id`)
955
+ * directly, without the page bridging it through `data-*` attributes.
956
+ */
947
957
  interface ComponentContext {
948
958
  /** The element the component instance is bound to. */
949
959
  el: Element;
950
960
  /** Page data extracted from the `script#__DATA__` payload. */
951
961
  data: PageData;
962
+ /** Resolved path params of the route matched for the current URL (empty if unmatched). */
963
+ readonly params: Record<string, string | undefined>;
964
+ /** The matched route's `.meta()` bag (empty if unmatched). */
965
+ readonly meta: Record<string, unknown>;
966
+ /** Active locale for the current route (empty string if unknown). */
967
+ readonly locale: string;
968
+ /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
969
+ readonly url: (name: string, params?: Record<string, string>) => string;
952
970
  }
953
971
  /** Lifecycle hooks a component may implement. */
954
972
  interface ComponentHooks {
package/dist/index.mjs CHANGED
@@ -4725,8 +4725,8 @@ function composeHeadHtml(ctx, instance, url, routeContext, data) {
4725
4725
  * layout is NOT re-applied on navigation), then serialize with
4726
4726
  * preact-render-to-string. Returns `""` when the route has no `.render()`.
4727
4727
  *
4728
- * @param definition - The route definition (provides `.render()`/`.layout()`/`._meta`).
4729
- * @param routeContext - The route context (params/data/locale/url) — extended with `meta` for the layout.
4728
+ * @param definition - The route definition (provides `.render()`/`.layout()`).
4729
+ * @param routeContext - The route context (params/data/locale/meta/url); `meta` flows to the layout.
4730
4730
  * @returns The SSR-rendered body HTML, or `""` when the route has no `.render()`.
4731
4731
  * @example
4732
4732
  * ```ts
@@ -4736,11 +4736,7 @@ function composeHeadHtml(ctx, instance, url, routeContext, data) {
4736
4736
  function renderBody(definition, routeContext) {
4737
4737
  const vnode = definition._handlers.render?.(routeContext);
4738
4738
  if (!vnode) return "";
4739
- const layoutContext = {
4740
- ...routeContext,
4741
- meta: definition._meta
4742
- };
4743
- return renderToString(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
4739
+ return renderToString(definition._handlers.layout ? definition._handlers.layout(routeContext, vnode) : vnode);
4744
4740
  }
4745
4741
  /**
4746
4742
  * Hash a page's render inputs (its loaded data) for the render cache. `null` when the
@@ -4874,6 +4870,7 @@ async function renderInstance(ctx, instance, shell, reuse) {
4874
4870
  params,
4875
4871
  data,
4876
4872
  locale,
4873
+ meta: definition._meta,
4877
4874
  url: (routeName, routeParams = {}) => router.toUrl(routeName, routeParams)
4878
4875
  };
4879
4876
  const parts = {
@@ -9085,6 +9082,23 @@ const ERROR_PREFIX$2 = "[web]";
9085
9082
  /** The set of legal hook names, frozen for O(1) membership checks. */
9086
9083
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
9087
9084
  /**
9085
+ * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
9086
+ *
9087
+ * @returns An empty string.
9088
+ * @example
9089
+ * const href = noUrl();
9090
+ */
9091
+ function noUrl() {
9092
+ return "";
9093
+ }
9094
+ /** Empty route slice — used for mounts with no matched route (headless, tests, public `scan()`). */
9095
+ const EMPTY_ROUTE = {
9096
+ params: {},
9097
+ meta: {},
9098
+ locale: "",
9099
+ url: noUrl
9100
+ };
9101
+ /**
9088
9102
  * Validate a single hook entry: its key must be a known hook name and its value
9089
9103
  * must be a function. Throws fail-fast on the first violation.
9090
9104
  *
@@ -9172,18 +9186,25 @@ function runHook(instance, hook, ctx) {
9172
9186
  instance.def.hooks[hook]?.(ctx);
9173
9187
  }
9174
9188
  /**
9175
- * Builds the component context handed to a hook (the bound element + page data).
9189
+ * Builds the component context handed to a hook: the bound element + page data, merged
9190
+ * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
9191
+ * when no route is supplied (headless, tests, public `scan()`).
9176
9192
  *
9177
9193
  * @param element - The element the instance is bound to.
9178
9194
  * @param data - The current page data payload.
9195
+ * @param route - The matched-route slice for the current URL.
9179
9196
  * @returns The hook context.
9180
9197
  * @example
9181
- * const ctx = makeContext(element, data);
9198
+ * const ctx = makeContext(element, data, route);
9182
9199
  */
9183
- function makeContext(element, data) {
9200
+ function makeContext(element, data, route = EMPTY_ROUTE) {
9184
9201
  return {
9185
9202
  el: element,
9186
- data
9203
+ data,
9204
+ params: route.params,
9205
+ meta: route.meta,
9206
+ locale: route.locale,
9207
+ url: route.url
9187
9208
  };
9188
9209
  }
9189
9210
  /**
@@ -9197,17 +9218,18 @@ function makeContext(element, data) {
9197
9218
  * @param swapArea - The swap-region element, or null when none was found.
9198
9219
  * @param data - The current page data payload.
9199
9220
  * @param element - The candidate element carrying a `data-component` attribute.
9221
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
9200
9222
  * @example
9201
- * mountElement(state, emit, swapArea, data, element);
9223
+ * mountElement(state, emit, swapArea, data, element, route);
9202
9224
  */
9203
- function mountElement(state, emit, swapArea, data, element) {
9225
+ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
9204
9226
  if (state.instances.has(element)) return;
9205
9227
  const name = element.dataset.component;
9206
9228
  if (!name) return;
9207
9229
  const definition = state.registeredComponents.get(name);
9208
9230
  if (!definition) return;
9209
9231
  const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
9210
- const ctx = makeContext(element, data);
9232
+ const ctx = makeContext(element, data, route);
9211
9233
  runHook(instance, "onCreate", ctx);
9212
9234
  runHook(instance, "onMount", ctx);
9213
9235
  state.instances.set(element, instance);
@@ -9225,14 +9247,15 @@ function mountElement(state, emit, swapArea, data, element) {
9225
9247
  * @param state - The plugin state (registeredComponents + instances).
9226
9248
  * @param emit - The event emitter for spa:component-mount.
9227
9249
  * @param swapSelector - CSS selector bounding page-specific components.
9250
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
9228
9251
  * @example
9229
- * scanAndMount(state, emit, "main > section");
9252
+ * scanAndMount(state, emit, "main > section", route);
9230
9253
  */
9231
- function scanAndMount(state, emit, swapSelector) {
9254
+ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
9232
9255
  if (typeof document === "undefined") return;
9233
9256
  const swapArea = document.querySelector(swapSelector);
9234
9257
  const data = extractPageData(document);
9235
- for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
9258
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
9236
9259
  }
9237
9260
  /**
9238
9261
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -9298,12 +9321,13 @@ function notifyNavStart(state) {
9298
9321
  * instances were already destroyed and re-created by the swap).
9299
9322
  *
9300
9323
  * @param state - The plugin state holding live instances.
9324
+ * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
9301
9325
  * @example
9302
- * notifyNavEnd(state);
9326
+ * notifyNavEnd(state, route);
9303
9327
  */
9304
- function notifyNavEnd(state) {
9328
+ function notifyNavEnd(state, route = EMPTY_ROUTE) {
9305
9329
  const data = typeof document === "undefined" ? {} : extractPageData(document);
9306
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data));
9330
+ for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
9307
9331
  }
9308
9332
  //#endregion
9309
9333
  //#region src/plugins/spa/head.ts
@@ -9946,6 +9970,26 @@ function createSpaKernel(state, config, emit, deps) {
9946
9970
  });
9947
9971
  };
9948
9972
  /**
9973
+ * Build the matched-route slice (params/meta/locale/url) for the component context at `path`,
9974
+ * so islands read their route's params/meta directly. An unmatched path yields an empty slice.
9975
+ *
9976
+ * @param path - The URL (pathname + search) to match.
9977
+ * @returns The route slice for the matched route.
9978
+ * @example
9979
+ * scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(pathname));
9980
+ */
9981
+ const componentRouteContext = (path) => {
9982
+ const matchPath = path.split("?")[0] ?? path;
9983
+ const hit = deps.router.match(matchPath);
9984
+ const locale = hit?.params.lang ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
9985
+ return {
9986
+ params: hit?.params ?? {},
9987
+ meta: hit?.route._meta ?? {},
9988
+ locale,
9989
+ url: (name, params = {}) => deps.router.toUrl(name, params)
9990
+ };
9991
+ };
9992
+ /**
9949
9993
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
9950
9994
  * When the region cannot be swapped (either document lacks the swap selector)
9951
9995
  * the SPA nav cannot complete — the head is already synced and the islands torn
@@ -9963,8 +10007,9 @@ function createSpaKernel(state, config, emit, deps) {
9963
10007
  syncHead(deps.head, doc);
9964
10008
  unmountPageSpecific(state, emit);
9965
10009
  if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
9966
- scanAndMount(state, emit, resolved.swapSelector);
9967
- notifyNavEnd(state);
10010
+ const routeSlice = componentRouteContext(pathname);
10011
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10012
+ notifyNavEnd(state, routeSlice);
9968
10013
  }, applyPendingScroll)) {
9969
10014
  handleError();
9970
10015
  location.href = pathname;
@@ -10033,6 +10078,7 @@ function createSpaKernel(state, config, emit, deps) {
10033
10078
  params: hit.params,
10034
10079
  data,
10035
10080
  locale,
10081
+ meta: hit.route._meta,
10036
10082
  url: (routeName, routeParams = {}) => deps.router.toUrl(routeName, routeParams)
10037
10083
  };
10038
10084
  const vnode = hit.route._handlers.render(routeContext);
@@ -10064,6 +10110,7 @@ function createSpaKernel(state, config, emit, deps) {
10064
10110
  if (signal?.aborted) return;
10065
10111
  syncDataHead(deps.head, route, routeContext);
10066
10112
  unmountPageSpecific(state, emit);
10113
+ const routeSlice = componentRouteContext(pathname);
10067
10114
  /**
10068
10115
  * Render the VNode into the region and re-mount its islands in one paint — the
10069
10116
  * swap body handed to `runSwap` (optionally wrapped in a View Transition).
@@ -10075,8 +10122,8 @@ function createSpaKernel(state, config, emit, deps) {
10075
10122
  */
10076
10123
  const renderAndMount = () => {
10077
10124
  renderVNode(vnode, region);
10078
- scanAndMount(state, emit, resolved.swapSelector);
10079
- notifyNavEnd(state);
10125
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10126
+ notifyNavEnd(state, routeSlice);
10080
10127
  };
10081
10128
  runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10082
10129
  state.currentUrl = pathname;
@@ -10121,15 +10168,16 @@ function createSpaKernel(state, config, emit, deps) {
10121
10168
  * await bootRender("/b/abc123");
10122
10169
  */
10123
10170
  const bootRender = async (pathname) => {
10171
+ const routeSlice = componentRouteContext(pathname);
10124
10172
  const resolvedRender = await resolveDataRender(pathname);
10125
10173
  if (resolvedRender === false) {
10126
- scanAndMount(state, emit, resolved.swapSelector);
10174
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10127
10175
  return;
10128
10176
  }
10129
10177
  const { vnode, region } = resolvedRender;
10130
10178
  const { renderVNode } = await import("./render-BNe0s7fr.mjs");
10131
10179
  renderVNode(vnode, region);
10132
- scanAndMount(state, emit, resolved.swapSelector);
10180
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10133
10181
  };
10134
10182
  /**
10135
10183
  * Unified navigation: try the client DATA path first (only when the `data`
@@ -10179,7 +10227,7 @@ function createSpaKernel(state, config, emit, deps) {
10179
10227
  const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
10180
10228
  const hit = deps.router.match(matchPath);
10181
10229
  if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
10182
- else scanAndMount(state, emit, resolved.swapSelector);
10230
+ else scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
10183
10231
  state.started = true;
10184
10232
  },
10185
10233
  /**
@@ -10210,7 +10258,7 @@ function createSpaKernel(state, config, emit, deps) {
10210
10258
  * kernel.scan();
10211
10259
  */
10212
10260
  scan() {
10213
- scanAndMount(state, emit, resolved.swapSelector);
10261
+ scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
10214
10262
  },
10215
10263
  /**
10216
10264
  * Tear down router listeners, dispose all instances, reset boot state.
package/package.json CHANGED
@@ -126,5 +126,5 @@
126
126
  "test:build-e2e": "bun test src/plugins/build/__tests__/e2e/",
127
127
  "test:coverage": "vitest run --project unit --project integration --coverage"
128
128
  },
129
- "version": "1.13.2"
129
+ "version": "1.14.0"
130
130
  }