bosia 1.2.1 → 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.
@@ -9,7 +9,7 @@
9
9
  import { pickErrorPage } from "../errorMatch.ts";
10
10
  import { fireAfterNavigate, type Navigation } from "./navListeners.ts";
11
11
  import { drainNavResolvers } from "./navigation.ts";
12
- import { collapseTitle } from "../headScan.ts";
12
+ import { collapseTitle, errorTitle } from "../headScan.ts";
13
13
  import { chainTitles, takeTitleWrite, titleNav } from "./titleMemory.svelte.ts";
14
14
  import TitleProbe from "./TitleProbe.svelte";
15
15
 
@@ -37,8 +37,7 @@
37
37
 
38
38
  let PageComponent = $state<any>(ssrPageComponent);
39
39
  let layoutComponents = $state<any[]>(ssrLayoutComponents ?? []);
40
- // Mounted page instance — the two <PageComponent> render spots are mutually
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
- function settleScroll() {
131
- if (router.isPush && !router.suppressScroll) {
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) return;
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
- currentLayoutPaths = (match.route as any).layoutPaths ?? [];
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 = navKey;
183
- lastTick = currentTick;
184
- // Restore-after-reload: router.init() staged this entry's snapshot.
185
- settleSnapshot();
186
- return; // Initial hydration — data already in SSR props, no fetch needed
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
- settleScroll();
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 invalidated = currentTick !== lastTick || appState.dirty.all;
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().then((m) => {
221
- if (cancelled) return; // superseded nav
222
- loadingDepth = s;
223
- LoadingComponent = m.default;
224
- showLoading = true;
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
- const settle = (target: { url: URL; params: Record<string, string> } | null) => {
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,119 +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
- ]).then(async ([pageMod, layoutMods, result]: [any, any[], any]) => {
304
- if (cancelled) return;
305
- navigating = false;
306
- navDone = true;
307
- // Tear down the loading skeleton and record identity for the next nav.
308
- // Superseded navs have cancelled === true and never reach here, so they
309
- // can't clobber a newer nav's loader.
310
- // The skeleton and the error page render in their own {#if} branch, so
311
- // leaving either remounts the page. Read before both are cleared below.
312
- const pageShown = !showLoading && !appState.errorComponent;
313
- showLoading = false;
314
- LoadingComponent = null;
315
- lastSettledPath = pathname;
316
- lastNavKey = navKey;
317
- navDoneTimer = setTimeout(() => {
318
- navDone = false;
319
- }, 400);
320
- if (result?.redirect) {
321
- router.navigate(result.redirect);
322
- return;
323
- }
324
- if (result?.error || (result === null && match.route.hasServerData)) {
325
- // New shape: { error: { status, message }, errorDepth, errorOrigin }
326
- const errInfo = result?.error;
327
- const errStatus =
328
- typeof errInfo === "object" && errInfo !== null
329
- ? (errInfo.status ?? 500)
330
- : (result?.status ?? 500);
331
- const errMessage =
332
- typeof errInfo === "object" && errInfo !== null
333
- ? (errInfo.message ?? "Internal Server Error")
334
- : typeof errInfo === "string"
335
- ? errInfo
336
- : "Internal Server Error";
337
- const errDepth: number =
338
- typeof result?.errorDepth === "number" ? result.errorDepth : match.route.layouts.length;
339
- const errOrigin = result?.errorOrigin === "layout" ? "layout" : "page";
340
- const picked = pickErrorPage(match.route.errorPages ?? [], errDepth, errOrigin);
341
- if (!picked) {
342
- // No nested boundary — full reload so server can render global error page
343
- 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();
344
430
  return;
345
431
  }
346
- try {
347
- const K = picked.depth;
348
- const [errMod, ...layoutModsForError] = await Promise.all([
349
- picked.loader(),
350
- ...match.route.layouts.slice(0, K).map((l: any) => l()),
351
- ]);
352
- if (cancelled) return;
353
- layoutComponents = layoutModsForError.map((m: any) => m.default);
354
- currentLayoutPaths = destLayoutPaths.slice(0, K);
355
- titleNav.seq++; // the error branch remounts the layouts
356
- const newLayoutData: Record<string, any>[] = [];
357
- for (let i = 0; i < K; i++) newLayoutData.push({});
358
- appState.layoutData = newLayoutData;
359
- appState.pageData = {};
360
- appState.routeParams = match.params;
361
- appState.errorComponent = errMod.default;
362
- appState.errorProps = { error: { status: errStatus, message: errMessage } };
363
- appState.errorDepth = K;
364
- settleScroll();
365
- settle({ url, params: match.params });
366
- } catch {
367
- window.location.href = path;
368
- }
369
- return;
370
- }
371
- // Layout paths are recorded only once they render: a redirect above
372
- // leaves the mounted ones in place.
373
- const shared = sharedDepth(currentLayoutPaths, destLayoutPaths);
374
- currentLayoutPaths = destLayoutPaths;
375
- const pageChanged = !pageShown || PageComponent !== pageMod.default;
376
- PageComponent = pageMod.default;
377
- layoutComponents = layoutMods.map((m: any) => m.default);
378
-
379
- // Merge sparse server response with the existing client cache.
380
- // Slots the server returned null for were intentionally skipped — we
381
- // must pull their data from the cache to keep rendering correct.
382
- const respLayoutData: (Record<string, any> | null)[] = result?.layoutData ?? [];
383
- const respLayoutDeps: (any | null)[] = result?.layoutDeps ?? [];
384
- const respPageData: Record<string, any> | null | undefined = result?.pageData;
385
- const respPageDeps: any = result?.pageDeps ?? null;
386
-
387
- const mergedLayoutData: Record<string, any>[] = [];
388
- for (let i = 0; i < layoutIds.length; i++) {
389
- const id = layoutIds[i];
390
- if (id === null) {
391
- mergedLayoutData.push({});
392
- continue;
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;
453
+ }
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;
393
501
  }
394
- if (respLayoutData[i] !== null && respLayoutData[i] !== undefined) {
395
- const entry: CacheEntry = {
396
- nodeId: id,
397
- data: respLayoutData[i] as Record<string, any>,
398
- deps: respLayoutDeps[i] ?? {
399
- keys: [],
400
- urls: [],
401
- params: [],
402
- searchParams: [],
403
- cookies: [],
404
- uses_url: false,
405
- },
406
- snapshot: captureSnapshot(
407
- respLayoutDeps[i] ?? {
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] ?? {
408
535
  keys: [],
409
536
  urls: [],
410
537
  params: [],
@@ -412,89 +539,106 @@
412
539
  cookies: [],
413
540
  uses_url: false,
414
541
  },
415
- ctx,
416
- ),
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),
417
577
  };
418
- appState.loaderCache.layouts[id] = entry;
419
- mergedLayoutData.push(entry.data);
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;
420
582
  } else {
421
- const cachedEntry = appState.loaderCache.layouts[id];
422
- mergedLayoutData.push(cachedEntry?.data ?? {});
583
+ mergedPageData = {};
423
584
  }
424
- }
425
-
426
- let mergedPageData: Record<string, any>;
427
- if (respPageData !== null && respPageData !== undefined) {
428
- const deps = respPageDeps ?? {
429
- keys: [],
430
- urls: [],
431
- params: [],
432
- searchParams: [],
433
- cookies: [],
434
- uses_url: false,
435
- };
436
- const entry: CacheEntry = {
437
- nodeId: pageId ?? "",
438
- data: respPageData,
439
- deps,
440
- snapshot: captureSnapshot(deps, ctx),
441
- };
442
- if (pageId !== null) appState.loaderCache.page = entry;
443
- mergedPageData = respPageData;
444
- } else if (appState.loaderCache.page && appState.loaderCache.page.nodeId === pageId) {
445
- mergedPageData = appState.loaderCache.page.data;
446
- } else {
447
- mergedPageData = {};
448
- }
449
585
 
450
- appState.pageData = mergedPageData;
451
- appState.layoutData = mergedLayoutData;
452
- appState.routeParams = match.params;
453
- // Successful navigation — clear any prior error state.
454
- appState.errorComponent = null;
455
- appState.errorProps = null;
456
- appState.errorDepth = null;
457
-
458
- settleScroll();
459
-
460
- // Re-emit the head from server metadata. `data-bosia-meta` (html.ts `OWNED`)
461
- // marks exactly the tags metadata() owns, so headExtras, the framework's own
462
- // static tags and <svelte:head> output are never touched. `result === null`
463
- // with server data means the fetch failed — leave the head as it is; without
464
- // server data there is no metadata() at all, so clearing matches SSR.
465
- if (result || !match.route.hasServerData) {
466
- const md = result?.metadata;
467
- document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
468
- // With no metadata() title, a page gets the one a hard load would give
469
- // it: the layouts' (baseTitle), else app.html's <title>, else "Bosia
470
- // App". A layout that stays mounted never writes its title again, so it
471
- // is put back here. Set before Svelte flushes, so a freshly mounted
472
- // <svelte:head><title> still wins. On the same page (invalidate, a form
473
- // action, ?page=2) it stays once a component like <Seo> took it over.
474
- if (md?.title) {
475
- document.title = md.title;
476
- navTitle = document.title;
477
- } else if (pageChanged || document.title === navTitle) {
478
- document.title = baseTitle(shared);
479
- navTitle = document.title;
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 });
480
627
  }
481
- if (md?.lang) document.documentElement.lang = md.lang;
482
- const emit = (tag: "meta" | "link", attrs: Record<string, string | undefined>) => {
483
- const el = document.createElement(tag);
484
- for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, v);
485
- el.setAttribute("data-bosia-meta", "");
486
- document.head.appendChild(el);
487
- };
488
- if (md?.description) emit("meta", { name: "description", content: md.description });
489
- for (const m of md?.meta ?? [])
490
- emit("meta", { name: m.name, property: m.property, content: m.content });
491
- for (const l of md?.link ?? [])
492
- emit("link", { rel: l.rel, href: l.href, hreflang: l.hreflang });
493
- }
494
- titleNav.seq++; // the probes read again as this flushes
495
-
496
- settle({ url, params: match.params });
497
- });
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
+ });
498
642
 
