@moku-labs/web 1.13.1 → 1.13.2

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/dist/browser.mjs CHANGED
@@ -471,6 +471,38 @@ function dynamicSegmentCount(pattern) {
471
471
  return count;
472
472
  }
473
473
  /**
474
+ * Whether a route is rendered ENTIRELY on the client in `spa` mode: a dynamic route
475
+ * (≥1 non-lang param) that declares no build-time `.generate()` enumerator, so its
476
+ * concrete param paths are unknown until runtime.
477
+ *
478
+ * The build SKIPS such a route — emitting a static page for it would write one
479
+ * param-less shell whose path (`/b/{id}`) matches no file (a 404) and carries no
480
+ * param for the islands to read. Instead the SPA client-renders it from the URL on
481
+ * boot and on navigation. Build and client share this ONE predicate (the same way
482
+ * `dynamicSegmentCount`/`bySpecificity` are shared) so the two sides can never
483
+ * disagree about which routes are pre-rendered vs. client-only.
484
+ *
485
+ * Static routes (`/`) and dynamic routes WITH `.generate()` are pre-rendered as
486
+ * usual and so are NOT client-only. In `ssg`/`hybrid` mode nothing is client-only
487
+ * (the build pre-renders every route), so this is always `false` outside `spa`.
488
+ *
489
+ * @param mode - The global render mode (`router.mode()`).
490
+ * @param route - The route to test (only its `pattern` + `.generate()` presence are read).
491
+ * @param route.pattern - The route's URL pattern string.
492
+ * @param route._handlers - The route's handler bag.
493
+ * @param route._handlers.generate - The build-only static-paths enumerator, if any (presence only).
494
+ * @returns `true` when the route is client-only (spa mode, dynamic, no `.generate()`).
495
+ * @example
496
+ * ```ts
497
+ * isClientOnlyRoute("spa", { pattern: "/b/{id}", _handlers: {} }); // true
498
+ * isClientOnlyRoute("spa", { pattern: "/", _handlers: {} }); // false (static)
499
+ * isClientOnlyRoute("hybrid", { pattern: "/b/{id}", _handlers: {} }); // false (not spa)
500
+ * ```
501
+ */
502
+ function isClientOnlyRoute(mode, route) {
503
+ return mode === "spa" && route._handlers.generate === void 0 && dynamicSegmentCount(route.pattern) > 0;
504
+ }
505
+ /**
474
506
  * Comparator that orders two routes most-specific-first (fewest dynamic segments
475
507
  * first). Equal specificity yields `0` so a stable sort preserves declaration
476
508
  * order — the exact ordering the compiled matcher table uses, guaranteeing
@@ -3374,12 +3406,16 @@ function createSpaKernel(state, config, emit, deps) {
3374
3406
  * const resolved = await resolveDataRender("/en/world/");
3375
3407
  */
