bosia 1.2.2 → 1.2.3
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/build.ts +10 -2
- package/src/core/client/App.svelte +363 -273
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +7 -2
- package/src/core/client/navigation.ts +46 -27
- package/src/core/client/router.svelte.ts +24 -5
- package/src/core/matcher.ts +52 -85
- package/src/core/prerender.ts +45 -25
- package/src/core/routeFile.ts +21 -17
- package/src/core/routeTypes.ts +14 -11
- package/src/core/scanner.ts +76 -13
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +1 -1
|
@@ -37,8 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
let PageComponent = $state<any>(ssrPageComponent);
|
|
39
39
|
let layoutComponents = $state<any[]>(ssrLayoutComponents ?? []);
|
|
40
|
-
// Mounted page instance
|
|
41
|
-
// exclusive branches, so one ref suffices. Exposes `export const snapshot`.
|
|
40
|
+
// Mounted page instance. Exposes `export const snapshot`.
|
|
42
41
|
let pageInstance = $state<any>(null);
|
|
43
42
|
if (!ssrMode) router.getPageSnapshot = () => pageInstance?.snapshot;
|
|
44
43
|
|
|
@@ -115,10 +114,23 @@
|
|
|
115
114
|
let LoadingComponent = $state<any>(null);
|
|
116
115
|
let loadingDepth = $state(0); // # of shared layouts to keep mounted under the loader
|
|
117
116
|
let showLoading = $state(false);
|
|
117
|
+
// How many layouts wrap the leaf: the shared ones under a skeleton, the error's depth.
|
|
118
|
+
const outerDepth = $derived(
|
|
119
|
+
showLoading && LoadingComponent
|
|
120
|
+
? Math.min(loadingDepth, layoutComponents.length)
|
|
121
|
+
: ErrorComponent
|
|
122
|
+
? Math.min(errorDepth ?? 0, layoutComponents.length)
|
|
123
|
+
: layoutComponents.length,
|
|
124
|
+
);
|
|
118
125
|
let currentLayoutPaths: string[] = [];
|
|
119
126
|
let lastSettledPath = "";
|
|
120
127
|
let lastNavKey = ""; // pathname + search of the last processed nav
|
|
128
|
+
let seenRoute = ""; // full route (with hash) the nav effect last ran for
|
|
129
|
+
let navPending = false; // a route change not yet scrolled for; outlives superseded/redirected runs
|
|
121
130
|
let lastTick = 0; // invalidationTick value last processed
|
|
131
|
+
// An invalidated run that was cancelled or redirected before applying its data
|
|
132
|
+
// already cleared the dirty set, so the next run refetches every loader instead.
|
|
133
|
+
let owedRefresh = false;
|
|
122
134
|
// Skip bar on the very first effect run (initial hydration — data already present)
|
|
123
135
|
let firstNav = true;
|
|
124
136
|
let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -127,21 +139,55 @@
|
|
|
127
139
|
// back/forward navs restore the position saved for that history entry.
|
|
128
140
|
// Runs after tick() so the destination page's real height is in the DOM.
|
|
129
141
|
// goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
|
|
130
|
-
|
|
131
|
-
|
|
142
|
+
// A re-run with no route change (`invalidate*()`, `use:enhance`) isn't a nav and
|
|
143
|
+
// keeps its scroll — unless `replaced`: an error page and the page swapped places.
|
|
144
|
+
// A run that ends without settling (no route, a redirect that didn't start a nav,
|
|
145
|
+
// a rejected chain): drop the loading UI and the scroll it owed, and release
|
|
146
|
+
// waiting goto()/use:enhance calls.
|
|
147
|
+
function dropLoadingUI() {
|
|
148
|
+
navigating = false;
|
|
149
|
+
showLoading = false;
|
|
150
|
+
LoadingComponent = null;
|
|
151
|
+
}
|
|
152
|
+
function abandonRun(scrolled?: Promise<void>) {
|
|
153
|
+
dropLoadingUI();
|
|
154
|
+
router.loading = false;
|
|
155
|
+
navPending = false;
|
|
156
|
+
router.suppressScroll = false;
|
|
157
|
+
drainNavResolvers(scrolled);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Full GET load of `path`, which the address bar already shows. reload() could
|
|
161
|
+
// re-POST an entry a native form submit made; href= the same URL with a #hash
|
|
162
|
+
// would only scroll — so drop the hash.
|
|
163
|
+
function hardLoad(path: string) {
|
|
164
|
+
// replace: the entry being reloaded is this one, so no extra history entry.
|
|
165
|
+
window.location.replace(path.split("#")[0]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Returns the pending scroll: goto()/use:enhance waiters resume after it, so a
|
|
169
|
+
// scrollTo() right after `await goto()` isn't overwritten.
|
|
170
|
+
function settleScroll(replaced = false): Promise<void> | undefined {
|
|
171
|
+
const navigated = navPending;
|
|
172
|
+
navPending = false;
|
|
173
|
+
let scrolled: Promise<void> | undefined;
|
|
174
|
+
if (!navigated) {
|
|
175
|
+
if (replaced) scrolled = tick().then(() => window.scrollTo(0, 0));
|
|
176
|
+
} else if (router.isPush && !router.suppressScroll) {
|
|
132
177
|
const hash = window.location.hash;
|
|
133
|
-
tick().then(() => {
|
|
178
|
+
scrolled = tick().then(() => {
|
|
134
179
|
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
135
180
|
});
|
|
136
181
|
} else if (!router.isPush) {
|
|
137
182
|
const pos = router.pendingScroll;
|
|
138
183
|
// single post-tick restore; content that loads later (images
|
|
139
184
|
// without dimensions) can still shift it. Revisit only if reported.
|
|
140
|
-
if (pos) tick().then(() => window.scrollTo(pos.x, pos.y));
|
|
185
|
+
if (pos) scrolled = tick().then(() => window.scrollTo(pos.x, pos.y));
|
|
141
186
|
}
|
|
142
187
|
router.pendingScroll = null;
|
|
143
188
|
router.suppressScroll = false;
|
|
144
189
|
settleSnapshot();
|
|
190
|
+
return scrolled;
|
|
145
191
|
}
|
|
146
192
|
|
|
147
193
|
// Each flush a navigation starts begins here, before any head effect: what
|
|
@@ -165,37 +211,66 @@
|
|
|
165
211
|
const pathname = url.pathname;
|
|
166
212
|
const navKey = pathname + url.search;
|
|
167
213
|
const match = findMatch(clientRoutes, pathname);
|
|
168
|
-
if (!match)
|
|
214
|
+
if (!match) {
|
|
215
|
+
// No client route: a server-rendered 404, or Back onto one. Moving onto it
|
|
216
|
+
// needs the server to render it; a refresh on it just releases waiters.
|
|
217
|
+
const moved = !firstNav && path.split("#")[0] !== seenRoute.split("#")[0];
|
|
218
|
+
firstNav = false; // a 404 hydrated nothing, so the next match is a real nav
|
|
219
|
+
seenRoute = path;
|
|
220
|
+
if (moved) hardLoad(path);
|
|
221
|
+
else abandonRun();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
169
224
|
|
|
170
225
|
let cancelled = false;
|
|
171
226
|
|
|
172
227
|
const isFirst = firstNav;
|
|
173
228
|
firstNav = false;
|
|
174
229
|
if (isFirst) {
|
|
175
|
-
|
|
230
|
+
const hUrl = new URL(router.hydratedRoute, window.location.origin);
|
|
231
|
+
const hMatch = router.hydratedRoute === path ? match : findMatch(clientRoutes, hUrl.pathname);
|
|
232
|
+
currentLayoutPaths = (hMatch?.route as any)?.layoutPaths ?? [];
|
|
176
233
|
// The probes ran during hydration, before this effect. A title other than
|
|
177
234
|
// metadata()'s or the layouts' was a component's, so navigation keeps it.
|
|
178
235
|
const ssrTitle: string | undefined = (window as any).__BOSIA_TITLE__;
|
|
179
236
|
const base = baseTitle(currentLayoutPaths.length);
|
|
180
237
|
navTitle = ssrTitle ? collapseTitle(ssrTitle) : document.title === base ? base : null;
|
|
181
|
-
lastSettledPath = pathname;
|
|
182
|
-
lastNavKey =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
238
|
+
lastSettledPath = hUrl.pathname;
|
|
239
|
+
lastNavKey = hUrl.pathname + hUrl.search;
|
|
240
|
+
seenRoute = router.hydratedRoute;
|
|
241
|
+
lastTick = 0; // the SSR data's tick
|
|
242
|
+
// Restore-after-reload: router.init() staged this entry's snapshot. Only for
|
|
243
|
+
// the hydrated page — a nav during hydration staged the destination's.
|
|
244
|
+
if (path === router.hydratedRoute) settleSnapshot();
|
|
245
|
+
// Initial hydration — data already in SSR props, no fetch needed. Unless a
|
|
246
|
+
// child's onMount (its effects run first) already invalidated or navigated:
|
|
247
|
+
// then fall through and run it as a normal nav from the server's route.
|
|
248
|
+
if (currentTick === 0 && path === router.hydratedRoute) {
|
|
249
|
+
router.loading = false; // a nav away and back before this run started
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
187
252
|
}
|
|
188
253
|
|
|
254
|
+
// Decided here, not at settle: a run superseded or redirected before it
|
|
255
|
+
// settles still owes the scroll its route change asked for.
|
|
256
|
+
if (path !== seenRoute) navPending = true;
|
|
257
|
+
seenRoute = path;
|
|
258
|
+
|
|
189
259
|
// Hash-only change (same pathname+search, no invalidation): scroll, don't
|
|
190
260
|
// refetch. A static host can't answer the parent-snapshot POST a refetch may
|
|
191
261
|
// issue, which would otherwise render the error page.
|
|
192
|
-
if (navKey === lastNavKey && currentTick === lastTick) {
|
|
193
|
-
|
|
262
|
+
if (navKey === lastNavKey && currentTick === lastTick && !owedRefresh) {
|
|
263
|
+
// Back to the settled page before an in-flight nav finished: its run was
|
|
264
|
+
// cancelled, so drop the loading UI it left up. goto("#x") waits on this nav
|
|
265
|
+
// like any other, so abandonRun() releases it too.
|
|
266
|
+
abandonRun(settleScroll());
|
|
194
267
|
return;
|
|
195
268
|
}
|
|
196
269
|
// A prefetch saved before an invalidation holds pre-mutation data — after a
|
|
197
270
|
// `use:enhance` success or `invalidate*()` it must not stand in for the refetch.
|
|
198
|
-
const
|
|
271
|
+
const carried = owedRefresh;
|
|
272
|
+
const invalidated = currentTick !== lastTick || appState.dirty.all || carried;
|
|
273
|
+
if (invalidated) owedRefresh = true;
|
|
199
274
|
lastTick = currentTick;
|
|
200
275
|
|
|
201
276
|
// Only a real navigation drops the action result. An invalidation re-run
|
|
@@ -208,6 +283,7 @@
|
|
|
208
283
|
}
|
|
209
284
|
navDone = false;
|
|
210
285
|
navigating = true;
|
|
286
|
+
router.loading = true;
|
|
211
287
|
|
|
212
288
|
// ─── +loading.svelte skeleton ─────────────────────────────────────
|
|
213
289
|
// On a real path change, show the destination's loading skeleton nested
|
|
@@ -217,12 +293,16 @@
|
|
|
217
293
|
const loadingImport = (match.route as any).loading as (() => Promise<any>) | null;
|
|
218
294
|
if (loadingImport && pathname !== lastSettledPath) {
|
|
219
295
|
const s = sharedDepth(currentLayoutPaths, destLayoutPaths);
|
|
220
|
-
loadingImport()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
296
|
+
loadingImport()
|
|
297
|
+
.then((m) => {
|
|
298
|
+
if (cancelled || !navigating) return; // superseded, or already settled/abandoned
|
|
299
|
+
loadingDepth = s;
|
|
300
|
+
LoadingComponent = m.default;
|
|
301
|
+
showLoading = true;
|
|
302
|
+
})
|
|
303
|
+
// No skeleton is no reason to fail the nav — and a stale chunk here must not
|
|
304
|
+
// reach hydrate.ts's reload while the page itself loaded fine.
|
|
305
|
+
.catch(() => {});
|
|
226
306
|
}
|
|
227
307
|
|
|
228
308
|
// ─── Loader cache: decide which loaders need to re-run ─────────────
|
|
@@ -236,14 +316,14 @@
|
|
|
236
316
|
const layoutRunFlags: boolean[] = layoutIds.map((id) => {
|
|
237
317
|
if (id === null) return false; // no server loader at this depth
|
|
238
318
|
const entry = appState.loaderCache.layouts[id];
|
|
239
|
-
if (!entry) return true; // never loaded → must run
|
|
319
|
+
if (!entry || carried) return true; // never loaded, or owed a refresh → must run
|
|
240
320
|
return shouldRerun(entry, appState.dirty, ctx);
|
|
241
321
|
});
|
|
242
322
|
|
|
243
323
|
let pageRun = false;
|
|
244
324
|
if (pageId !== null) {
|
|
245
325
|
const entry = appState.loaderCache.page;
|
|
246
|
-
if (!entry || entry.nodeId !== pageId) {
|
|
326
|
+
if (!entry || entry.nodeId !== pageId || carried) {
|
|
247
327
|
pageRun = true;
|
|
248
328
|
} else {
|
|
249
329
|
pageRun = shouldRerun(entry, appState.dirty, ctx);
|
|
@@ -284,7 +364,12 @@
|
|
|
284
364
|
.catch(() => null)
|
|
285
365
|
: Promise.resolve(null);
|
|
286
366
|
|
|
287
|
-
|
|
367
|
+
// Scrolls first: waiting goto()/use:enhance calls resume after the scroll.
|
|
368
|
+
const settle = (
|
|
369
|
+
target: { url: URL; params: Record<string, string> } | null,
|
|
370
|
+
replaced: boolean,
|
|
371
|
+
) => {
|
|
372
|
+
const scrolled = settleScroll(replaced);
|
|
288
373
|
const nav: Navigation = {
|
|
289
374
|
from: null,
|
|
290
375
|
to: target,
|
|
@@ -292,144 +377,161 @@
|
|
|
292
377
|
willUnload: false,
|
|
293
378
|
cancel: () => {},
|
|
294
379
|
};
|
|
380
|
+
// Cleared here, not with the loading UI: an error chunk await or a redirect
|
|
381
|
+
// hop is still loading for a goto() to the same URL.
|
|
382
|
+
router.loading = false;
|
|
383
|
+
// Drain first: a goto() an afterNavigate listener starts must wait for its own
|
|
384
|
+
// nav. Awaiters still resume after the listeners — resolve() is async.
|
|
385
|
+
drainNavResolvers(scrolled);
|
|
295
386
|
fireAfterNavigate(nav);
|
|
296
|
-
drainNavResolvers();
|
|
297
387
|
};
|
|
298
388
|
|
|
299
389
|
Promise.all([
|
|
300
390
|
match.route.page(),
|
|
301
391
|
Promise.all(match.route.layouts.map((l: any) => l())),
|
|
302
392
|
dataFetch,
|
|
303
|
-
])
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
?
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const errDepth: number =
|
|
341
|
-
typeof result?.errorDepth === "number" ? result.errorDepth : match.route.layouts.length;
|
|
342
|
-
const errOrigin = result?.errorOrigin === "layout" ? "layout" : "page";
|
|
343
|
-
const picked = pickErrorPage(match.route.errorPages ?? [], errDepth, errOrigin);
|
|
344
|
-
if (!picked) {
|
|
345
|
-
// No nested boundary — full reload so server can render global error page
|
|
346
|
-
window.location.href = path;
|
|
393
|
+
])
|
|
394
|
+
.then(async ([pageMod, layoutMods, result]: [any, any[], any]) => {
|
|
395
|
+
if (cancelled) return;
|
|
396
|
+
navigating = false;
|
|
397
|
+
navDone = true;
|
|
398
|
+
// Tear down the loading skeleton and record identity for the next nav.
|
|
399
|
+
// Superseded navs have cancelled === true and never reach here, so they
|
|
400
|
+
// can't clobber a newer nav's loader.
|
|
401
|
+
// The skeleton and the error page take the page's slot in the leaf {#if}, so
|
|
402
|
+
// leaving either remounts the page. Read before both are cleared below.
|
|
403
|
+
// The skeleton shows only once its chunk loaded (template's condition).
|
|
404
|
+
const skeletonShown = showLoading && !!LoadingComponent;
|
|
405
|
+
const pageShown = !skeletonShown && !appState.errorComponent;
|
|
406
|
+
const errorShown = skeletonShown ? null : appState.errorComponent;
|
|
407
|
+
dropLoadingUI();
|
|
408
|
+
navDoneTimer = setTimeout(() => {
|
|
409
|
+
navDone = false;
|
|
410
|
+
}, 400);
|
|
411
|
+
if (result?.redirect) {
|
|
412
|
+
// A redirect that doesn't start a nav (cancelled, same URL, left the app)
|
|
413
|
+
// gets no run to settle it — release the waiting goto()/use:enhance here.
|
|
414
|
+
// owedRefresh stays set: the destination owes this run's invalidation.
|
|
415
|
+
// Replace: Back must skip the redirecting URL, not bounce off it again.
|
|
416
|
+
// navigate() resets suppressScroll; a noScroll goto keeps it through its redirect.
|
|
417
|
+
// The hop is a cancellable push nav: Back/hydration hand it on as "goto".
|
|
418
|
+
const keepNoScroll = router.suppressScroll;
|
|
419
|
+
const from = router.lastNavType;
|
|
420
|
+
const r = router.navigate(result.redirect, {
|
|
421
|
+
replace: true,
|
|
422
|
+
source: from === "popstate" || from === "enter" ? "goto" : from,
|
|
423
|
+
});
|
|
424
|
+
if (r === "started") {
|
|
425
|
+
router.suppressScroll = keepNoScroll;
|
|
426
|
+
// The target may be the page already shown (/admin → /): forget the
|
|
427
|
+
// settled key so it runs as a real nav, not the hash-only shortcut.
|
|
428
|
+
lastNavKey = "";
|
|
429
|
+
} else abandonRun();
|
|
347
430
|
return;
|
|
348
431
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
currentLayoutPaths = destLayoutPaths.slice(0, K);
|
|
371
|
-
titleNav.seq++; // the probes read again as this flushes
|
|
372
|
-
const newLayoutData: Record<string, any>[] = [];
|
|
373
|
-
for (let i = 0; i < K; i++) newLayoutData.push({});
|
|
374
|
-
appState.layoutData = newLayoutData;
|
|
375
|
-
appState.pageData = {};
|
|
376
|
-
appState.routeParams = match.params;
|
|
377
|
-
appState.errorComponent = errMod.default;
|
|
378
|
-
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
379
|
-
appState.errorDepth = K;
|
|
380
|
-
// What a hard load shows (renderer.ts): a layout's title, else the
|
|
381
|
-
// status title. Set before Svelte flushes, so a freshly mounted
|
|
382
|
-
// <title> in +error.svelte or a layout still wins.
|
|
383
|
-
document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
|
|
384
|
-
if (!keepTitle) {
|
|
385
|
-
document.title =
|
|
386
|
-
(kept > 0 ? chainTitles[kept - 1] : null) ?? errorTitle(errStatus, errMessage);
|
|
387
|
-
navTitle = document.title;
|
|
432
|
+
if (result?.error || (result === null && match.route.hasServerData)) {
|
|
433
|
+
// New shape: { error: { status, message }, errorDepth, errorOrigin }
|
|
434
|
+
const errInfo = result?.error;
|
|
435
|
+
const errStatus =
|
|
436
|
+
typeof errInfo === "object" && errInfo !== null
|
|
437
|
+
? (errInfo.status ?? 500)
|
|
438
|
+
: (result?.status ?? 500);
|
|
439
|
+
const errMessage =
|
|
440
|
+
typeof errInfo === "object" && errInfo !== null
|
|
441
|
+
? (errInfo.message ?? "Internal Server Error")
|
|
442
|
+
: typeof errInfo === "string"
|
|
443
|
+
? errInfo
|
|
444
|
+
: "Internal Server Error";
|
|
445
|
+
const errDepth: number =
|
|
446
|
+
typeof result?.errorDepth === "number" ? result.errorDepth : match.route.layouts.length;
|
|
447
|
+
const errOrigin = result?.errorOrigin === "layout" ? "layout" : "page";
|
|
448
|
+
const picked = pickErrorPage(match.route.errorPages ?? [], errDepth, errOrigin);
|
|
449
|
+
if (!picked) {
|
|
450
|
+
// No nested boundary — full reload so server can render global error page
|
|
451
|
+
hardLoad(path);
|
|
452
|
+
return;
|
|
388
453
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
454
|
+
try {
|
|
455
|
+
const K = picked.depth;
|
|
456
|
+
const [errMod, ...layoutModsForError] = await Promise.all([
|
|
457
|
+
picked.loader(),
|
|
458
|
+
...match.route.layouts.slice(0, K).map((l: any) => l()),
|
|
459
|
+
]);
|
|
460
|
+
if (cancelled) return;
|
|
461
|
+
// The layouts both routes share stay mounted (one render tree) and
|
|
462
|
+
// never write their title again. Same error component under all of
|
|
463
|
+
// them too: nothing remounts, so a <title> it wrote stays — keep it.
|
|
464
|
+
const kept = sharedDepth(currentLayoutPaths, destLayoutPaths.slice(0, K));
|
|
465
|
+
const prev = appState.errorProps?.error;
|
|
466
|
+
const keepTitle =
|
|
467
|
+
errorShown === errMod.default &&
|
|
468
|
+
kept === K &&
|
|
469
|
+
appState.errorDepth === K &&
|
|
470
|
+
!!prev &&
|
|
471
|
+
document.title !== collapseTitle(errorTitle(prev.status, prev.message));
|
|
472
|
+
layoutComponents = layoutModsForError.map((m: any) => m.default);
|
|
473
|
+
currentLayoutPaths = destLayoutPaths.slice(0, K);
|
|
474
|
+
titleNav.seq++; // the probes read again as this flushes
|
|
475
|
+
const newLayoutData: Record<string, any>[] = [];
|
|
476
|
+
for (let i = 0; i < K; i++) newLayoutData.push({});
|
|
477
|
+
appState.layoutData = newLayoutData;
|
|
478
|
+
appState.pageData = {};
|
|
479
|
+
appState.routeParams = match.params;
|
|
480
|
+
appState.errorComponent = errMod.default;
|
|
481
|
+
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
482
|
+
appState.errorDepth = K;
|
|
483
|
+
// What a hard load shows (renderer.ts): a layout's title, else the
|
|
484
|
+
// status title. Set before Svelte flushes, so a freshly mounted
|
|
485
|
+
// <title> in +error.svelte or a layout still wins.
|
|
486
|
+
document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
|
|
487
|
+
if (!keepTitle) {
|
|
488
|
+
document.title =
|
|
489
|
+
(kept > 0 ? chainTitles[kept - 1] : null) ?? errorTitle(errStatus, errMessage);
|
|
490
|
+
navTitle = document.title;
|
|
491
|
+
}
|
|
492
|
+
// Recorded only once something renders for it (here after the error
|
|
493
|
+
// chunk's await): a cancelled or redirected run never counts as settled.
|
|
494
|
+
lastSettledPath = pathname;
|
|
495
|
+
lastNavKey = navKey;
|
|
496
|
+
settle({ url, params: match.params }, !errorShown);
|
|
497
|
+
} catch {
|
|
498
|
+
if (!cancelled) hardLoad(path);
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
418
501
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
502
|
+
// Fresh data is applied below; the error branch above applied none, so it
|
|
503
|
+
// left any owed refresh for the next run.
|
|
504
|
+
owedRefresh = false;
|
|
505
|
+
lastSettledPath = pathname;
|
|
506
|
+
lastNavKey = navKey;
|
|
507
|
+
// Layout paths are recorded only once they render: a redirect above
|
|
508
|
+
// leaves the mounted ones in place.
|
|
509
|
+
const shared = sharedDepth(currentLayoutPaths, destLayoutPaths);
|
|
510
|
+
currentLayoutPaths = destLayoutPaths;
|
|
511
|
+
const pageChanged = !pageShown || PageComponent !== pageMod.default;
|
|
512
|
+
PageComponent = pageMod.default;
|
|
513
|
+
layoutComponents = layoutMods.map((m: any) => m.default);
|
|
514
|
+
|
|
515
|
+
// Merge sparse server response with the existing client cache.
|
|
516
|
+
// Slots the server returned null for were intentionally skipped — we
|
|
517
|
+
// must pull their data from the cache to keep rendering correct.
|
|
518
|
+
const respLayoutData: (Record<string, any> | null)[] = result?.layoutData ?? [];
|
|
519
|
+
const respLayoutDeps: (any | null)[] = result?.layoutDeps ?? [];
|
|
520
|
+
const respPageData: Record<string, any> | null | undefined = result?.pageData;
|
|
521
|
+
const respPageDeps: any = result?.pageDeps ?? null;
|
|
522
|
+
|
|
523
|
+
const mergedLayoutData: Record<string, any>[] = [];
|
|
524
|
+
for (let i = 0; i < layoutIds.length; i++) {
|
|
525
|
+
const id = layoutIds[i];
|
|
526
|
+
if (id === null) {
|
|
527
|
+
mergedLayoutData.push({});
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (respLayoutData[i] !== null && respLayoutData[i] !== undefined) {
|
|
531
|
+
const entry: CacheEntry = {
|
|
532
|
+
nodeId: id,
|
|
533
|
+
data: respLayoutData[i] as Record<string, any>,
|
|
534
|
+
deps: respLayoutDeps[i] ?? {
|
|
433
535
|
keys: [],
|
|
434
536
|
urls: [],
|
|
435
537
|
params: [],
|
|
@@ -437,89 +539,106 @@
|
|
|
437
539
|
cookies: [],
|
|
438
540
|
uses_url: false,
|
|
439
541
|
},
|
|
440
|
-
|
|
441
|
-
|
|
542
|
+
snapshot: captureSnapshot(
|
|
543
|
+
respLayoutDeps[i] ?? {
|
|
544
|
+
keys: [],
|
|
545
|
+
urls: [],
|
|
546
|
+
params: [],
|
|
547
|
+
searchParams: [],
|
|
548
|
+
cookies: [],
|
|
549
|
+
uses_url: false,
|
|
550
|
+
},
|
|
551
|
+
ctx,
|
|
552
|
+
),
|
|
553
|
+
};
|
|
554
|
+
appState.loaderCache.layouts[id] = entry;
|
|
555
|
+
mergedLayoutData.push(entry.data);
|
|
556
|
+
} else {
|
|
557
|
+
const cachedEntry = appState.loaderCache.layouts[id];
|
|
558
|
+
mergedLayoutData.push(cachedEntry?.data ?? {});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let mergedPageData: Record<string, any>;
|
|
563
|
+
if (respPageData !== null && respPageData !== undefined) {
|
|
564
|
+
const deps = respPageDeps ?? {
|
|
565
|
+
keys: [],
|
|
566
|
+
urls: [],
|
|
567
|
+
params: [],
|
|
568
|
+
searchParams: [],
|
|
569
|
+
cookies: [],
|
|
570
|
+
uses_url: false,
|
|
571
|
+
};
|
|
572
|
+
const entry: CacheEntry = {
|
|
573
|
+
nodeId: pageId ?? "",
|
|
574
|
+
data: respPageData,
|
|
575
|
+
deps,
|
|
576
|
+
snapshot: captureSnapshot(deps, ctx),
|
|
442
577
|
};
|
|
443
|
-
appState.loaderCache.
|
|
444
|
-
|
|
578
|
+
if (pageId !== null) appState.loaderCache.page = entry;
|
|
579
|
+
mergedPageData = respPageData;
|
|
580
|
+
} else if (appState.loaderCache.page && appState.loaderCache.page.nodeId === pageId) {
|
|
581
|
+
mergedPageData = appState.loaderCache.page.data;
|
|
445
582
|
} else {
|
|
446
|
-
|
|
447
|
-
mergedLayoutData.push(cachedEntry?.data ?? {});
|
|
583
|
+
mergedPageData = {};
|
|
448
584
|
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
let mergedPageData: Record<string, any>;
|
|
452
|
-
if (respPageData !== null && respPageData !== undefined) {
|
|
453
|
-
const deps = respPageDeps ?? {
|
|
454
|
-
keys: [],
|
|
455
|
-
urls: [],
|
|
456
|
-
params: [],
|
|
457
|
-
searchParams: [],
|
|
458
|
-
cookies: [],
|
|
459
|
-
uses_url: false,
|
|
460
|
-
};
|
|
461
|
-
const entry: CacheEntry = {
|
|
462
|
-
nodeId: pageId ?? "",
|
|
463
|
-
data: respPageData,
|
|
464
|
-
deps,
|
|
465
|
-
snapshot: captureSnapshot(deps, ctx),
|
|
466
|
-
};
|
|
467
|
-
if (pageId !== null) appState.loaderCache.page = entry;
|
|
468
|
-
mergedPageData = respPageData;
|
|
469
|
-
} else if (appState.loaderCache.page && appState.loaderCache.page.nodeId === pageId) {
|
|
470
|
-
mergedPageData = appState.loaderCache.page.data;
|
|
471
|
-
} else {
|
|
472
|
-
mergedPageData = {};
|
|
473
|
-
}
|
|
474
585
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
document.title
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
586
|
+
appState.pageData = mergedPageData;
|
|
587
|
+
appState.layoutData = mergedLayoutData;
|
|
588
|
+
appState.routeParams = match.params;
|
|
589
|
+
// Successful navigation — clear any prior error state.
|
|
590
|
+
appState.errorComponent = null;
|
|
591
|
+
appState.errorProps = null;
|
|
592
|
+
appState.errorDepth = null;
|
|
593
|
+
|
|
594
|
+
// Re-emit the head from server metadata. `data-bosia-meta` (html.ts `OWNED`)
|
|
595
|
+
// marks exactly the tags metadata() owns, so headExtras, the framework's own
|
|
596
|
+
// static tags and <svelte:head> output are never touched. `result === null`
|
|
597
|
+
// with server data means the fetch failed — leave the head as it is; without
|
|
598
|
+
// server data there is no metadata() at all, so clearing matches SSR.
|
|
599
|
+
if (result || !match.route.hasServerData) {
|
|
600
|
+
const md = result?.metadata;
|
|
601
|
+
document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
|
|
602
|
+
// With no metadata() title, a page gets the one a hard load would give
|
|
603
|
+
// it: the layouts' (baseTitle), else app.html's <title>, else "Bosia
|
|
604
|
+
// App". A layout that stays mounted never writes its title again, so it
|
|
605
|
+
// is put back here. Set before Svelte flushes, so a freshly mounted
|
|
606
|
+
// <svelte:head><title> still wins. On the same page (invalidate, a form
|
|
607
|
+
// action, ?page=2) it stays once a component like <Seo> took it over.
|
|
608
|
+
if (md?.title) {
|
|
609
|
+
document.title = md.title;
|
|
610
|
+
navTitle = document.title;
|
|
611
|
+
} else if (pageChanged || document.title === navTitle) {
|
|
612
|
+
document.title = baseTitle(shared);
|
|
613
|
+
navTitle = document.title;
|
|
614
|
+
}
|
|
615
|
+
if (md?.lang) document.documentElement.lang = md.lang;
|
|
616
|
+
const emit = (tag: "meta" | "link", attrs: Record<string, string | undefined>) => {
|
|
617
|
+
const el = document.createElement(tag);
|
|
618
|
+
for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, v);
|
|
619
|
+
el.setAttribute("data-bosia-meta", "");
|
|
620
|
+
document.head.appendChild(el);
|
|
621
|
+
};
|
|
622
|
+
if (md?.description) emit("meta", { name: "description", content: md.description });
|
|
623
|
+
for (const m of md?.meta ?? [])
|
|
624
|
+
emit("meta", { name: m.name, property: m.property, content: m.content });
|
|
625
|
+
for (const l of md?.link ?? [])
|
|
626
|
+
emit("link", { rel: l.rel, href: l.href, hreflang: l.hreflang });
|
|
505
627
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
settle({ url, params: match.params });
|
|
522
|
-
});
|
|
628
|
+
titleNav.seq++; // the probes read again as this flushes
|
|
629
|
+
|
|
630
|
+
settle({ url, params: match.params }, !pageShown);
|
|
631
|
+
})
|
|
632
|
+
.catch((err) => {
|
|
633
|
+
// A rejected chunk or a throwing page module: tear down this run's loading UI
|
|
634
|
+
// and release waiting goto()/use:enhance calls, then rethrow — a stale-deploy
|
|
635
|
+
// chunk error must still reach hydrate.ts's unhandledrejection recovery.
|
|
636
|
+
// A run the user already left must not trigger hydrate.ts's reload of the
|
|
637
|
+
// page they're on now; navigating there again retries the chunk.
|
|
638
|
+
if (cancelled) return;
|
|
639
|
+
abandonRun();
|
|
640
|
+
throw err;
|
|
641
|
+
});
|
|
523
642
|
|
|
524
643
|
return () => {
|
|
525
644
|
cancelled = true;
|
|
@@ -538,55 +657,26 @@
|
|
|
538
657
|
<div class="bosia-bar done"></div>
|
|
539
658
|
{/if}
|
|
540
659
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
{
|
|
544
|
-
{@const depth = errorDepth ?? 0}
|
|
545
|
-
{#if depth > 0 && layoutComponents.length > 0}
|
|
546
|
-
{@render renderLayout(0, depth)}
|
|
547
|
-
{:else}
|
|
548
|
-
<ErrorComponent {...errorProps ?? {}} {params} />
|
|
549
|
-
{/if}
|
|
550
|
-
{:else if layoutComponents.length > 0}
|
|
551
|
-
{@render renderLayout(0, layoutComponents.length)}
|
|
552
|
-
{:else if PageComponent}
|
|
553
|
-
<PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
|
|
554
|
-
{:else}
|
|
555
|
-
<p>Loading...</p>
|
|
556
|
-
{/if}
|
|
660
|
+
<!-- One tree for page, error and skeleton: a layout at the same depth stays mounted
|
|
661
|
+
whichever of them it wraps, so its state (an open sidebar) survives the skeleton. -->
|
|
662
|
+
{@render renderTree(0)}
|
|
557
663
|
|
|
558
|
-
{#snippet
|
|
559
|
-
{#if index <
|
|
664
|
+
{#snippet renderTree(index: number)}
|
|
665
|
+
{#if index < outerDepth}
|
|
560
666
|
{@const Layout = layoutComponents[index]}
|
|
561
667
|
{@const data = layoutData[index] ?? {}}
|
|
562
|
-
<Layout {data} {params}>
|
|
563
|
-
{@render renderLoading(index + 1)}
|
|
564
|
-
</Layout>
|
|
565
|
-
{:else}
|
|
566
|
-
<LoadingComponent />
|
|
567
|
-
{/if}
|
|
568
|
-
{/snippet}
|
|
569
|
-
|
|
570
|
-
{#snippet renderLayout(index: number, leafDepth: number)}
|
|
571
|
-
{@const Layout = layoutComponents[index]}
|
|
572
|
-
{@const data = layoutData[index] ?? {}}
|
|
573
|
-
|
|
574
|
-
{#if index < leafDepth - 1}
|
|
575
668
|
<Layout {data} {params}>
|
|
576
669
|
<TitleProbe depth={index} layout={Layout} />
|
|
577
|
-
{@render
|
|
670
|
+
{@render renderTree(index + 1)}
|
|
578
671
|
</Layout>
|
|
672
|
+
{:else if showLoading && LoadingComponent}
|
|
673
|
+
<LoadingComponent />
|
|
674
|
+
{:else if ErrorComponent}
|
|
675
|
+
<ErrorComponent {...errorProps ?? {}} {params} />
|
|
676
|
+
{:else if PageComponent}
|
|
677
|
+
<PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
|
|
579
678
|
{:else}
|
|
580
|
-
<
|
|
581
|
-
<TitleProbe depth={index} layout={Layout} />
|
|
582
|
-
{#if ErrorComponent}
|
|
583
|
-
<ErrorComponent {...errorProps ?? {}} {params} />
|
|
584
|
-
{:else if PageComponent}
|
|
585
|
-
<PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
|
|
586
|
-
{:else}
|
|
587
|
-
<p>Loading...</p>
|
|
588
|
-
{/if}
|
|
589
|
-
</Layout>
|
|
679
|
+
<p>Loading...</p>
|
|
590
680
|
{/if}
|
|
591
681
|
{/snippet}
|
|
592
682
|
|