bosia 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
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>;