3376
3408
  const resolveDataRender = async (pathname) => {
3377
- if (!deps.dataAt) return false;
3378
3409
  const matchPath = pathname.split("?")[0] ?? pathname;
3379
3410
  const hit = deps.router.match(matchPath);
3380
3411
  if (!hit?.route._handlers.render) return false;
3381
- const data = await deps.dataAt(pathname);
3382
- if (data === null) return false;
3412
+ let data = {};
3413
+ if (!isClientOnlyRoute(deps.router.mode(), hit.route)) {
3414
+ if (!deps.dataAt) return false;
3415
+ const persisted = await deps.dataAt(pathname);
3416
+ if (persisted === null) return false;
3417
+ data = persisted;
3418
+ }
3383
3419
  const locale = hit.params.lang ?? document.documentElement.lang ?? "";
3384
3420
  const routeContext = {
3385
3421
  params: hit.params,
@@ -3461,6 +3497,29 @@ function createSpaKernel(state, config, emit, deps) {
3461
3497
  }
3462
3498
  };
3463
3499
  /**
3500
+ * Initial-load render for a spa client-only route (dynamic, no `.generate()`): the build emitted
3501
+ * no static HTML for it, so the host served a fallback shell. Client-render the matched route into
3502
+ * the swap region from the URL, then mount its islands — the deep-link / refresh paint. Unlike a
3503
+ * navigation there is nothing to unmount and no `spa:navigated` to emit. If the route cannot be
3504
+ * resolved (defensive — a matched client-only route always resolves), fall back to mounting the
3505
+ * served body so boot still wires up whatever islands the shell does carry.
3506
+ *
3507
+ * @param pathname - The current document path (pathname + search).
3508
+ * @example
3509
+ * await bootRender("/b/abc123");
3510
+ */
3511
+ const bootRender = async (pathname) => {
3512
+ const resolvedRender = await resolveDataRender(pathname);
3513
+ if (resolvedRender === false) {
3514
+ scanAndMount(state, emit, resolved.swapSelector);
3515
+ return;
3516
+ }
3517
+ const { vnode, region } = resolvedRender;
3518
+ const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3519
+ renderVNode(vnode, region);
3520
+ scanAndMount(state, emit, resolved.swapSelector);
3521
+ };
3522
+ /**
3464
3523
  * Unified navigation: try the client DATA path first (only when the `data`
3465
3524
  * plugin is composed), then fall back to HTML-over-fetch (which itself falls
3466
3525
  * back to a full `location.href` reload). Injected into the router so every
@@ -3505,7 +3564,10 @@ function createSpaKernel(state, config, emit, deps) {
3505
3564
  progress = createProgressBar(resolved.progressBar);
3506
3565
  state.currentUrl = currentLocationUrl();
3507
3566
  state.destroyRouter = attachRouter(handlers, navigate);
3508
- scanAndMount(state, emit, resolved.swapSelector);
3567
+ const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
3568
+ const hit = deps.router.match(matchPath);
3569
+ if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
3570
+ else scanAndMount(state, emit, resolved.swapSelector);
3509
3571
  state.started = true;
3510
3572
  },
3511
3573
  /**
package/dist/index.cjs CHANGED
@@ -1084,6 +1084,38 @@ function dynamicSegmentCount(pattern) {
1084
1084
  return count;
1085
1085
  }
1086
1086
  /**
1087
+ * Whether a route is rendered ENTIRELY on the client in `spa` mode: a dynamic route
1088
+ * (≥1 non-lang param) that declares no build-time `.generate()` enumerator, so its
1089
+ * concrete param paths are unknown until runtime.
1090
+ *
1091
+ * The build SKIPS such a route — emitting a static page for it would write one
1092
+ * param-less shell whose path (`/b/{id}`) matches no file (a 404) and carries no
1093
+ * param for the islands to read. Instead the SPA client-renders it from the URL on
1094
+ * boot and on navigation. Build and client share this ONE predicate (the same way
1095
+ * `dynamicSegmentCount`/`bySpecificity` are shared) so the two sides can never
1096
+ * disagree about which routes are pre-rendered vs. client-only.
1097
+ *
1098
+ * Static routes (`/`) and dynamic routes WITH `.generate()` are pre-rendered as
1099
+ * usual and so are NOT client-only. In `ssg`/`hybrid` mode nothing is client-only
1100
+ * (the build pre-renders every route), so this is always `false` outside `spa`.
1101
+ *
1102
+ * @param mode - The global render mode (`router.mode()`).
1103
+ * @param route - The route to test (only its `pattern` + `.generate()` presence are read).
1104
+ * @param route.pattern - The route's URL pattern string.
1105
+ * @param route._handlers - The route's handler bag.
1106
+ * @param route._handlers.generate - The build-only static-paths enumerator, if any (presence only).
1107
+ * @returns `true` when the route is client-only (spa mode, dynamic, no `.generate()`).
1108
+ * @example
1109
+ * ```ts
1110
+ * isClientOnlyRoute("spa", { pattern: "/b/{id}", _handlers: {} }); // true
1111
+ * isClientOnlyRoute("spa", { pattern: "/", _handlers: {} }); // false (static)
1112
+ * isClientOnlyRoute("hybrid", { pattern: "/b/{id}", _handlers: {} }); // false (not spa)
1113
+ * ```
1114
+ */
1115
+ function isClientOnlyRoute(mode, route) {
1116
+ return mode === "spa" && route._handlers.generate === void 0 && dynamicSegmentCount(route.pattern) > 0;
1117
+ }
1118
+ /**
1087
1119
  * Comparator that orders two routes most-specific-first (fewest dynamic segments
1088
1120
  * first). Equal specificity yields `0` so a stable sort preserves declaration
1089
1121
  * order — the exact ordering the compiled matcher table uses, guaranteeing
@@ -4528,16 +4560,23 @@ function resolveEntry(byPattern, definition) {
4528
4560
  * generate context is the spec `{ locale, require, has }`, so a `.generate()` handler
4529
4561
  * pulls sibling APIs the spec way.
4530
4562
  *
4563
+ * In `spa` mode a client-only route (dynamic, no `.generate()`) is SKIPPED entirely
4564
+ * (`[]`) — it is rendered on the client from the URL, so emitting a static param-less
4565
+ * shell here would only write a file at the wrong path (a 404 for any real param path)
4566
+ * carrying no param. See {@link isClientOnlyRoute}.
4567
+ *
4531
4568
  * @param definition - The route definition from the manifest.
4532
4569
  * @param locale - The active locale to generate param sets for.
4570
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4533
4571
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4534
- * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`).
4572
+ * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`; `[]` when client-only).
4535
4573
  * @example
4536
4574
  * ```ts
4537
- * const paramSets = await generateParamSets(def, "en", ctx);
4575
+ * const paramSets = await generateParamSets(def, "en", "hybrid", ctx);
4538
4576
  * ```
4539
4577
  */
4540
- async function generateParameterSets(definition, locale, ctx) {
4578
+ async function generateParameterSets(definition, locale, mode, ctx) {
4579
+ if (isClientOnlyRoute(mode, definition)) return [];
4541
4580
  const generateContext = {
4542
4581
  locale,
4543
4582
  require: ctx.require,
@@ -4561,21 +4600,22 @@ async function generateParameterSets(definition, locale, ctx) {
4561
4600
  * @param locales - Active locale codes from i18n.
4562
4601
  * @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
4563
4602
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4603
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4564
4604
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4565
4605
  * @returns The flattened, file-deduplicated list of page instances for this route.
4566
4606
  * @example
4567
4607
  * ```ts
4568
- * await expandRoute(def, ["en"], "en", byPattern, ctx);
4608
+ * await expandRoute(def, ["en"], "en", byPattern, "hybrid", ctx);
4569
4609
  * ```
4570
4610
  */
4571
- async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
4611
+ async function expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx) {
4572
4612
  const entry = resolveEntry(byPattern, definition);
4573
4613
  const { name } = entry;
4574
4614
  const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
4575
4615
  const instances = [];
4576
4616
  const claimedFiles = /* @__PURE__ */ new Set();
4577
4617
  for (const locale of orderedLocales) {
4578
- const parameterSets = await generateParameterSets(definition, locale, ctx);
4618
+ const parameterSets = await generateParameterSets(definition, locale, mode, ctx);
4579
4619
  for (const raw of parameterSets) {
4580
4620
  const params = raw ?? {};
4581
4621
  const file = entry.toFile(params);
@@ -4909,15 +4949,16 @@ async function prepareShell(ctx) {
4909
4949
  * @param locales - Active locale codes from i18n.
4910
4950
  * @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
4911
4951
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4952
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4912
4953
  * @param ctx - Plugin context (provides `require`/`has` for generate contexts).
4913
4954
  * @returns The flattened list of page instances to render.
4914
4955
  * @example
4915
4956
  * ```ts
4916
- * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
4957
+ * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, "hybrid", ctx);
4917
4958
  * ```
4918
4959
  */
4919
- async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
4920
- return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
4960
+ async function expandAllInstances(manifest, locales, defaultLocale, byPattern, mode, ctx) {
4961
+ return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx)))).flat();
4921
4962
  }
4922
4963
  /**
4923
4964
  * Persist per-page client-data sidecars when the app opts into client navigation
@@ -5027,14 +5068,15 @@ async function renderInBatches(items, batchSize, worker) {
5027
5068
  async function renderPages(ctx, options) {
5028
5069
  const reuse = options?.reuse === true;
5029
5070
  const router = ctx.require(routerPlugin);
5071
+ const mode = router.mode();
5030
5072
  const manifest = router.manifest();
5031
5073
  ctx.state.manifest = [...manifest];
5032
5074
  const locales = ctx.require(i18nPlugin).locales();
5033
5075
  const byPattern = makeEntryMap(router);
5034
5076
  if (!reuse) ctx.state.renderCache.clear();
5035
5077
  const shell = await prepareShell(ctx);
5036
- const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5037
- await writeDataSidecars(ctx, rendered, router.mode());
5078
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, mode, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5079
+ await writeDataSidecars(ctx, rendered, mode);
5038
5080
  ctx.log.debug("build:pages", { count: rendered.length });
5039
5081
  return {
5040
5082
  pageCount: rendered.length,
@@ -9989,12 +10031,16 @@ function createSpaKernel(state, config, emit, deps) {
9989
10031
  * const resolved = await resolveDataRender("/en/world/");
9990
10032
  */
9991
10033
  const resolveDataRender = async (pathname) => {
9992
- if (!deps.dataAt) return false;
9993
10034
  const matchPath = pathname.split("?")[0] ?? pathname;
9994
10035
  const hit = deps.router.match(matchPath);
9995
10036
  if (!hit?.route._handlers.render) return false;
9996
- const data = await deps.dataAt(pathname);
9997
- if (data === null) return false;
10037
+ let data = {};
10038
+ if (!isClientOnlyRoute(deps.router.mode(), hit.route)) {
10039
+ if (!deps.dataAt) return false;
10040
+ const persisted = await deps.dataAt(pathname);
10041
+ if (persisted === null) return false;
10042
+ data = persisted;
10043
+ }
9998
10044
  const locale = hit.params.lang ?? document.documentElement.lang ?? "";
9999
10045
  const routeContext = {
10000
10046
  params: hit.params,
@@ -10076,6 +10122,29 @@ function createSpaKernel(state, config, emit, deps) {
10076
10122
  }
10077
10123
  };
10078
10124
  /**
10125
+ * Initial-load render for a spa client-only route (dynamic, no `.generate()`): the build emitted
10126
+ * no static HTML for it, so the host served a fallback shell. Client-render the matched route into
10127
+ * the swap region from the URL, then mount its islands — the deep-link / refresh paint. Unlike a
10128
+ * navigation there is nothing to unmount and no `spa:navigated` to emit. If the route cannot be
10129
+ * resolved (defensive — a matched client-only route always resolves), fall back to mounting the
10130
+ * served body so boot still wires up whatever islands the shell does carry.
10131
+ *
10132
+ * @param pathname - The current document path (pathname + search).
10133
+ * @example
10134
+ * await bootRender("/b/abc123");
10135
+ */
10136
+ const bootRender = async (pathname) => {
10137
+ const resolvedRender = await resolveDataRender(pathname);
10138
+ if (resolvedRender === false) {
10139
+ scanAndMount(state, emit, resolved.swapSelector);
10140
+ return;
10141
+ }
10142
+ const { vnode, region } = resolvedRender;
10143
+ const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
10144
+ renderVNode(vnode, region);
10145
+ scanAndMount(state, emit, resolved.swapSelector);
10146
+ };
10147
+ /**
10079
10148
  * Unified navigation: try the client DATA path first (only when the `data`
10080
10149
  * plugin is composed), then fall back to HTML-over-fetch (which itself falls
10081
10150
  * back to a full `location.href` reload). Injected into the router so every
@@ -10120,7 +10189,10 @@ function createSpaKernel(state, config, emit, deps) {
10120
10189
  progress = createProgressBar(resolved.progressBar);
10121
10190
  state.currentUrl = currentLocationUrl();
10122
10191
  state.destroyRouter = attachRouter(handlers, navigate);
10123
- scanAndMount(state, emit, resolved.swapSelector);
10192
+ const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
10193
+ const hit = deps.router.match(matchPath);
10194
+ if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
10195
+ else scanAndMount(state, emit, resolved.swapSelector);
10124
10196
  state.started = true;
10125
10197
  },
10126
10198
  /**
package/dist/index.mjs CHANGED
@@ -1071,6 +1071,38 @@ function dynamicSegmentCount(pattern) {
1071
1071
  return count;
1072
1072
  }
1073
1073
  /**
1074
+ * Whether a route is rendered ENTIRELY on the client in `spa` mode: a dynamic route
1075
+ * (≥1 non-lang param) that declares no build-time `.generate()` enumerator, so its
1076
+ * concrete param paths are unknown until runtime.
1077
+ *
1078
+ * The build SKIPS such a route — emitting a static page for it would write one
1079
+ * param-less shell whose path (`/b/{id}`) matches no file (a 404) and carries no
1080
+ * param for the islands to read. Instead the SPA client-renders it from the URL on
1081
+ * boot and on navigation. Build and client share this ONE predicate (the same way
1082
+ * `dynamicSegmentCount`/`bySpecificity` are shared) so the two sides can never
1083
+ * disagree about which routes are pre-rendered vs. client-only.
1084
+ *
1085
+ * Static routes (`/`) and dynamic routes WITH `.generate()` are pre-rendered as
1086
+ * usual and so are NOT client-only. In `ssg`/`hybrid` mode nothing is client-only
1087
+ * (the build pre-renders every route), so this is always `false` outside `spa`.
1088
+ *
1089
+ * @param mode - The global render mode (`router.mode()`).
1090
+ * @param route - The route to test (only its `pattern` + `.generate()` presence are read).
1091
+ * @param route.pattern - The route's URL pattern string.
1092
+ * @param route._handlers - The route's handler bag.
1093
+ * @param route._handlers.generate - The build-only static-paths enumerator, if any (presence only).
1094
+ * @returns `true` when the route is client-only (spa mode, dynamic, no `.generate()`).
1095
+ * @example
1096
+ * ```ts
1097
+ * isClientOnlyRoute("spa", { pattern: "/b/{id}", _handlers: {} }); // true
1098
+ * isClientOnlyRoute("spa", { pattern: "/", _handlers: {} }); // false (static)
1099
+ * isClientOnlyRoute("hybrid", { pattern: "/b/{id}", _handlers: {} }); // false (not spa)
1100
+ * ```
1101
+ */
1102
+ function isClientOnlyRoute(mode, route) {
1103
+ return mode === "spa" && route._handlers.generate === void 0 && dynamicSegmentCount(route.pattern) > 0;
1104
+ }
1105
+ /**
1074
1106
  * Comparator that orders two routes most-specific-first (fewest dynamic segments
1075
1107
  * first). Equal specificity yields `0` so a stable sort preserves declaration
1076
1108
  * order — the exact ordering the compiled matcher table uses, guaranteeing
@@ -4515,16 +4547,23 @@ function resolveEntry(byPattern, definition) {
4515
4547
  * generate context is the spec `{ locale, require, has }`, so a `.generate()` handler
4516
4548
  * pulls sibling APIs the spec way.
4517
4549
  *
4550
+ * In `spa` mode a client-only route (dynamic, no `.generate()`) is SKIPPED entirely
4551
+ * (`[]`) — it is rendered on the client from the URL, so emitting a static param-less
4552
+ * shell here would only write a file at the wrong path (a 404 for any real param path)
4553
+ * carrying no param. See {@link isClientOnlyRoute}.
4554
+ *
4518
4555
  * @param definition - The route definition from the manifest.
4519
4556
  * @param locale - The active locale to generate param sets for.
4557
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4520
4558
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4521
- * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`).
4559
+ * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`; `[]` when client-only).
4522
4560
  * @example
4523
4561
  * ```ts
4524
- * const paramSets = await generateParamSets(def, "en", ctx);
4562
+ * const paramSets = await generateParamSets(def, "en", "hybrid", ctx);
4525
4563
  * ```
4526
4564
  */
4527
- async function generateParameterSets(definition, locale, ctx) {
4565
+ async function generateParameterSets(definition, locale, mode, ctx) {
4566
+ if (isClientOnlyRoute(mode, definition)) return [];
4528
4567
  const generateContext = {
4529
4568
  locale,
4530
4569
  require: ctx.require,
@@ -4548,21 +4587,22 @@ async function generateParameterSets(definition, locale, ctx) {
4548
4587
  * @param locales - Active locale codes from i18n.
4549
4588
  * @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
4550
4589
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4590
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4551
4591
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4552
4592
  * @returns The flattened, file-deduplicated list of page instances for this route.
4553
4593
  * @example
4554
4594
  * ```ts
4555
- * await expandRoute(def, ["en"], "en", byPattern, ctx);
4595
+ * await expandRoute(def, ["en"], "en", byPattern, "hybrid", ctx);
4556
4596
  * ```
4557
4597
  */
4558
- async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
4598
+ async function expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx) {
4559
4599
  const entry = resolveEntry(byPattern, definition);
4560
4600
  const { name } = entry;
4561
4601
  const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
4562
4602
  const instances = [];
4563
4603
  const claimedFiles = /* @__PURE__ */ new Set();
4564
4604
  for (const locale of orderedLocales) {
4565
- const parameterSets = await generateParameterSets(definition, locale, ctx);
4605
+ const parameterSets = await generateParameterSets(definition, locale, mode, ctx);
4566
4606
  for (const raw of parameterSets) {
4567
4607
  const params = raw ?? {};
4568
4608
  const file = entry.toFile(params);
@@ -4896,15 +4936,16 @@ async function prepareShell(ctx) {
4896
4936
  * @param locales - Active locale codes from i18n.
4897
4937
  * @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
4898
4938
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4939
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4899
4940
  * @param ctx - Plugin context (provides `require`/`has` for generate contexts).
4900
4941
  * @returns The flattened list of page instances to render.
4901
4942
  * @example
4902
4943
  * ```ts
4903
- * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
4944
+ * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, "hybrid", ctx);
4904
4945
  * ```
4905
4946
  */
4906
- async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
4907
- return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
4947
+ async function expandAllInstances(manifest, locales, defaultLocale, byPattern, mode, ctx) {
4948
+ return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx)))).flat();
4908
4949
  }
4909
4950
  /**
4910
4951
  * Persist per-page client-data sidecars when the app opts into client navigation
@@ -5014,14 +5055,15 @@ async function renderInBatches(items, batchSize, worker) {
5014
5055
  async function renderPages(ctx, options) {
5015
5056
  const reuse = options?.reuse === true;
5016
5057
  const router = ctx.require(routerPlugin);
5058
+ const mode = router.mode();
5017
5059
  const manifest = router.manifest();
5018
5060
  ctx.state.manifest = [...manifest];
5019
5061
  const locales = ctx.require(i18nPlugin).locales();
5020
5062
  const byPattern = makeEntryMap(router);
5021
5063
  if (!reuse) ctx.state.renderCache.clear();
5022
5064
  const shell = await prepareShell(ctx);
5023
- const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5024
- await writeDataSidecars(ctx, rendered, router.mode());
5065
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, mode, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5066
+ await writeDataSidecars(ctx, rendered, mode);
5025
5067
  ctx.log.debug("build:pages", { count: rendered.length });
5026
5068
  return {
5027
5069
  pageCount: rendered.length,
@@ -9976,12 +10018,16 @@ function createSpaKernel(state, config, emit, deps) {
9976
10018
  * const resolved = await resolveDataRender("/en/world/");
9977
10019
  */
9978
10020
  const resolveDataRender = async (pathname) => {
9979
- if (!deps.dataAt) return false;
9980
10021
  const matchPath = pathname.split("?")[0] ?? pathname;
9981
10022
  const hit = deps.router.match(matchPath);
9982
10023
  if (!hit?.route._handlers.render) return false;
9983
- const data = await deps.dataAt(pathname);
9984
- if (data === null) return false;
10024
+ let data = {};
10025
+ if (!isClientOnlyRoute(deps.router.mode(), hit.route)) {
10026
+ if (!deps.dataAt) return false;
10027
+ const persisted = await deps.dataAt(pathname);
10028
+ if (persisted === null) return false;
10029
+ data = persisted;
10030
+ }
9985
10031
  const locale = hit.params.lang ?? document.documentElement.lang ?? "";
9986
10032
  const routeContext = {
9987
10033
  params: hit.params,
@@ -10063,6 +10109,29 @@ function createSpaKernel(state, config, emit, deps) {
10063
10109
  }
10064
10110
  };
10065
10111
  /**
10112
+ * Initial-load render for a spa client-only route (dynamic, no `.generate()`): the build emitted
10113
+ * no static HTML for it, so the host served a fallback shell. Client-render the matched route into
10114
+ * the swap region from the URL, then mount its islands — the deep-link / refresh paint. Unlike a
10115
+ * navigation there is nothing to unmount and no `spa:navigated` to emit. If the route cannot be
10116
+ * resolved (defensive — a matched client-only route always resolves), fall back to mounting the
10117
+ * served body so boot still wires up whatever islands the shell does carry.
10118
+ *
10119
+ * @param pathname - The current document path (pathname + search).
10120
+ * @example
10121
+ * await bootRender("/b/abc123");
10122
+ */
10123
+ const bootRender = async (pathname) => {
10124
+ const resolvedRender = await resolveDataRender(pathname);
10125
+ if (resolvedRender === false) {
10126
+ scanAndMount(state, emit, resolved.swapSelector);
10127
+ return;
10128
+ }
10129
+ const { vnode, region } = resolvedRender;
10130
+ const { renderVNode } = await import("./render-BNe0s7fr.mjs");
10131
+ renderVNode(vnode, region);
10132
+ scanAndMount(state, emit, resolved.swapSelector);
10133
+ };
10134
+ /**
10066
10135
  * Unified navigation: try the client DATA path first (only when the `data`
10067
10136
  * plugin is composed), then fall back to HTML-over-fetch (which itself falls
10068
10137
  * back to a full `location.href` reload). Injected into the router so every
@@ -10107,7 +10176,10 @@ function createSpaKernel(state, config, emit, deps) {
10107
10176
  progress = createProgressBar(resolved.progressBar);
10108
10177
  state.currentUrl = currentLocationUrl();
10109
10178
  state.destroyRouter = attachRouter(handlers, navigate);
10110
- scanAndMount(state, emit, resolved.swapSelector);
10179
+ const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
10180
+ const hit = deps.router.match(matchPath);
10181
+ if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
10182
+ else scanAndMount(state, emit, resolved.swapSelector);
10111
10183
  state.started = true;
10112
10184
  },
10113
10185
  /**
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.1"
129
+ "version": "1.13.2"
130
130
  }