499
643
  return () => {
500
644
  cancelled = true;
@@ -513,55 +657,26 @@
513
657
  <div class="bosia-bar done"></div>
514
658
  {/if}
515
659
 
516
- {#if showLoading && LoadingComponent}
517
- {@render renderLoading(0)}
518
- {:else if ErrorComponent}
519
- {@const depth = errorDepth ?? 0}
520
- {#if depth > 0 && layoutComponents.length > 0}
521
- {@render renderLayout(0, depth)}
522
- {:else}
523
- <ErrorComponent {...errorProps ?? {}} {params} />
524
- {/if}
525
- {:else if layoutComponents.length > 0}
526
- {@render renderLayout(0, layoutComponents.length)}
527
- {:else if PageComponent}
528
- <PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
529
- {:else}
530
- <p>Loading...</p>
531
- {/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)}
532
663
 
533
- {#snippet renderLoading(index: number)}
534
- {#if index < loadingDepth}
664
+ {#snippet renderTree(index: number)}
665
+ {#if index < outerDepth}
535
666
  {@const Layout = layoutComponents[index]}
536
667
  {@const data = layoutData[index] ?? {}}
537
- <Layout {data} {params}>
538
- {@render renderLoading(index + 1)}
539
- </Layout>
540
- {:else}
541
- <LoadingComponent />
542
- {/if}
543
- {/snippet}
544
-
545
- {#snippet renderLayout(index: number, leafDepth: number)}
546
- {@const Layout = layoutComponents[index]}
547
- {@const data = layoutData[index] ?? {}}
548
-
549
- {#if index < leafDepth - 1}
550
668
  <Layout {data} {params}>
551
669
  <TitleProbe depth={index} layout={Layout} />
552
- {@render renderLayout(index + 1, leafDepth)}
670
+ {@render renderTree(index + 1)}
553
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} />
554
678
  {:else}
555
- <Layout {data} {params}>
556
- <TitleProbe depth={index} layout={Layout} />
557
- {#if ErrorComponent}
558
- <ErrorComponent {...errorProps ?? {}} {params} />
559
- {:else if PageComponent}
560
- <PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
561
- {:else}
562
- <p>Loading...</p>
563
- {/if}
564
- </Layout>
679
+ <p>Loading...</p>
565
680
  {/if}
566
681
  {/snippet}
567
682