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/package.json +1 -1
- package/src/core/client/App.svelte +121 -5
- package/src/core/client/appState.svelte.ts +24 -37
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +51 -3
- package/src/core/client/loaderCache.ts +127 -0
- package/src/core/client/navigation.ts +59 -0
- package/src/core/client/prefetch.ts +48 -3
- package/src/core/hooks.ts +27 -0
- package/src/core/html.ts +49 -8
- package/src/core/renderer.ts +235 -28
- package/src/core/routeFile.ts +26 -0
- package/src/core/server.ts +62 -5
- package/src/lib/client.ts +1 -0
- package/src/lib/index.ts +1 -0
- package/templates/default/bosia.config.ts +10 -0
- package/templates/demo/bosia.config.ts +10 -0
- package/templates/todo/bosia.config.ts +10 -0
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}
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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>`;
|
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
|
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({
|
|
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(
|
|
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(
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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:
|
|
439
|
-
ssrLayoutData:
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
569
|
-
|
|
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:
|
|
585
|
-
ssrLayoutData:
|
|
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
|
-
|
|
594
|
-
|
|
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
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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");
|