bosia 0.4.6 → 0.5.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -3,7 +3,8 @@
3
3
  import { findMatch } from "../matcher.ts";
4
4
  import { clientRoutes } from "bosia:routes";
5
5
  import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
6
- import { appState } from "./appState.svelte.ts";
6
+ import { appState, clearDirty } from "./appState.svelte.ts";
7
+ import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
7
8
  import { pickErrorPage } from "../errorMatch.ts";
8
9
 
9
10
  let {
@@ -49,6 +50,10 @@
49
50
  $effect(() => {
50
51
  if (ssrMode) return;
51
52
 
53
+ // Subscribe to `invalidationTick` so `invalidate()` can wake the effect
54
+ // without a URL change.
55
+ void appState.invalidationTick;
56
+
52
57
  const path = router.currentRoute;
53
58
  const pathname = path.split("?")[0].split("#")[0];
54
59
  const match = findMatch(clientRoutes, pathname);
@@ -68,6 +73,43 @@
68
73
  navDone = false;
69
74
  navigating = true;
70
75
 
76
+ // ─── Loader cache: decide which loaders need to re-run ─────────────
77
+ // For each layout depth + the page, compare the cached entry (if any)
78
+ // against the live URL/params and the dirty set. If everything is
79
+ // cacheable, skip the fetch entirely.
80
+ const url = new URL(path, window.location.origin);
81
+ const ctx = liveContext(pathname, match.params, url);
82
+ const layoutIds = (match.route as any).layoutIds as (string | null)[];
83
+ const pageId = (match.route as any).pageId as string | null;
84
+
85
+ const layoutRunFlags: boolean[] = layoutIds.map((id) => {
86
+ if (id === null) return false; // no server loader at this depth
87
+ const entry = appState.loaderCache.layouts[id];
88
+ if (!entry) return true; // never loaded → must run
89
+ return shouldRerun(entry, appState.dirty, ctx);
90
+ });
91
+
92
+ let pageRun = false;
93
+ if (pageId !== null) {
94
+ const entry = appState.loaderCache.page;
95
+ if (!entry || entry.nodeId !== pageId) {
96
+ pageRun = true;
97
+ } else {
98
+ pageRun = shouldRerun(entry, appState.dirty, ctx);
99
+ }
100
+ }
101
+
102
+ // Build `_invalidated=<bits>` — char 0 = page, char i+1 = layouts[i].
103
+ // '1' = run, '0' = skip. Always sent so the server honors cached layers.
104
+ // We always issue the fetch (even when all loaders skip) so page-level
105
+ // metadata stays fresh on every navigation; only the loaders flagged in
106
+ // the mask actually run server-side.
107
+ const maskBits =
108
+ (pageRun ? "1" : "0") + layoutRunFlags.map((b) => (b ? "1" : "0")).join("");
109
+
110
+ // Clear dirty set now — we've baked it into the mask.
111
+ clearDirty();
112
+
71
113
  // Load components + data in parallel, then update state atomically
72
114
  // to avoid a flash of stale/empty data before the fetch completes.
73
115
  const cached = match.route.hasServerData ? consumePrefetch(path) : null;
@@ -75,7 +117,7 @@
75
117
  const dataFetch = cached
76
118
  ? Promise.resolve(cached)
77
119
  : match.route.hasServerData
78
- ? fetch(dataUrl(path))
120
+ ? fetch(dataUrl(path, maskBits))
79
121
  .then((r) => r.json())
80
122
  .catch(() => null)
81
123
  : Promise.resolve(null);
@@ -143,9 +185,83 @@
143
185
  }
144
186
  PageComponent = pageMod.default;
145
187
  layoutComponents = layoutMods.map((m: any) => m.default);
146
- appState.pageData = result?.pageData ?? {};
147
- appState.layoutData = result?.layoutData ?? [];
148
- appState.routeParams = result?.pageData?.params ?? match.params;
188
+
189
+ // Merge sparse server response with the existing client cache.
190
+ // Slots the server returned null for were intentionally skipped — we
191
+ // must pull their data from the cache to keep rendering correct.
192
+ const respLayoutData: (Record<string, any> | null)[] = result?.layoutData ?? [];
193
+ const respLayoutDeps: (any | null)[] = result?.layoutDeps ?? [];
194
+ const respPageData: Record<string, any> | null | undefined = result?.pageData;
195
+ const respPageDeps: any = result?.pageDeps ?? null;
196
+
197
+ const mergedLayoutData: Record<string, any>[] = [];
198
+ for (let i = 0; i < layoutIds.length; i++) {
199
+ const id = layoutIds[i];
200
+ if (id === null) {
201
+ mergedLayoutData.push({});
202
+ continue;
203
+ }
204
+ if (respLayoutData[i] !== null && respLayoutData[i] !== undefined) {
205
+ const entry: CacheEntry = {
206
+ nodeId: id,
207
+ data: respLayoutData[i] as Record<string, any>,
208
+ deps: respLayoutDeps[i] ?? {
209
+ keys: [],
210
+ urls: [],
211
+ params: [],
212
+ searchParams: [],
213
+ cookies: [],
214
+ uses_url: false,
215
+ },
216
+ snapshot: captureSnapshot(
217
+ respLayoutDeps[i] ?? {
218
+ keys: [],
219
+ urls: [],
220
+ params: [],
221
+ searchParams: [],
222
+ cookies: [],
223
+ uses_url: false,
224
+ },
225
+ ctx,
226
+ ),
227
+ };
228
+ appState.loaderCache.layouts[id] = entry;
229
+ mergedLayoutData.push(entry.data);
230
+ } else {
231
+ const cachedEntry = appState.loaderCache.layouts[id];
232
+ mergedLayoutData.push(cachedEntry?.data ?? {});
233
+ }
234
+ }
235
+
236
+ let mergedPageData: Record<string, any>;
237
+ if (respPageData !== null && respPageData !== undefined) {
238
+ const deps = respPageDeps ?? {
239
+ keys: [],
240
+ urls: [],
241
+ params: [],
242
+ searchParams: [],
243
+ cookies: [],
244
+ uses_url: false,
245
+ };
246
+ const entry: CacheEntry = {
247
+ nodeId: pageId ?? "",
248
+ data: respPageData,
249
+ deps,
250
+ snapshot: captureSnapshot(deps, ctx),
251
+ };
252
+ if (pageId !== null) appState.loaderCache.page = entry;
253
+ mergedPageData = respPageData;
254
+ } else if (appState.loaderCache.page && appState.loaderCache.page.nodeId === pageId) {
255
+ mergedPageData = appState.loaderCache.page.data;
256
+ } else {
257
+ mergedPageData = {};
258
+ }
259
+
260
+ // Always overlay current match.params — cached pageData carries the
261
+ // stale params from when the loader ran, so trust the live match.
262
+ appState.pageData = { ...mergedPageData, params: match.params };
263
+ appState.layoutData = mergedLayoutData;
264
+ appState.routeParams = match.params;
149
265
  // Successful navigation — clear any prior error state.
150
266
  appState.errorComponent = null;
151
267
  appState.errorProps = null;
@@ -7,8 +7,7 @@
7
7
  // `ssrMode` and reads from `ssrXxx` props directly during SSR,
8
8
  // so concurrent requests don't share these cells.
9
9
 
10
- import { dataUrl } from "./prefetch.ts";
11
- import { router } from "./router.svelte.ts";
10
+ import type { CacheEntry, DirtyState } from "./loaderCache.ts";
12
11
 
13
12
  class AppState {
14
13
  pageData = $state<Record<string, any>>({});
@@ -21,43 +20,31 @@ class AppState {
21
20
  errorComponent = $state<any>(null);
22
21
  errorProps = $state<{ error: { status: number; message: string } } | null>(null);
23
22
  errorDepth = $state<number | null>(null);
23
+ // Loader cache — keyed by stable id (codegen-emitted server file path).
24
+ // Mirrors the most recent successful run of each layout/page server loader.
25
+ // Wiped on hard refresh, never persisted.
26
+ loaderCache: { page: CacheEntry | null; layouts: Record<string, CacheEntry> } = {
27
+ page: null,
28
+ layouts: {},
29
+ };
30
+ // Pending invalidations consumed by the next data fetch. Mutated by
31
+ // `invalidate()` / `invalidateAll()` and cleared after the request fires.
32
+ dirty: DirtyState = {
33
+ all: false,
34
+ keys: new Set<string>(),
35
+ urls: new Set<string>(),
36
+ urlMatchers: [],
37
+ };
38
+ // Bumped by `invalidate*()` to wake the App.svelte nav effect, so calling
39
+ // `invalidate("k")` re-runs the loader pipeline without changing the URL.
40
+ invalidationTick = $state(0);
24
41
  }
25
42
 
26
43
  export const appState = new AppState();
27
44
 
28
- /**
29
- * Re-fetch loader data for the given path and apply to `appState`.
30
- * Used by `use:enhance` after a successful action — mirrors SvelteKit's
31
- * `invalidateAll` default. No-op if the fetch fails or returns an error.
32
- */
33
- export async function refreshData(path: string): Promise<void> {
34
- try {
35
- const res = await fetch(dataUrl(path));
36
- if (!res.ok) return;
37
- const result = await res.json();
38
- if (result?.redirect) {
39
- router.navigate(result.redirect);
40
- return;
41
- }
42
- if (result?.error) return;
43
- appState.pageData = result?.pageData ?? {};
44
- appState.layoutData = result?.layoutData ?? [];
45
- appState.routeParams = result?.pageData?.params ?? appState.routeParams;
46
- if (result?.metadata) {
47
- if (result.metadata.title) document.title = result.metadata.title;
48
- if (result.metadata.description) {
49
- let meta = document.querySelector(
50
- 'meta[name="description"]',
51
- ) as HTMLMetaElement | null;
52
- if (!meta) {
53
- meta = document.createElement("meta");
54
- meta.name = "description";
55
- document.head.appendChild(meta);
56
- }
57
- meta.content = result.metadata.description;
58
- }
59
- }
60
- } catch {
61
- // best-effort — silently swallow
62
- }
45
+ export function clearDirty(): void {
46
+ appState.dirty.all = false;
47
+ appState.dirty.keys.clear();
48
+ appState.dirty.urls.clear();
49
+ appState.dirty.urlMatchers = [];
63
50
  }
@@ -5,7 +5,7 @@
5
5
  // full page reload. Falls back to native form submission when JS is
6
6
  // disabled because nothing is wired up until this action runs.
7
7
 
8
- import { appState, refreshData } from "./appState.svelte.ts";
8
+ import { appState } from "./appState.svelte.ts";
9
9
  import { router } from "./router.svelte.ts";
10
10
 
11
11
  export type ActionResult =
@@ -53,7 +53,11 @@ async function applyResult(
53
53
  appState.form = result.data;
54
54
  if (reset) form.reset();
55
55
  if (invalidateAll) {
56
- await refreshData(window.location.pathname + window.location.search);
56
+ // Form actions invalidate the page loader only by default — layouts
57
+ // stay cached. Loaders that need to react to a mutation should call
58
+ // `invalidate("app:key")` from the action's submit handler.
59
+ appState.loaderCache.page = null;
60
+ appState.invalidationTick = appState.invalidationTick + 1;
57
61
  }
58
62
  }
59
63
 
@@ -5,10 +5,22 @@ import { initPrefetch } from "./prefetch.ts";
5
5
  import { findMatch, compileRoutes, canonicalPathname } from "../matcher.ts";
6
6
  import { clientRoutes } from "bosia:routes";
7
7
  import { appState } from "./appState.svelte.ts";
8
+ import { captureSnapshot, liveContext, type CacheEntry } from "./loaderCache.ts";
9
+ import type { LoaderDeps } from "../hooks.ts";
8
10
 
9
11
  // Pre-compile route patterns into RegExp at startup (shared by App.svelte and router via module reference)
10
12
  compileRoutes(clientRoutes);
11
13
 
14
+ function readJsonScript<T>(id: string): T | null {
15
+ const el = document.getElementById(id);
16
+ if (!el) return null;
17
+ try {
18
+ return JSON.parse(el.textContent ?? "null") as T;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
12
24
  // ─── Hydration ────────────────────────────────────────────
13
25
 
14
26
  async function main() {
@@ -49,9 +61,9 @@ async function main() {
49
61
  router.params = match.params;
50
62
  }
51
63
 
52
- const ssrPageData = (window as any).__BOSIA_PAGE_DATA__ ?? {};
53
- const ssrLayoutData = (window as any).__BOSIA_LAYOUT_DATA__ ?? [];
54
- const ssrFormData = (window as any).__BOSIA_FORM_DATA__ ?? null;
64
+ const ssrPageData = readJsonScript<Record<string, any>>("__bosia-page-data__") ?? {};
65
+ const ssrLayoutData = readJsonScript<Record<string, any>[]>("__bosia-layout-data__") ?? [];
66
+ const ssrFormData = readJsonScript<Record<string, any>>("__bosia-form-data__");
55
67
 
56
68
  // Seed shared client state so `use:enhance` and other helpers
57
69
  // start from the same values App.svelte renders during hydration.
@@ -60,6 +72,42 @@ async function main() {
60
72
  appState.routeParams = ssrPageData?.params ?? match?.params ?? {};
61
73
  appState.form = ssrFormData;
62
74
 
75
+ // Seed the loader cache from window globals emitted server-side so the
76
+ // next client navigation can decide which loaders to skip without an
77
+ // extra fetch round-trip.
78
+ if (match) {
79
+ const url = new URL(window.location.href);
80
+ const ctx = liveContext(window.location.pathname, match.params, url);
81
+ const ssrPageDeps: LoaderDeps | null = (window as any).__BOSIA_PAGE_DEPS__ ?? null;
82
+ const ssrLayoutDeps: (LoaderDeps | null)[] = (window as any).__BOSIA_LAYOUT_DEPS__ ?? [];
83
+ const pageId = (match.route as any).pageId as string | null;
84
+ const layoutIds = (match.route as any).layoutIds as (string | null)[];
85
+
86
+ if (pageId !== null && ssrPageDeps && ssrPageData) {
87
+ const entry: CacheEntry = {
88
+ nodeId: pageId,
89
+ data: ssrPageData,
90
+ deps: ssrPageDeps,
91
+ snapshot: captureSnapshot(ssrPageDeps, ctx),
92
+ };
93
+ appState.loaderCache.page = entry;
94
+ }
95
+ for (let i = 0; i < layoutIds.length; i++) {
96
+ const id = layoutIds[i];
97
+ if (id === null) continue;
98
+ const deps = ssrLayoutDeps[i];
99
+ const data = ssrLayoutData[i];
100
+ if (!deps || !data) continue;
101
+ const entry: CacheEntry = {
102
+ nodeId: id,
103
+ data,
104
+ deps,
105
+ snapshot: captureSnapshot(deps, ctx),
106
+ };
107
+ appState.loaderCache.layouts[id] = entry;
108
+ }
109
+ }
110
+
63
111
  const target = document.getElementById("app")!;
64
112
  const props = {
65
113
  ssrMode: false,
@@ -0,0 +1,127 @@
1
+ // ─── Client-Side Loader Cache ─────────────────────────────
2
+ // Stores the result of each server loader run by stable id (the
3
+ // +page.server.ts / +layout.server.ts path emitted by codegen) along
4
+ // with the LoaderDeps record captured server-side. On each client
5
+ // navigation we use the cache + a "dirty" set populated by the
6
+ // `invalidate()` API to decide which loaders need to re-run, sending
7
+ // the decision as an `_invalidated=<bits>` query param so the server
8
+ // can skip the loaders that haven't conceptually changed.
9
+ //
10
+ // Cache lives in browser memory only — wiped on hard refresh.
11
+
12
+ import type { LoaderDeps } from "../hooks.ts";
13
+
14
+ export type CacheSnapshot = {
15
+ pathname: string;
16
+ params: Record<string, string>;
17
+ searchParams: Record<string, string | null>;
18
+ cookies: Record<string, string | undefined>;
19
+ };
20
+
21
+ export type CacheEntry = {
22
+ nodeId: string;
23
+ data: Record<string, any>;
24
+ deps: LoaderDeps;
25
+ snapshot: CacheSnapshot;
26
+ };
27
+
28
+ export type EvalContext = {
29
+ pathname: string;
30
+ params: Record<string, string>;
31
+ url: URL;
32
+ cookies: Record<string, string | undefined>;
33
+ };
34
+
35
+ function readDocumentCookies(): Record<string, string> {
36
+ const out: Record<string, string> = {};
37
+ if (typeof document === "undefined") return out;
38
+ for (const pair of document.cookie.split(";")) {
39
+ const idx = pair.indexOf("=");
40
+ if (idx === -1) continue;
41
+ const name = pair.slice(0, idx).trim();
42
+ const value = pair.slice(idx + 1).trim();
43
+ if (name) {
44
+ try {
45
+ out[name] = decodeURIComponent(value);
46
+ } catch {
47
+ out[name] = value;
48
+ }
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ export function liveContext(
55
+ pathname: string,
56
+ params: Record<string, string>,
57
+ url: URL,
58
+ ): EvalContext {
59
+ return {
60
+ pathname,
61
+ params,
62
+ url,
63
+ cookies: readDocumentCookies(),
64
+ };
65
+ }
66
+
67
+ /** Capture only the values relevant to a loader's tracked deps. */
68
+ export function captureSnapshot(deps: LoaderDeps, ctx: EvalContext): CacheSnapshot {
69
+ const params: Record<string, string> = {};
70
+ for (const k of deps.params) params[k] = ctx.params[k] ?? "";
71
+ const searchParams: Record<string, string | null> = {};
72
+ for (const k of deps.searchParams) searchParams[k] = ctx.url.searchParams.get(k);
73
+ const cookies: Record<string, string | undefined> = {};
74
+ for (const k of deps.cookies) cookies[k] = ctx.cookies[k];
75
+ return { pathname: ctx.pathname, params, searchParams, cookies };
76
+ }
77
+
78
+ export type DirtyState = {
79
+ all: boolean;
80
+ keys: Set<string>;
81
+ urls: Set<string>;
82
+ urlMatchers: Array<(u: URL) => boolean>;
83
+ };
84
+
85
+ export function shouldRerun(entry: CacheEntry, dirty: DirtyState, next: EvalContext): boolean {
86
+ if (dirty.all) return true;
87
+ const { deps, snapshot } = entry;
88
+ // Dirty keys
89
+ for (const k of deps.keys) {
90
+ if (dirty.keys.has(k)) return true;
91
+ }
92
+ // Dirty URLs (string match or predicate match)
93
+ if (deps.urls.length > 0) {
94
+ for (const u of deps.urls) {
95
+ if (dirty.urls.has(u)) return true;
96
+ for (const fn of dirty.urlMatchers) {
97
+ try {
98
+ if (fn(new URL(u))) return true;
99
+ } catch {
100
+ // ignore malformed URL deps
101
+ }
102
+ }
103
+ }
104
+ }
105
+ // Param value changes
106
+ for (const k of deps.params) {
107
+ if (snapshot.params[k] !== (next.params[k] ?? "")) return true;
108
+ }
109
+ // Search param value changes
110
+ for (const k of deps.searchParams) {
111
+ if (snapshot.searchParams[k] !== next.url.searchParams.get(k)) return true;
112
+ }
113
+ // Cookie value changes
114
+ for (const k of deps.cookies) {
115
+ if (k === "*") {
116
+ // Broad cookie read — re-run on any cookie change. Detect by
117
+ // comparing the full incoming jar against the captured slice.
118
+ // In practice an empty captured slice will never equal the live
119
+ // jar once any cookie exists, so we re-run whenever cookies do.
120
+ return true;
121
+ }
122
+ if (snapshot.cookies[k] !== next.cookies[k]) return true;
123
+ }
124
+ // Pathname change for loaders that read the URL
125
+ if (deps.uses_url && snapshot.pathname !== next.pathname) return true;
126
+ return false;
127
+ }
@@ -0,0 +1,59 @@
1
+ // ─── Public Invalidation API ──────────────────────────────
2
+ // Counterpart to SvelteKit's `invalidate()` / `invalidateAll()`. Marks
3
+ // loader cache entries dirty and triggers the App.svelte nav effect to
4
+ // re-run only the loaders whose dependencies were invalidated.
5
+ //
6
+ // Usage:
7
+ // import { invalidate, invalidateAll } from "bosia/client";
8
+ // await invalidate("app:user");
9
+ // await invalidate("/api/posts");
10
+ // await invalidate((url) => url.pathname.startsWith("/api/"));
11
+ // await invalidateAll();
12
+
13
+ import { appState } from "./appState.svelte.ts";
14
+
15
+ type InvalidateTarget = string | URL | ((url: URL) => boolean);
16
+
17
+ function bumpTick() {
18
+ appState.invalidationTick = appState.invalidationTick + 1;
19
+ }
20
+
21
+ /**
22
+ * Mark a dependency as invalid; the next navigation (and any in-progress
23
+ * navigation that hasn't started its fetch yet) will re-run loaders that
24
+ * declared a matching `depends()` key, fetched a matching URL, or — when
25
+ * given a predicate — fetched any URL the predicate returns true for.
26
+ *
27
+ * Returns a promise that resolves after the nav effect has flushed.
28
+ */
29
+ export function invalidate(target: InvalidateTarget): Promise<void> {
30
+ if (typeof target === "function") {
31
+ appState.dirty.urlMatchers.push(target);
32
+ } else {
33
+ const str = typeof target === "string" ? target : target.href;
34
+ const isUrl = str.startsWith("/") || str.includes("://");
35
+ if (isUrl) {
36
+ try {
37
+ // Normalize to an absolute URL — fetches the loader recorded are
38
+ // always absolute, so a relative `/api/foo` must be promoted here.
39
+ const abs = new URL(str, window.location.origin).href;
40
+ appState.dirty.urls.add(abs);
41
+ } catch {
42
+ appState.dirty.urls.add(str);
43
+ }
44
+ } else {
45
+ appState.dirty.keys.add(str);
46
+ }
47
+ }
48
+ bumpTick();
49
+ return Promise.resolve();
50
+ }
51
+
52
+ /**
53
+ * Mark every loader as invalid. Next navigation re-runs all of them.
54
+ */
55
+ export function invalidateAll(): Promise<void> {
56
+ appState.dirty.all = true;
57
+ bumpTick();
58
+ return Promise.resolve();
59
+ }
@@ -2,11 +2,52 @@
2
2
  // Supports `data-bosia-preload="hover"` and `data-bosia-preload="viewport"`
3
3
  // on <a> elements or their ancestors.
4
4
 
5
+ import { findMatch } from "../matcher.ts";
6
+ import { clientRoutes } from "bosia:routes";
7
+ import { appState } from "./appState.svelte.ts";
8
+ import { liveContext, shouldRerun } from "./loaderCache.ts";
9
+
10
+ /**
11
+ * Build the `_invalidated` mask bits for a target path using the current
12
+ * client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = run,
13
+ * '0' = skip. Returns `null` when the route cannot be matched.
14
+ */
15
+ export function buildMaskBits(path: string): string | null {
16
+ const url = new URL(path, window.location.origin);
17
+ const pathname = url.pathname;
18
+ const match = findMatch(clientRoutes, pathname);
19
+ if (!match) return null;
20
+ const ctx = liveContext(pathname, match.params, url);
21
+ const layoutIds = (match.route as any).layoutIds as (string | null)[];
22
+ const pageId = (match.route as any).pageId as string | null;
23
+
24
+ const layoutRunFlags = layoutIds.map((id) => {
25
+ if (id === null) return false;
26
+ const entry = appState.loaderCache.layouts[id];
27
+ if (!entry) return true;
28
+ return shouldRerun(entry, appState.dirty, ctx);
29
+ });
30
+
31
+ let pageRun = false;
32
+ if (pageId !== null) {
33
+ const entry = appState.loaderCache.page;
34
+ if (!entry || entry.nodeId !== pageId) pageRun = true;
35
+ else pageRun = shouldRerun(entry, appState.dirty, ctx);
36
+ }
37
+
38
+ return (pageRun ? "1" : "0") + layoutRunFlags.map((b) => (b ? "1" : "0")).join("");
39
+ }
40
+
5
41
  /** Builds the `/__bosia/data/…` URL for a given client path. */
6
- export function dataUrl(path: string): string {
42
+ export function dataUrl(path: string, invalidatedBits?: string): string {
7
43
  const url = new URL(path, window.location.origin);
8
44
  let p = url.pathname.replace(/\/$/, "");
9
- return `/__bosia/data${p || "/index"}.json${url.search}`;
45
+ let qs = url.search;
46
+ if (invalidatedBits) {
47
+ const sep = qs ? "&" : "?";
48
+ qs = `${qs}${sep}_invalidated=${invalidatedBits}`;
49
+ }
50
+ return `/__bosia/data${p || "/index"}.json${qs}`;
10
51
  }
11
52
 
12
53
  export const prefetchCache = new Map<string, { data: any; ts: number }>();
@@ -33,7 +74,11 @@ export async function prefetchPath(path: string): Promise<void> {
33
74
 
34
75
  pending.add(path);
35
76
  try {
36
- const res = await fetch(dataUrl(path));
77
+ // Send the same mask as a real client nav would so the server can skip
78
+ // loaders whose tracked inputs haven't changed. Falls back to running
79
+ // everything when the route can't be matched (e.g. external/unknown URL).
80
+ const maskBits = buildMaskBits(path) ?? undefined;
81
+ const res = await fetch(dataUrl(path, maskBits));
37
82
  if (res.ok) {
38
83
  if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
39
84
  const oldest = prefetchCache.keys().next().value;
package/src/core/hooks.ts CHANGED
@@ -56,6 +56,33 @@ export type LoadEvent = {
56
56
  fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
57
57
  parent: () => Promise<Record<string, any>>;
58
58
  metadata: Record<string, any> | null;
59
+ /**
60
+ * Declare custom dependency keys for this loader. The client cache
61
+ * will re-run the loader when `invalidate(key)` is called with any
62
+ * of these keys. Keys are arbitrary strings, but conventionally
63
+ * namespaced (e.g. `"app:user"`).
64
+ */
65
+ depends: (...keys: string[]) => void;
66
+ };
67
+
68
+ /**
69
+ * Tracked dependencies captured for a single loader during one run.
70
+ * Shipped to the client so subsequent client-side navigations can
71
+ * decide whether to re-run the loader.
72
+ */
73
+ export type LoaderDeps = {
74
+ /** `depends(...keys)` declarations. */
75
+ keys: string[];
76
+ /** Absolute URLs passed to the loader's `fetch()`. */
77
+ urls: string[];
78
+ /** Route params the loader read (`params.X`). */
79
+ params: string[];
80
+ /** Search params the loader read (`url.searchParams.get(X)` / `.has(X)`). */
81
+ searchParams: string[];
82
+ /** Cookies the loader read (`cookies.get(X)`). */
83
+ cookies: string[];
84
+ /** True if the loader read `url.pathname`/`url.origin`/`url.hash`/`url.href`. */
85
+ uses_url: boolean;
59
86
  };
60
87
 
61
88
  export type ResolveFunction = (event: RequestEvent) => MaybePromise<Response>;
package/src/core/html.ts CHANGED
@@ -37,6 +37,22 @@ export function safeJsonStringify(data: unknown): string {
37
37
  return json.replace(/[<>&\u2028\u2029]/g, (c) => map[c]);
38
38
  }
39
39
 
40
+ const SCRIPT_HAZARD_RE = /<(\/script|!--)/gi;
41
+
42
+ /** Escapes JSON for safe embedding inside <script type="application/json"> blocks.
43
+ * Blocks premature </script> and <!-- (HTML script-data escape state) without
44
+ * the JS-context overhead of safeJsonStringify. */
45
+ export function safeJsonForScript(data: unknown): string {
46
+ let json: string;
47
+ try {
48
+ json = JSON.stringify(data);
49
+ } catch {
50
+ console.error("safeJsonForScript: failed to serialize data (circular reference?)");
51
+ json = "null";
52
+ }
53
+ return json.replace(SCRIPT_HAZARD_RE, "\\u003c$1");
54
+ }
55
+
40
56
  // ─── Public Env Injection ─────────────────────────────────
41
57
 
42
58
  /**
@@ -78,6 +94,8 @@ export function buildHtml(
78
94
  lang?: string,
79
95
  ssr = true,
80
96
  nonce?: string,
97
+ pageDeps: any = null,
98
+ layoutDeps: any[] | null = null,
81
99
  ): string {
82
100
  const cssLinks = (distManifest.css ?? [])
83
101
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
@@ -92,12 +110,26 @@ export function buildHtml(
92
110
  ? `\n <script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
93
111
  : "";
94
112
 
95
- const formScript =
96
- formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
97
113
  const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
98
114
 
115
+ const depsScript =
116
+ pageDeps !== null || layoutDeps !== null
117
+ ? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
118
+ : "";
119
+
120
+ const sysScript =
121
+ ssrFlag || depsScript ? `\n <script${n}>${ssrFlag}${depsScript}</script>` : "";
122
+
123
+ const dataIslands = csr
124
+ ? `\n <script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>` +
125
+ `\n <script${n} type="application/json" id="__bosia-layout-data__">${safeJsonForScript(layoutData)}</script>` +
126
+ (formData != null
127
+ ? `\n <script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`
128
+ : "")
129
+ : "";
130
+
99
131
  const scripts = csr
100
- ? `${envScript}\n <script${n}>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
132
+ ? `${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
101
133
  : isDev
102
134
  ? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
103
135
  : "";
@@ -208,6 +240,8 @@ export function buildHtmlTail(
208
240
  ssr = true,
209
241
  bodyEndExtras?: string[],
210
242
  nonce?: string,
243
+ pageDeps: any = null,
244
+ layoutDeps: any[] | null = null,
211
245
  ): string {
212
246
  const n = nonceAttr(nonce);
213
247
  let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
@@ -219,12 +253,19 @@ export function buildHtmlTail(
219
253
  if (Object.keys(publicEnv).length > 0) {
220
254
  out += `\n<script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
221
255
  }
222
- const formInject =
223
- formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
256
+ out += `\n<script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>`;
257
+ out += `\n<script${n} type="application/json" id="__bosia-layout-data__">${safeJsonForScript(layoutData)}</script>`;
258
+ if (formData != null) {
259
+ out += `\n<script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`;
260
+ }
224
261
  const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
225
- out +=
226
- `\n<script${n}>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
227
- `window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formInject}</script>`;
262
+ const depsInject =
263
+ pageDeps !== null || layoutDeps !== null
264
+ ? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
265
+ : "";
266
+ if (ssrFlag || depsInject) {
267
+ out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
268
+ }
228
269
  out += `\n<script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
229
270
  } else if (isDev) {
230
271
  out += `\n<script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
@@ -3,7 +3,7 @@ import { render } from "svelte/server";
3
3
  import { findMatch } from "./matcher.ts";
4
4
  import { serverRoutes, errorPage } from "bosia:routes";
5
5
  import type { RouteMatch } from "./types.ts";
6
- import type { Cookies } from "./hooks.ts";
6
+ import type { Cookies, LoaderDeps } from "./hooks.ts";
7
7
  import { CSP_ENABLED } from "./csp.ts";
8
8
  import { HttpError, Redirect } from "./errors.ts";
9
9
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
@@ -158,9 +158,134 @@ function stampErrorContext(
158
158
  e.partialLayoutData ??= [...partialLayoutData];
159
159
  }
160
160
 
161
+ // ─── Per-Loader Dependency Tracking ──────────────────────
162
+ // Wraps `params`, `url`, `cookies`, and `fetch` with proxies/closures
163
+ // that record every key read during a single loader run. The client
164
+ // cache uses these records to skip re-runs on subsequent navigations
165
+ // when none of the tracked inputs changed.
166
+
167
+ function emptyDeps(): LoaderDeps {
168
+ return {
169
+ keys: [],
170
+ urls: [],
171
+ params: [],
172
+ searchParams: [],
173
+ cookies: [],
174
+ uses_url: false,
175
+ };
176
+ }
177
+
178
+ function trackedParams(params: Record<string, string>, deps: LoaderDeps): Record<string, string> {
179
+ return new Proxy(params, {
180
+ get(target, prop) {
181
+ if (typeof prop === "string") {
182
+ if (!deps.params.includes(prop)) deps.params.push(prop);
183
+ }
184
+ return Reflect.get(target, prop);
185
+ },
186
+ });
187
+ }
188
+
189
+ const URL_TRACKED_PROPS = new Set(["pathname", "origin", "hash", "href", "host", "hostname"]);
190
+
191
+ function trackedUrl(url: URL, deps: LoaderDeps): URL {
192
+ const trackedSearch = new Proxy(url.searchParams, {
193
+ get(target, prop) {
194
+ if (prop === "get" || prop === "has" || prop === "getAll") {
195
+ return (key: string) => {
196
+ if (typeof key === "string" && !deps.searchParams.includes(key)) {
197
+ deps.searchParams.push(key);
198
+ }
199
+ return (target as any)[prop](key);
200
+ };
201
+ }
202
+ const value = Reflect.get(target, prop);
203
+ return typeof value === "function" ? value.bind(target) : value;
204
+ },
205
+ });
206
+ return new Proxy(url, {
207
+ get(target, prop) {
208
+ if (prop === "searchParams") return trackedSearch;
209
+ if (prop === "search") {
210
+ // Whole search string read → treat as broad searchParams dep
211
+ deps.uses_url = true;
212
+ return target.search;
213
+ }
214
+ if (typeof prop === "string" && URL_TRACKED_PROPS.has(prop)) {
215
+ deps.uses_url = true;
216
+ }
217
+ const value = Reflect.get(target, prop);
218
+ return typeof value === "function" ? value.bind(target) : value;
219
+ },
220
+ });
221
+ }
222
+
223
+ function trackedCookies(cookies: Cookies, deps: LoaderDeps): Cookies {
224
+ return {
225
+ get(name: string) {
226
+ if (!deps.cookies.includes(name)) deps.cookies.push(name);
227
+ return cookies.get(name);
228
+ },
229
+ getAll() {
230
+ // Broad read — treat as wildcard; record empty marker so any cookie
231
+ // invalidation triggers re-run. Use a sentinel "*" to mean "all".
232
+ if (!deps.cookies.includes("*")) deps.cookies.push("*");
233
+ return cookies.getAll();
234
+ },
235
+ set(name, value, options) {
236
+ cookies.set(name, value, options);
237
+ },
238
+ delete(name, options) {
239
+ cookies.delete(name, options);
240
+ },
241
+ };
242
+ }
243
+
244
+ function trackedFetch(
245
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
246
+ origin: string,
247
+ deps: LoaderDeps,
248
+ ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
249
+ return (input, init) => {
250
+ let href: string | null = null;
251
+ try {
252
+ if (typeof input === "string") href = new URL(input, origin).href;
253
+ else if (input instanceof URL) href = input.href;
254
+ else href = new URL(input.url, origin).href;
255
+ } catch {
256
+ href = null;
257
+ }
258
+ if (href && !deps.urls.includes(href)) deps.urls.push(href);
259
+ return fetch(input, init);
260
+ };
261
+ }
262
+
263
+ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
264
+ return (...keys: string[]) => {
265
+ for (const k of keys) {
266
+ if (typeof k === "string" && !deps.keys.includes(k)) deps.keys.push(k);
267
+ }
268
+ };
269
+ }
270
+
161
271
  // ─── Route Data Loader ───────────────────────────────────
162
272
  // Runs layout + page server loaders for a given URL.
163
273
  // Used by both SSR and the /__bosia/data JSON endpoint.
274
+ //
275
+ // `mask` controls selective re-runs from the client data endpoint:
276
+ // - undefined → run everything (SSR, first nav)
277
+ // - layouts[i] === true → run that layout; false → skip, emit null
278
+ // - page === true → run page; false → skip, emit null
279
+ // When skipped, the parent() chain still receives the *combined parent
280
+ // data* contributed by previously-cached layers, which the client
281
+ // reconstructs and forwards in the request body. For now (initial
282
+ // implementation), skipped loaders contribute `{}` to parent and the
283
+ // response slot is `null`; the client merges with its cached data.
284
+
285
+ export type LoaderMask = {
286
+ page: boolean;
287
+ layouts: boolean[];
288
+ };
164
289
 
165
290
  export async function loadRouteData(
166
291
  url: URL,
@@ -169,89 +294,155 @@ export async function loadRouteData(
169
294
  cookies: Cookies,
170
295
  metadataData: Record<string, any> | null = null,
171
296
  match?: RouteMatch<(typeof serverRoutes)[number]> | null,
297
+ mask?: LoaderMask,
172
298
  ) {
173
299
  match ??= findMatch(serverRoutes, url.pathname);
174
300
  if (!match) return null;
175
301
 
176
302
  const { route, params } = match;
177
303
  const fetch = makeFetch(req, url);
178
- const layoutData: Record<string, any>[] = [];
304
+ const origin = url.origin;
305
+ const layoutData: (Record<string, any> | null)[] = [];
306
+ const layoutDeps: (LoaderDeps | null)[] = [];
179
307
  let parentData: Record<string, any> = {};
180
308
 
181
309
  // Run layout server loaders root → leaf, each gets parent() data
182
310
  for (const ls of route.layoutServers) {
311
+ const skip = mask && mask.layouts[ls.depth] === false;
183
312
  try {
313
+ if (skip) {
314
+ layoutData[ls.depth] = null;
315
+ layoutDeps[ls.depth] = null;
316
+ // Skipped layers contribute {} to the parent chain. The client
317
+ // already has their data and renders it from cache, so dependent
318
+ // loaders that DO re-run will see stale parent() data here. This
319
+ // is the same trade-off SvelteKit makes; loaders that need fresh
320
+ // upstream data should call `depends()` on a shared key.
321
+ continue;
322
+ }
184
323
  const mod = await ls.loader();
185
324
  if (typeof mod.load === "function") {
186
325
  // Snapshot per layer so loaders cannot mutate the shared accumulator,
187
326
  // preserving the same isolation semantics as the previous merge-on-call code.
188
327
  const snapshot = { ...parentData };
189
328
  const parent = async () => snapshot;
329
+ const deps = emptyDeps();
190
330
  const result =
191
331
  (await withTimeout(
192
- mod.load({ params, url, locals, cookies, parent, fetch, metadata: null }),
332
+ mod.load({
333
+ params: trackedParams(params, deps),
334
+ url: trackedUrl(url, deps),
335
+ locals,
336
+ cookies: trackedCookies(cookies, deps),
337
+ parent,
338
+ fetch: trackedFetch(fetch, origin, deps),
339
+ metadata: null,
340
+ depends: makeDepends(deps),
341
+ }),
193
342
  LOAD_TIMEOUT,
194
343
  `layout load (depth=${ls.depth}, ${url.pathname})`,
195
344
  )) ?? {};
196
345
  layoutData[ls.depth] = result;
346
+ layoutDeps[ls.depth] = deps;
197
347
  parentData = { ...parentData, ...result };
348
+ } else {
349
+ layoutData[ls.depth] = {};
350
+ layoutDeps[ls.depth] = emptyDeps();
198
351
  }
199
352
  } catch (err) {
200
353
  if (err instanceof Redirect) throw err;
201
354
  if (err instanceof HttpError) {
202
- stampErrorContext(err, ls.depth, "layout", layoutData);
355
+ stampErrorContext(
356
+ err,
357
+ ls.depth,
358
+ "layout",
359
+ layoutData.map((d) => d ?? {}),
360
+ );
203
361
  throw err;
204
362
  }
205
363
  if (isDev) console.error("Layout server load error:", err);
206
364
  else console.error("Layout server load error:", (err as Error).message ?? err);
207
365
  const wrapped = new HttpError(500, "Internal Server Error");
208
- stampErrorContext(wrapped, ls.depth, "layout", layoutData);
366
+ stampErrorContext(
367
+ wrapped,
368
+ ls.depth,
369
+ "layout",
370
+ layoutData.map((d) => d ?? {}),
371
+ );
209
372
  throw wrapped;
210
373
  }
211
374
  }
212
375
 
213
376
  // Run page server loader
214
- let pageData: Record<string, any> = {};
377
+ let pageData: Record<string, any> | null = null;
378
+ let pageDeps: LoaderDeps | null = null;
215
379
  let csr = true;
216
380
  let ssr = true;
381
+ const skipPage = mask && mask.page === false;
217
382
  if (route.pageServer) {
218
383
  try {
219
384
  const mod = await route.pageServer();
220
385
  if (mod.csr === false) csr = false;
221
386
  if (mod.ssr === false) ssr = false;
222
- if (typeof mod.load === "function") {
387
+ if (skipPage) {
388
+ pageData = null;
389
+ pageDeps = null;
390
+ } else if (typeof mod.load === "function") {
223
391
  const snapshot = { ...parentData };
224
392
  const parent = async () => snapshot;
393
+ const deps = emptyDeps();
225
394
  pageData =
226
395
  (await withTimeout(
227
396
  mod.load({
228
- params,
229
- url,
397
+ params: trackedParams(params, deps),
398
+ url: trackedUrl(url, deps),
230
399
  locals,
231
- cookies,
400
+ cookies: trackedCookies(cookies, deps),
232
401
  parent,
233
- fetch,
402
+ fetch: trackedFetch(fetch, origin, deps),
234
403
  metadata: metadataData,
404
+ depends: makeDepends(deps),
235
405
  }),
236
406
  LOAD_TIMEOUT,
237
407
  `page load (${url.pathname})`,
238
408
  )) ?? {};
409
+ pageDeps = deps;
410
+ } else {
411
+ pageData = {};
412
+ pageDeps = emptyDeps();
239
413
  }
240
414
  } catch (err) {
241
415
  if (err instanceof Redirect) throw err;
242
416
  if (err instanceof HttpError) {
243
- stampErrorContext(err, route.layoutModules.length, "page", layoutData);
417
+ stampErrorContext(
418
+ err,
419
+ route.layoutModules.length,
420
+ "page",
421
+ layoutData.map((d) => d ?? {}),
422
+ );
244
423
  throw err;
245
424
  }
246
425
  if (isDev) console.error("Page server load error:", err);
247
426
  else console.error("Page server load error:", (err as Error).message ?? err);
248
427
  const wrapped = new HttpError(500, "Internal Server Error");
249
- stampErrorContext(wrapped, route.layoutModules.length, "page", layoutData);
428
+ stampErrorContext(
429
+ wrapped,
430
+ route.layoutModules.length,
431
+ "page",
432
+ layoutData.map((d) => d ?? {}),
433
+ );
250
434
  throw wrapped;
251
435
  }
436
+ } else {
437
+ pageData = {};
438
+ pageDeps = emptyDeps();
252
439
  }
253
440
 
254
- return { pageData: { ...pageData, params }, layoutData, csr, ssr };
441
+ // `params` are always attached to pageData for client-side router consumption.
442
+ // When pageData is skipped, the client merges its cached pageData with current
443
+ // route params separately, so we keep the `null` sentinel here.
444
+ const pageOut = pageData === null ? null : { ...pageData, params };
445
+ return { pageData: pageOut, layoutData, csr, ssr, pageDeps, layoutDeps };
255
446
  }
256
447
 
257
448
  // ─── Metadata Loader ─────────────────────────────────────
@@ -399,6 +590,10 @@ export async function renderSSRStream(
399
590
  pluginRenderFragments("bodyEnd", renderCtx),
400
591
  ]);
401
592
 
593
+ // SSR always runs every loader, so coerce types from the optional sparse shape.
594
+ const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
595
+ const pageDataFull = data.pageData ?? {};
596
+
402
597
  // ssr=false → no render() needed; ship shell + hydration as a single response.
403
598
  // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
404
599
  if (!data.ssr) {
@@ -413,13 +608,15 @@ export async function renderSSRStream(
413
608
  buildHtmlTail(
414
609
  "",
415
610
  "",
416
- data.pageData,
417
- data.layoutData,
611
+ pageDataFull,
612
+ layoutDataFull,
418
613
  true,
419
614
  null,
420
615
  false,
421
616
  bodyEndExtras,
422
617
  nonce,
618
+ data.pageDeps,
619
+ data.layoutDeps,
423
620
  );
424
621
  return new Response(html, {
425
622
  headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -435,8 +632,8 @@ export async function renderSSRStream(
435
632
  ssrMode: true,
436
633
  ssrPageComponent: pageMod.default,
437
634
  ssrLayoutComponents: layoutMods.map((m: any) => m.default),
438
- ssrPageData: data.pageData,
439
- ssrLayoutData: data.layoutData,
635
+ ssrPageData: pageDataFull,
636
+ ssrLayoutData: layoutDataFull,
440
637
  },
441
638
  }));
442
639
  } catch (err) {
@@ -451,7 +648,7 @@ export async function renderSSRStream(
451
648
  route,
452
649
  route.layoutModules.length,
453
650
  "page",
454
- data.layoutData,
651
+ layoutDataFull,
455
652
  nonce,
456
653
  );
457
654
  }
@@ -464,13 +661,15 @@ export async function renderSSRStream(
464
661
  buildHtmlTail(
465
662
  body,
466
663
  head,
467
- data.pageData,
468
- data.layoutData,
664
+ pageDataFull,
665
+ layoutDataFull,
469
666
  data.csr,
470
667
  null,
471
668
  true,
472
669
  bodyEndExtras,
473
670
  nonce,
671
+ data.pageDeps,
672
+ data.layoutDeps,
474
673
  ),
475
674
  ),
476
675
  ];
@@ -556,6 +755,10 @@ export async function renderPageWithFormData(
556
755
  nonce,
557
756
  );
558
757
 
758
+ // Form-action re-render always runs every loader (no client mask).
759
+ const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
760
+ const pageDataFull = data.pageData ?? {};
761
+
559
762
  if (!data.ssr) {
560
763
  if (!data.csr && isDev) {
561
764
  console.warn(
@@ -565,13 +768,15 @@ export async function renderPageWithFormData(
565
768
  const html = buildHtml(
566
769
  "",
567
770
  "",
568
- data.pageData,
569
- data.layoutData,
771
+ pageDataFull,
772
+ layoutDataFull,
570
773
  true,
571
774
  formData,
572
775
  undefined,
573
776
  false,
574
777
  nonce,
778
+ data.pageDeps,
779
+ data.layoutDeps,
575
780
  );
576
781
  return compress(html, "text/html; charset=utf-8", req, status);
577
782
  }
@@ -581,8 +786,8 @@ export async function renderPageWithFormData(
581
786
  ssrMode: true,
582
787
  ssrPageComponent: pageMod.default,
583
788
  ssrLayoutComponents: layoutMods.map((m: any) => m.default),
584
- ssrPageData: data.pageData,
585
- ssrLayoutData: data.layoutData,
789
+ ssrPageData: pageDataFull,
790
+ ssrLayoutData: layoutDataFull,
586
791
  ssrFormData: formData,
587
792
  },
588
793
  });
@@ -590,13 +795,15 @@ export async function renderPageWithFormData(
590
795
  const html = buildHtml(
591
796
  body,
592
797
  head,
593
- data.pageData,
594
- data.layoutData,
798
+ pageDataFull,
799
+ layoutDataFull,
595
800
  data.csr,
596
801
  formData,
597
802
  undefined,
598
803
  true,
599
804
  nonce,
805
+ data.pageDeps,
806
+ data.layoutDeps,
600
807
  );
601
808
  return compress(html, "text/html; charset=utf-8", req, status);
602
809
  }
@@ -615,7 +822,7 @@ export async function renderErrorPage(
615
822
  route?: any,
616
823
  errorDepth?: number,
617
824
  errorOrigin?: ErrorOrigin,
618
- partialLayoutData?: Record<string, any>[],
825
+ partialLayoutData?: (Record<string, any> | null)[],
619
826
  nonce?: string,
620
827
  ): Promise<Response> {
621
828
  // Strip the nonce from emitted scripts when CSP is off — the attribute
@@ -40,6 +40,11 @@ export function generateRoutesFile(manifest: RouteManifest): void {
40
40
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
41
41
  lines.push(" hasServerData: boolean;");
42
42
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
43
+ // Stable ids per node, used by the client loader cache to detect whether
44
+ // a layout/page at a given depth is the same as last nav's (skip) or new
45
+ // (re-run). One id per layout in order, plus an optional page id.
46
+ lines.push(" pageId: string | null;");
47
+ lines.push(" layoutIds: (string | null)[];");
43
48
  lines.push("}> = [");
44
49
  for (const r of pages) {
45
50
  const layoutImports = r.layouts
@@ -52,6 +57,15 @@ export function generateRoutesFile(manifest: RouteManifest): void {
52
57
  )
53
58
  .join(", ");
54
59
  const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
60
+ const pageId = r.pageServer ? JSON.stringify(r.pageServer) : "null";
61
+ // Map +layout.server.ts paths to their layout depth, leaving nulls for
62
+ // layouts that have no server loader. Length matches layouts.length so
63
+ // client cache can index by depth.
64
+ const layoutIdByDepth: (string | null)[] = new Array(r.layouts.length).fill(null);
65
+ for (const ls of r.layoutServers) layoutIdByDepth[ls.depth] = ls.path;
66
+ const layoutIds = layoutIdByDepth
67
+ .map((id) => (id === null ? "null" : JSON.stringify(id)))
68
+ .join(", ");
55
69
  lines.push(" {");
56
70
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
57
71
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
@@ -59,6 +73,8 @@ export function generateRoutesFile(manifest: RouteManifest): void {
59
73
  lines.push(` errorPages: [${errorPageImports}],`);
60
74
  lines.push(` hasServerData: ${hasServerData},`);
61
75
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
76
+ lines.push(` pageId: ${pageId},`);
77
+ lines.push(` layoutIds: [${layoutIds}],`);
62
78
  lines.push(" },");
63
79
  }
64
80
  lines.push("];\n");
@@ -157,6 +173,8 @@ function generateClientRoutesFile(
157
173
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
158
174
  lines.push(" hasServerData: boolean;");
159
175
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
176
+ lines.push(" pageId: string | null;");
177
+ lines.push(" layoutIds: (string | null)[];");
160
178
  lines.push("}> = [");
161
179
  for (const r of pages) {
162
180
  const layoutImports = r.layouts
@@ -169,6 +187,12 @@ function generateClientRoutesFile(
169
187
  )
170
188
  .join(", ");
171
189
  const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
190
+ const pageId = r.pageServer ? JSON.stringify(r.pageServer) : "null";
191
+ const layoutIdByDepth: (string | null)[] = new Array(r.layouts.length).fill(null);
192
+ for (const ls of r.layoutServers) layoutIdByDepth[ls.depth] = ls.path;
193
+ const layoutIds = layoutIdByDepth
194
+ .map((id) => (id === null ? "null" : JSON.stringify(id)))
195
+ .join(", ");
172
196
  lines.push(" {");
173
197
  lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
174
198
  lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
@@ -176,6 +200,8 @@ function generateClientRoutesFile(
176
200
  lines.push(` errorPages: [${errorPageImports}],`);
177
201
  lines.push(` hasServerData: ${hasServerData},`);
178
202
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
203
+ lines.push(` pageId: ${pageId},`);
204
+ lines.push(` layoutIds: [${layoutIds}],`);
179
205
  lines.push(" },");
180
206
  }
181
207
  lines.push("];\n");
@@ -112,6 +112,23 @@ function isValidRoutePath(path: string, origin: string): boolean {
112
112
  }
113
113
  }
114
114
 
115
+ /**
116
+ * Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
117
+ * depth i, '1' = run, '0' = skip. Missing/extra chars default to run.
118
+ */
119
+ function buildMaskFromBits(
120
+ bits: string,
121
+ layoutCount: number,
122
+ ): { page: boolean; layouts: boolean[] } {
123
+ const page = bits[0] !== "0";
124
+ const layouts: boolean[] = [];
125
+ for (let i = 0; i < layoutCount; i++) {
126
+ const c = bits[i + 1];
127
+ layouts.push(c !== "0");
128
+ }
129
+ return { page, layouts };
130
+ }
131
+
115
132
  /** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
116
133
  function parseActionName(url: URL): string {
117
134
  for (const key of url.searchParams.keys()) {
@@ -146,15 +163,41 @@ async function resolve(event: RequestEvent): Promise<Response> {
146
163
  return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
147
164
  }
148
165
  const routeUrl = new URL(routePathStr, url.origin);
166
+ let invalidatedBits: string | null = null;
149
167
  for (const [key, val] of url.searchParams.entries()) {
168
+ if (key === "_invalidated") {
169
+ invalidatedBits = val;
170
+ continue;
171
+ }
150
172
  routeUrl.searchParams.append(key, val);
151
173
  }
152
174
  // Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
153
175
  event.url = routeUrl;
154
176
  try {
155
177
  const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
178
+ // Build mask from `?_invalidated=<bits>` where char 0 = page,
179
+ // char i+1 = layout depth i, '1' = run, '0' = skip. Absent → run all.
180
+ // Mask is sized to the total layout count (matching client `layoutIds`),
181
+ // not the count of layout servers, so depths without a server loader
182
+ // still occupy a bit position and stay aligned with the client.
183
+ const mask = invalidatedBits
184
+ ? buildMaskFromBits(
185
+ invalidatedBits,
186
+ pageMatch?.route
187
+ ? ((pageMatch.route as any).layoutModules?.length ?? 0)
188
+ : 0,
189
+ )
190
+ : undefined;
156
191
  const runLoad = async () => {
157
- const data = await loadRouteData(routeUrl, locals, request, cookies);
192
+ const data = await loadRouteData(
193
+ routeUrl,
194
+ locals,
195
+ request,
196
+ cookies,
197
+ null,
198
+ pageMatch,
199
+ mask,
200
+ );
158
201
 
159
202
  let metadata = null;
160
203
  if (pageMatch) {
@@ -176,12 +219,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
176
219
  return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
177
220
  };
178
221
 
179
- // Dedup public routes by URL only. `(private)` scope routes (per-user content)
180
- // skip the cache to prevent cross-user data leaks. See dedup.ts.
222
+ // Dedup public routes by URL + mask. `(private)` scope routes (per-user
223
+ // content) skip the cache to prevent cross-user data leaks. The mask is
224
+ // part of the key so concurrent requests for the same URL with different
225
+ // invalidation patterns don't collapse onto each other. See dedup.ts.
226
+ const dedupK = invalidatedBits
227
+ ? `${dedupKey(routeUrl)}|m=${invalidatedBits}`
228
+ : dedupKey(routeUrl);
181
229
  const result =
182
230
  pageMatch?.route.scope === "private"
183
231
  ? await runLoad()
184
- : await dedup(dedupKey(routeUrl), runLoad);
232
+ : await dedup(dedupK, runLoad);
185
233
 
186
234
  const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
187
235
  const cc = cookiesWereAccessed
package/src/lib/client.ts CHANGED
@@ -10,3 +10,4 @@
10
10
 
11
11
  export { enhance } from "../core/client/enhance.ts";
12
12
  export type { SubmitFunction, ActionResult } from "../core/client/enhance.ts";
13
+ export { invalidate, invalidateAll } from "../core/client/navigation.ts";
package/src/lib/index.ts CHANGED
@@ -10,6 +10,7 @@ export type { HttpError, Redirect, RedirectOptions, ActionFailure } from "../cor
10
10
  export type {
11
11
  RequestEvent,
12
12
  LoadEvent,
13
+ LoaderDeps,
13
14
  MetadataEvent,
14
15
  Metadata,
15
16
  Handle,