bosia 0.4.6 → 0.5.1

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/src/core/html.ts CHANGED
@@ -37,6 +37,22 @@ export function safeJsonStringify(data: unknown): string {
37
37
  return json.replace(/[<>&\u2028\u2029]/g, (c) => map[c]);
38
38
  }
39
39
 
40
+ const SCRIPT_HAZARD_RE = /<(\/script|!--)/gi;
41
+
42
+ /** Escapes JSON for safe embedding inside <script type="application/json"> blocks.
43
+ * Blocks premature </script> and <!-- (HTML script-data escape state) without
44
+ * the JS-context overhead of safeJsonStringify. */
45
+ export function safeJsonForScript(data: unknown): string {
46
+ let json: string;
47
+ try {
48
+ json = JSON.stringify(data);
49
+ } catch {
50
+ console.error("safeJsonForScript: failed to serialize data (circular reference?)");
51
+ json = "null";
52
+ }
53
+ return json.replace(SCRIPT_HAZARD_RE, "\\u003c$1");
54
+ }
55
+
40
56
  // ─── Public Env Injection ─────────────────────────────────
41
57
 
42
58
  /**
@@ -78,6 +94,8 @@ export function buildHtml(
78
94
  lang?: string,
79
95
  ssr = true,
80
96
  nonce?: string,
97
+ pageDeps: any = null,
98
+ layoutDeps: any[] | null = null,
81
99
  ): string {
82
100
  const cssLinks = (distManifest.css ?? [])
83
101
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
@@ -92,12 +110,26 @@ export function buildHtml(
92
110
  ? `\n <script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
93
111
  : "";
94
112
 
95
- const formScript =
96
- formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
97
113
  const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
98
114
 
115
+ const depsScript =
116
+ pageDeps !== null || layoutDeps !== null
117
+ ? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
118
+ : "";
119
+
120
+ const sysScript =
121
+ ssrFlag || depsScript ? `\n <script${n}>${ssrFlag}${depsScript}</script>` : "";
122
+
123
+ const dataIslands = csr
124
+ ? `\n <script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>` +
125
+ `\n <script${n} type="application/json" id="__bosia-layout-data__">${safeJsonForScript(layoutData)}</script>` +
126
+ (formData != null
127
+ ? `\n <script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`
128
+ : "")
129
+ : "";
130
+
99
131
  const scripts = csr
100
- ? `${envScript}\n <script${n}>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
132
+ ? `${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
101
133
  : isDev
102
134
  ? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
103
135
  : "";
@@ -208,6 +240,8 @@ export function buildHtmlTail(
208
240
  ssr = true,
209
241
  bodyEndExtras?: string[],
210
242
  nonce?: string,
243
+ pageDeps: any = null,
244
+ layoutDeps: any[] | null = null,
211
245
  ): string {
212
246
  const n = nonceAttr(nonce);
213
247
  let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
@@ -219,12 +253,19 @@ export function buildHtmlTail(
219
253
  if (Object.keys(publicEnv).length > 0) {
220
254
  out += `\n<script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
221
255
  }
222
- const formInject =
223
- formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
256
+ out += `\n<script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>`;
257
+ out += `\n<script${n} type="application/json" id="__bosia-layout-data__">${safeJsonForScript(layoutData)}</script>`;
258
+ if (formData != null) {
259
+ out += `\n<script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`;
260
+ }
224
261
  const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
225
- out +=
226
- `\n<script${n}>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
227
- `window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formInject}</script>`;
262
+ const depsInject =
263
+ pageDeps !== null || layoutDeps !== null
264
+ ? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
265
+ : "";
266
+ if (ssrFlag || depsInject) {
267
+ out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
268
+ }
228
269
  out += `\n<script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
229
270
  } else if (isDev) {
230
271
  out += `\n<script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
@@ -3,7 +3,7 @@ import { render } from "svelte/server";
3
3
  import { findMatch } from "./matcher.ts";
4
4
  import { serverRoutes, errorPage } from "bosia:routes";
5
5
  import type { RouteMatch } from "./types.ts";
6
- import type { Cookies } from "./hooks.ts";
6
+ import type { Cookies, LoaderDeps } from "./hooks.ts";
7
7
  import { CSP_ENABLED } from "./csp.ts";
8
8
  import { HttpError, Redirect } from "./errors.ts";
9
9
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
@@ -158,9 +158,134 @@ function stampErrorContext(
158
158
  e.partialLayoutData ??= [...partialLayoutData];
159
159
  }
160
160
 
161
+ // ─── Per-Loader Dependency Tracking ──────────────────────
162
+ // Wraps `params`, `url`, `cookies`, and `fetch` with proxies/closures
163
+ // that record every key read during a single loader run. The client
164
+ // cache uses these records to skip re-runs on subsequent navigations
165
+ // when none of the tracked inputs changed.
166
+
167
+ function emptyDeps(): LoaderDeps {
168
+ return {
169
+ keys: [],
170
+ urls: [],
171
+ params: [],
172
+ searchParams: [],
173
+ cookies: [],
174
+ uses_url: false,
175
+ };
176
+ }
177
+
178
+ function trackedParams(params: Record<string, string>, deps: LoaderDeps): Record<string, string> {
179
+ return new Proxy(params, {
180
+ get(target, prop) {
181
+ if (typeof prop === "string") {
182
+ if (!deps.params.includes(prop)) deps.params.push(prop);
183
+ }
184
+ return Reflect.get(target, prop);
185
+ },
186
+ });
187
+ }
188
+
189
+ const URL_TRACKED_PROPS = new Set(["pathname", "origin", "hash", "href", "host", "hostname"]);
190
+
191
+ function trackedUrl(url: URL, deps: LoaderDeps): URL {
192
+ const trackedSearch = new Proxy(url.searchParams, {
193
+ get(target, prop) {
194
+ if (prop === "get" || prop === "has" || prop === "getAll") {
195
+ return (key: string) => {
196
+ if (typeof key === "string" && !deps.searchParams.includes(key)) {
197
+ deps.searchParams.push(key);
198
+ }
199
+ return (target as any)[prop](key);
200
+ };
201
+ }
202
+ const value = Reflect.get(target, prop);
203
+ return typeof value === "function" ? value.bind(target) : value;
204
+ },
205
+ });
206
+ return new Proxy(url, {
207
+ get(target, prop) {
208
+ if (prop === "searchParams") return trackedSearch;
209
+ if (prop === "search") {
210
+ // Whole search string read → treat as broad searchParams dep
211
+ deps.uses_url = true;
212
+ return target.search;
213
+ }
214
+ if (typeof prop === "string" && URL_TRACKED_PROPS.has(prop)) {
215
+ deps.uses_url = true;
216
+ }
217
+ const value = Reflect.get(target, prop);
218
+ return typeof value === "function" ? value.bind(target) : value;
219
+ },
220
+ });
221
+ }
222
+
223
+ function trackedCookies(cookies: Cookies, deps: LoaderDeps): Cookies {
224
+ return {
225
+ get(name: string) {
226
+ if (!deps.cookies.includes(name)) deps.cookies.push(name);
227
+ return cookies.get(name);
228
+ },
229
+ getAll() {
230
+ // Broad read — treat as wildcard; record empty marker so any cookie
231
+ // invalidation triggers re-run. Use a sentinel "*" to mean "all".
232
+ if (!deps.cookies.includes("*")) deps.cookies.push("*");
233
+ return cookies.getAll();
234
+ },
235
+ set(name, value, options) {
236
+ cookies.set(name, value, options);
237
+ },
238
+ delete(name, options) {
239
+ cookies.delete(name, options);
240
+ },
241
+ };
242
+ }
243
+
244
+ function trackedFetch(
245
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
246
+ origin: string,
247
+ deps: LoaderDeps,
248
+ ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
249
+ return (input, init) => {
250
+ let href: string | null = null;
251
+ try {
252
+ if (typeof input === "string") href = new URL(input, origin).href;
253
+ else if (input instanceof URL) href = input.href;
254
+ else href = new URL(input.url, origin).href;
255
+ } catch {
256
+ href = null;
257
+ }
258
+ if (href && !deps.urls.includes(href)) deps.urls.push(href);
259
+ return fetch(input, init);
260
+ };
261
+ }
262
+
263
+ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
264
+ return (...keys: string[]) => {
265
+ for (const k of keys) {
266
+ if (typeof k === "string" && !deps.keys.includes(k)) deps.keys.push(k);
267
+ }
268
+ };
269
+ }
270
+
161
271
  // ─── Route Data Loader ───────────────────────────────────
162
272
  // Runs layout + page server loaders for a given URL.
163
273
  // Used by both SSR and the /__bosia/data JSON endpoint.
274
+ //
275
+ // `mask` controls selective re-runs from the client data endpoint:
276
+ // - undefined → run everything (SSR, first nav)
277
+ // - layouts[i] === true → run that layout; false → skip, emit null
278
+ // - page === true → run page; false → skip, emit null
279
+ // When skipped, the parent() chain still receives the *combined parent
280
+ // data* contributed by previously-cached layers, which the client
281
+ // reconstructs and forwards in the request body. For now (initial
282
+ // implementation), skipped loaders contribute `{}` to parent and the
283
+ // response slot is `null`; the client merges with its cached data.
284
+
285
+ export type LoaderMask = {
286
+ page: boolean;
287
+ layouts: boolean[];
288
+ };
164
289
 
165
290
  export async function loadRouteData(
166
291
  url: URL,
@@ -169,89 +294,155 @@ export async function loadRouteData(
169
294
  cookies: Cookies,
170
295
  metadataData: Record<string, any> | null = null,
171
296
  match?: RouteMatch<(typeof serverRoutes)[number]> | null,
297
+ mask?: LoaderMask,
172
298
  ) {
173
299
  match ??= findMatch(serverRoutes, url.pathname);
174
300
  if (!match) return null;
175
301
 
176
302
  const { route, params } = match;
177
303
  const fetch = makeFetch(req, url);
178
- const layoutData: Record<string, any>[] = [];
304
+ const origin = url.origin;
305
+ const layoutData: (Record<string, any> | null)[] = [];
306
+ const layoutDeps: (LoaderDeps | null)[] = [];
179
307
  let parentData: Record<string, any> = {};
180
308
 
181
309
  // Run layout server loaders root → leaf, each gets parent() data
182
310
  for (const ls of route.layoutServers) {
311
+ const skip = mask && mask.layouts[ls.depth] === false;
183
312
  try {
313
+ if (skip) {
314
+ layoutData[ls.depth] = null;
315
+ layoutDeps[ls.depth] = null;
316
+ // Skipped layers contribute {} to the parent chain. The client
317
+ // already has their data and renders it from cache, so dependent
318
+ // loaders that DO re-run will see stale parent() data here. This
319
+ // is the same trade-off SvelteKit makes; loaders that need fresh
320
+ // upstream data should call `depends()` on a shared key.
321
+ continue;
322
+ }
184
323
  const mod = await ls.loader();
185
324
  if (typeof mod.load === "function") {
186
325
  // Snapshot per layer so loaders cannot mutate the shared accumulator,
187
326
  // preserving the same isolation semantics as the previous merge-on-call code.
188
327
  const snapshot = { ...parentData };
189
328
  const parent = async () => snapshot;
329
+ const deps = emptyDeps();
190
330
  const result =
191
331
  (await withTimeout(
192
- mod.load({ params, url, locals, cookies, parent, fetch, metadata: null }),
332
+ mod.load({
333
+ params: trackedParams(params, deps),
334
+ url: trackedUrl(url, deps),
335
+ locals,
336
+ cookies: trackedCookies(cookies, deps),
337
+ parent,
338
+ fetch: trackedFetch(fetch, origin, deps),
339
+ metadata: null,
340
+ depends: makeDepends(deps),
341
+ }),
193
342
  LOAD_TIMEOUT,
194
343
  `layout load (depth=${ls.depth}, ${url.pathname})`,
195
344
  )) ?? {};
196
345
  layoutData[ls.depth] = result;
346
+ layoutDeps[ls.depth] = deps;
197
347
  parentData = { ...parentData, ...result };
348
+ } else {
349
+ layoutData[ls.depth] = {};
350
+ layoutDeps[ls.depth] = emptyDeps();
198
351
  }
199
352
  } catch (err) {
200
353
  if (err instanceof Redirect) throw err;
201
354
  if (err instanceof HttpError) {
202
- stampErrorContext(err, ls.depth, "layout", layoutData);
355
+ stampErrorContext(
356
+ err,
357
+ ls.depth,
358
+ "layout",
359
+ layoutData.map((d) => d ?? {}),
360
+ );
203
361
  throw err;
204
362
  }
205
363
  if (isDev) console.error("Layout server load error:", err);
206
364
  else console.error("Layout server load error:", (err as Error).message ?? err);
207
365
  const wrapped = new HttpError(500, "Internal Server Error");
208
- stampErrorContext(wrapped, ls.depth, "layout", layoutData);
366
+ stampErrorContext(
367
+ wrapped,
368
+ ls.depth,
369
+ "layout",
370
+ layoutData.map((d) => d ?? {}),
371
+ );
209
372
  throw wrapped;
210
373
  }
211
374
  }
212
375
 
213
376
  // Run page server loader
214
- let pageData: Record<string, any> = {};
377
+ let pageData: Record<string, any> | null = null;
378
+ let pageDeps: LoaderDeps | null = null;
215
379
  let csr = true;
216
380
  let ssr = true;
381
+ const skipPage = mask && mask.page === false;
217
382
  if (route.pageServer) {
218
383
  try {
219
384
  const mod = await route.pageServer();
220
385
  if (mod.csr === false) csr = false;
221
386
  if (mod.ssr === false) ssr = false;
222
- if (typeof mod.load === "function") {
387
+ if (skipPage) {
388
+ pageData = null;
389
+ pageDeps = null;
390
+ } else if (typeof mod.load === "function") {
223
391
  const snapshot = { ...parentData };
224
392
  const parent = async () => snapshot;
393
+ const deps = emptyDeps();
225
394
  pageData =
226
395
  (await withTimeout(
227
396
  mod.load({
228
- params,
229
- url,
397
+ params: trackedParams(params, deps),
398
+ url: trackedUrl(url, deps),
230
399
  locals,
231
- cookies,
400
+ cookies: trackedCookies(cookies, deps),
232
401
  parent,
233
- fetch,
402
+ fetch: trackedFetch(fetch, origin, deps),
234
403
  metadata: metadataData,
404
+ depends: makeDepends(deps),
235
405
  }),
236
406
  LOAD_TIMEOUT,
237
407
  `page load (${url.pathname})`,
238
408
  )) ?? {};
409
+ pageDeps = deps;
410
+ } else {
411
+ pageData = {};
412
+ pageDeps = emptyDeps();
239
413
  }
240
414
  } catch (err) {
241
415
  if (err instanceof Redirect) throw err;
242
416
  if (err instanceof HttpError) {
243
- stampErrorContext(err, route.layoutModules.length, "page", layoutData);
417
+ stampErrorContext(
418
+ err,
419
+ route.layoutModules.length,
420
+ "page",
421
+ layoutData.map((d) => d ?? {}),
422
+ );
244
423
  throw err;
245
424
  }
246
425
  if (isDev) console.error("Page server load error:", err);
247
426
  else console.error("Page server load error:", (err as Error).message ?? err);
248
427
  const wrapped = new HttpError(500, "Internal Server Error");
249
- stampErrorContext(wrapped, route.layoutModules.length, "page", layoutData);
428
+ stampErrorContext(
429
+ wrapped,
430
+ route.layoutModules.length,
431
+ "page",
432
+ layoutData.map((d) => d ?? {}),
433
+ );
250
434
  throw wrapped;
251
435
  }
436
+ } else {
437
+ pageData = {};
438
+ pageDeps = emptyDeps();
252
439
  }
253
440
 
254
- return { pageData: { ...pageData, params }, layoutData, csr, ssr };
441
+ // `params` are always attached to pageData for client-side router consumption.
442
+ // When pageData is skipped, the client merges its cached pageData with current
443
+ // route params separately, so we keep the `null` sentinel here.
444
+ const pageOut = pageData === null ? null : { ...pageData, params };
445
+ return { pageData: pageOut, layoutData, csr, ssr, pageDeps, layoutDeps };
255
446
  }
256
447
 
257
448
  // ─── Metadata Loader ─────────────────────────────────────
@@ -399,6 +590,10 @@ export async function renderSSRStream(
399
590
  pluginRenderFragments("bodyEnd", renderCtx),
400
591
  ]);
401
592
 
593
+ // SSR always runs every loader, so coerce types from the optional sparse shape.
594
+ const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
595
+ const pageDataFull = data.pageData ?? {};
596
+
402
597
  // ssr=false → no render() needed; ship shell + hydration as a single response.
403
598
  // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
404
599
  if (!data.ssr) {
@@ -413,13 +608,15 @@ export async function renderSSRStream(
413
608
  buildHtmlTail(
414
609
  "",
415
610
  "",
416
- data.pageData,
417
- data.layoutData,
611
+ pageDataFull,
612
+ layoutDataFull,
418
613
  true,
419
614
  null,
420
615
  false,
421
616
  bodyEndExtras,
422
617
  nonce,
618
+ data.pageDeps,
619
+ data.layoutDeps,
423
620
  );
424
621
  return new Response(html, {
425
622
  headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -435,8 +632,8 @@ export async function renderSSRStream(
435
632
  ssrMode: true,
436
633
  ssrPageComponent: pageMod.default,
437
634
  ssrLayoutComponents: layoutMods.map((m: any) => m.default),
438
- ssrPageData: data.pageData,
439
- ssrLayoutData: data.layoutData,
635
+ ssrPageData: pageDataFull,
636
+ ssrLayoutData: layoutDataFull,
440
637
  },
441
638
  }));
442
639
  } catch (err) {
@@ -451,7 +648,7 @@ export async function renderSSRStream(
451
648
  route,
452
649
  route.layoutModules.length,
453
650
  "page",
454
- data.layoutData,
651
+ layoutDataFull,
455
652
  nonce,
456
653
  );
457
654
  }
@@ -464,13 +661,15 @@ export async function renderSSRStream(
464
661
  buildHtmlTail(
465
662
  body,
466
663
  head,
467
- data.pageData,
468
- data.layoutData,
664
+ pageDataFull,
665
+ layoutDataFull,
469
666
  data.csr,
470
667
  null,
471
668
  true,
472
669
  bodyEndExtras,
473
670
  nonce,
671
+ data.pageDeps,
672
+ data.layoutDeps,
474
673
  ),
475
674
  ),
476
675
  ];
@@ -556,6 +755,10 @@ export async function renderPageWithFormData(
556
755
  nonce,
557
756
  );
558
757
 
758
+ // Form-action re-render always runs every loader (no client mask).
759
+ const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
760
+ const pageDataFull = data.pageData ?? {};
761
+
559
762
  if (!data.ssr) {
560
763
  if (!data.csr && isDev) {
561
764
  console.warn(
@@ -565,13 +768,15 @@ export async function renderPageWithFormData(
565
768
  const html = buildHtml(
566
769
  "",
567
770
  "",
568
- data.pageData,
569
- data.layoutData,
771
+ pageDataFull,
772
+ layoutDataFull,
570
773
  true,
571
774
  formData,
572
775
  undefined,
573
776
  false,
574
777
  nonce,
778
+ data.pageDeps,
779
+ data.layoutDeps,
575
780
  );
576
781
  return compress(html, "text/html; charset=utf-8", req, status);
577
782
  }
@@ -581,8 +786,8 @@ export async function renderPageWithFormData(
581
786
  ssrMode: true,
582
787
  ssrPageComponent: pageMod.default,
583
788
  ssrLayoutComponents: layoutMods.map((m: any) => m.default),
584
- ssrPageData: data.pageData,
585
- ssrLayoutData: data.layoutData,
789
+ ssrPageData: pageDataFull,
790
+ ssrLayoutData: layoutDataFull,
586
791
  ssrFormData: formData,
587
792
  },
588
793
  });
@@ -590,13 +795,15 @@ export async function renderPageWithFormData(
590
795
  const html = buildHtml(
591
796
  body,
592
797
  head,
593
- data.pageData,
594
- data.layoutData,
798
+ pageDataFull,
799
+ layoutDataFull,
595
800
  data.csr,
596
801
  formData,
597
802
  undefined,
598
803
  true,
599
804
  nonce,
805
+ data.pageDeps,
806
+ data.layoutDeps,
600
807
  );
601
808
  return compress(html, "text/html; charset=utf-8", req, status);
602
809
  }
@@ -615,7 +822,7 @@ export async function renderErrorPage(
615
822
  route?: any,
616
823
  errorDepth?: number,
617
824
  errorOrigin?: ErrorOrigin,
618
- partialLayoutData?: Record<string, any>[],
825
+ partialLayoutData?: (Record<string, any> | null)[],
619
826
  nonce?: string,
620
827
  ): Promise<Response> {
621
828
  // Strip the nonce from emitted scripts when CSP is off — the attribute
@@ -40,6 +40,11 @@ export function generateRoutesFile(manifest: RouteManifest): void {
40
40
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
41
41
  lines.push(" hasServerData: boolean;");
42
42
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
43
+ // Stable ids per node, used by the client loader cache to detect whether
44
+ // a layout/page at a given depth is the same as last nav's (skip) or new
45
+ // (re-run). One id per layout in order, plus an optional page id.
46
+ lines.push(" pageId: string | null;");
47
+ lines.push(" layoutIds: (string | null)[];");
43
48
  lines.push("}> = [");
44
49
  for (const r of pages) {
45
50
  const layoutImports = r.layouts
@@ -52,6 +57,15 @@ export function generateRoutesFile(manifest: RouteManifest): void {
52
57
  )
53
58
  .join(", ");
54
59
  const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
60
+ const pageId = r.pageServer ? JSON.stringify(r.pageServer) : "null";
61
+ // Map +layout.server.ts paths to their layout depth, leaving nulls for
62
+ // layouts that have no server loader. Length matches layouts.length so
63
+ // client cache can index by depth.
64
+ const layoutIdByDepth: (string | null)[] = new Array(r.layouts.length).fill(null);
65
+ for (const ls of r.layoutServers) layoutIdByDepth[ls.depth] = ls.path;
66
+ const layoutIds = layoutIdByDepth
67
+ .map((id) => (id === null ? "null" : JSON.stringify(id)))
68
+ .join(", ");
55
69
  lines.push(" {");
56
70
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
57
71
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
@@ -59,6 +73,8 @@ export function generateRoutesFile(manifest: RouteManifest): void {
59
73
  lines.push(` errorPages: [${errorPageImports}],`);
60
74
  lines.push(` hasServerData: ${hasServerData},`);
61
75
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
76
+ lines.push(` pageId: ${pageId},`);
77
+ lines.push(` layoutIds: [${layoutIds}],`);
62
78
  lines.push(" },");
63
79
  }
64
80
  lines.push("];\n");
@@ -157,6 +173,8 @@ function generateClientRoutesFile(
157
173
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
158
174
  lines.push(" hasServerData: boolean;");
159
175
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
176
+ lines.push(" pageId: string | null;");
177
+ lines.push(" layoutIds: (string | null)[];");
160
178
  lines.push("}> = [");
161
179
  for (const r of pages) {
162
180
  const layoutImports = r.layouts
@@ -169,6 +187,12 @@ function generateClientRoutesFile(
169
187
  )
170
188
  .join(", ");
171
189
  const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
190
+ const pageId = r.pageServer ? JSON.stringify(r.pageServer) : "null";
191
+ const layoutIdByDepth: (string | null)[] = new Array(r.layouts.length).fill(null);
192
+ for (const ls of r.layoutServers) layoutIdByDepth[ls.depth] = ls.path;
193
+ const layoutIds = layoutIdByDepth
194
+ .map((id) => (id === null ? "null" : JSON.stringify(id)))
195
+ .join(", ");
172
196
  lines.push(" {");
173
197
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
174
198
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
@@ -176,6 +200,8 @@ function generateClientRoutesFile(
176
200
  lines.push(` errorPages: [${errorPageImports}],`);
177
201
  lines.push(` hasServerData: ${hasServerData},`);
178
202
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
203
+ lines.push(` pageId: ${pageId},`);
204
+ lines.push(` layoutIds: [${layoutIds}],`);
179
205
  lines.push(" },");
180
206
  }
181
207
  lines.push("];\n");