Skip to content

Commit 802e0ef

Browse files
authored
Merge pull request #213 from JoviDeCroock/JoviDeCroock/issue-106
Add configurable route loader cache duration
2 parents b58eeda + 5322200 commit 802e0ef

31 files changed

Lines changed: 490 additions & 28 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@pracht/core": patch
3+
"@pracht/cli": patch
4+
---
5+
6+
Add an inheritable `loaderCache` route option for controlling how long browsers privately cache successful route-state loader data. Positive durations emit `Cache-Control: private, max-age=<seconds>`, while `false`, `0`, and the default remain `no-store`.
7+
8+
Expose the resolved loader cache policy in `pracht inspect routes --json` and the MCP route graph.
9+
10+
Manual `useRevalidate()` calls bypass route-state browser caching so explicit refreshes and post-mutation reloads still re-run the loader.
11+
12+
Form redirects after state-changing submissions also bypass cached route-state data when reloading the destination route.

docs/ARCHITECTURE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,15 @@ User clicks <a> or calls navigate()
294294
└─ Import shell module chunk (if applicable)
295295
→ Otherwise, import the route/shell modules only and skip the server fetch
296296
→ Server runs middleware + loader when needed and returns JSON (no HTML rendering)
297+
with no-store by default or the route's private loaderCache duration
297298
→ Client updates component tree with new data + loaded modules
298299
→ Update URL via history.pushState
299300
```
300301

301302
Module imports are cached so repeated navigations to the same shell skip the import.
302303
Prefetching (hover/intent/viewport) also warms module chunks alongside route-state data.
304+
Its bounded 30-second in-memory reuse window is independent of the route-state
305+
response's browser HTTP cache policy.
303306

304307
This "server-owned navigation" pattern means loaders never run in the browser.
305308
Secrets in loader code stay server-side. The client only receives serialized data.

docs/DATA_LOADING.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,30 @@ plugins turn Markdown/MDX or TypeScript route files into JavaScript.
7070

7171
Framework-generated route-state responses add `Vary: x-pracht-route-state-request`
7272
so caches keep HTML and JSON variants separate. Those JSON responses also default
73-
to `Cache-Control: no-store` unless your app sets a stricter policy explicitly.
73+
to `Cache-Control: no-store`.
74+
75+
Use `loaderCache` in route metadata when loader data can safely be reused by the
76+
same browser:
77+
78+
```typescript
79+
route("/settings", () => import("./routes/settings.tsx"), {
80+
render: "spa",
81+
loaderCache: 3600,
82+
});
83+
```
84+
85+
A positive integer sets `Cache-Control: private, max-age=<seconds>` on successful
86+
route-state data responses. `loaderCache: false` and `loaderCache: 0` both produce
87+
`no-store`, which also lets a route opt out of a group default. Redirects and error
88+
responses remain `no-store`. If a loader returns a `Response` with an explicit
89+
`Cache-Control` header, that header takes precedence over route metadata.
90+
91+
`loaderCache` is client-side browser caching only. It does not change ISG
92+
`revalidate`, and the prefetcher's bounded 30-second in-memory cache remains
93+
independent so an intent prefetch can still be consumed by the navigation that
94+
immediately follows it. Explicit `useRevalidate()` calls bypass this browser
95+
cache so user-triggered refreshes and post-mutation reloads still re-run the
96+
loader.
7497

7598
For SPA routes, the initial HTML can still include the matched shell and an
7699
optional shell `Loading` export so the page is not blank before the route-state

docs/REQUEST_FLOWS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ and returns a small JSON envelope:
2626

2727
The `Vary: x-pracht-route-state-request` response header tells caches to keep
2828
the HTML and JSON variants separate. JSON responses default to
29-
`Cache-Control: no-store`.
29+
`Cache-Control: no-store`; a positive route `loaderCache` value changes
30+
successful loader-data responses to `private, max-age=<seconds>`.
3031

3132
If the target route has neither a loader nor middleware, client navigation can
3233
skip the route-state request entirely and only load the route/shell modules.
@@ -397,6 +398,10 @@ Vary: x-pracht-route-state-request
397398
Cache-Control: no-store
398399
```
399400

401+
`Cache-Control` above is the default. Routes configured with
402+
`loaderCache: <seconds>` return `private, max-age=<seconds>` for successful data;
403+
redirects and errors remain `no-store`.
404+
400405
No HTML is rendered during navigation regardless of the target route's mode.
401406
The client updates the component tree in-place.
402407

docs/ROUTING.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Defines a single route:
9393
| ------ | ----------- | ------------------------------------------------------ |
9494
| `path` | `string` | URL pattern (e.g. `/blog/:slug`) |
9595
| `file` | `ModuleRef` | Module reference — `() => import("./path")` or string |
96-
| `meta` | `RouteMeta` | Optional: render mode, shell, middleware, revalidation |
96+
| `meta` | `RouteMeta` | Optional: render mode, shell, middleware, revalidation, loader caching |
9797

9898
> [!IMPORTANT]
9999
> Function module refs must use the exact inline form `() => import("./path")`
@@ -107,7 +107,7 @@ Groups routes with shared configuration:
107107

108108
| Param | Type | Description |
109109
| -------- | ------------------- | ----------------------------------------------------- |
110-
| `meta` | `GroupMeta` | Shell, middleware, render mode, pathPrefix to inherit |
110+
| `meta` | `GroupMeta` | Shell, middleware, render mode, loader cache, pathPrefix to inherit |
111111
| `routes` | `RouteDefinition[]` | Routes in this group |
112112

113113
Group properties cascade to children. A route's own meta overrides the group's.
@@ -124,6 +124,7 @@ interface RouteMeta {
124124
hydration?: "full" | "islands" | "none"; // Partial hydration (see ISLANDS.md)
125125
middleware?: string[]; // Named middleware from defineApp.middleware
126126
revalidate?: RouteRevalidate; // ISG revalidation policy
127+
loaderCache?: number | false; // Browser cache seconds for route-state loader data
127128
prefetch?: "none" | "hover" | "viewport" | "intent"; // Route-level prefetch strategy (default: "intent")
128129
speculation?: "prefetch" | "prerender" | { mode; eagerness };
129130
}
@@ -134,6 +135,13 @@ interface RouteMeta {
134135
through `group(...)` like `render` and documented in
135136
[ISLANDS.md](ISLANDS.md).
136137

138+
`loaderCache` accepts a non-negative integer number of seconds or `false`.
139+
Positive values set `Cache-Control: private, max-age=<seconds>` on successful
140+
route-state loader data. `0`, `false`, and an omitted value use `no-store`.
141+
It inherits through `group(...)`, and a route-level value overrides the group.
142+
This browser cache is independent of ISG `revalidate` and the client's 30-second
143+
in-memory prefetch cache. See [DATA_LOADING.md](DATA_LOADING.md#loaders).
144+
137145
See [Speculation Rules](#speculation-rules) for `speculation` semantics and how
138146
it composes with the JS-based `prefetch` strategy.
139147

@@ -263,7 +271,8 @@ and `<Link prefetch>` overrides it per link:
263271
Prefetching warms the route's JS chunks and caches the route-state JSON in a
264272
bounded LRU cache (30s TTL); a subsequent navigation consumes the cached
265273
result instead of fetching again. Failed prefetches are evicted so they never
266-
poison a later navigation.
274+
poison a later navigation. This short-lived in-memory cache is independent of
275+
the route's HTTP `loaderCache` policy.
267276

268277
There is also an imperative API for warming a route from code (e.g. before
269278
opening a client-side dialog that links somewhere):

e2e/basic.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,15 @@ test("route state request returns JSON", async ({ request }) => {
230230
expect(json.data.highlights).toContain("Hybrid route manifest");
231231
});
232232

233+
test("route state request uses the configured loader cache duration", async ({ request }) => {
234+
const response = await request.get("/pricing", {
235+
headers: { "x-pracht-route-state-request": "1" },
236+
});
237+
238+
expect(response.status()).toBe(200);
239+
expect(response.headers()["cache-control"]).toBe("private, max-age=60");
240+
});
241+
233242
// ---------------------------------------------------------------------------
234243
// 404 handling
235244
// ---------------------------------------------------------------------------

examples/cloudflare/src/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const app = defineApp({
1717
}),
1818
route("/pricing", () => import("./routes/pricing.tsx"), {
1919
id: "pricing",
20+
loaderCache: 60,
2021
render: "isg",
2122
revalidate: timeRevalidate(3600),
2223
speculation: "prefetch",

examples/docs/src/routes/docs/data-loading.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,32 @@ export. Named route exports such as `loader`, `head`, `headers`,
6464
> [!NOTE]
6565
> Loaders **never** run in the browser. Database connections, API keys, and secrets in loader code stay server-side permanently.
6666
67+
### Route-state caching
68+
69+
Client navigation fetches loader data through Pracht's route-state endpoint. By
70+
default those JSON responses use `Cache-Control: no-store`, so every navigation
71+
asks the server for fresh loader data.
72+
73+
Use `loaderCache` in route metadata when the returned data can safely be reused
74+
by the same browser for a short time:
75+
76+
```ts [src/routes.ts]
77+
route("/pricing", "./routes/pricing.tsx", {
78+
render: "isg",
79+
loaderCache: 60,
80+
});
81+
```
82+
83+
A positive value sets `Cache-Control: private, max-age=<seconds>` on successful
84+
route-state responses. `loaderCache: false` and `loaderCache: 0` keep `no-store`
85+
and can opt a route out of a group default.
86+
87+
Only cache data that is safe to reuse for the configured duration in the same
88+
browser. Avoid positive `loaderCache` values for loader data that depends on the
89+
current user, permissions, session, or cookies. `loaderCache` does not change
90+
ISG `revalidate`, and it is separate from Pracht's short in-memory prefetch
91+
cache.
92+
6793
### Error handling
6894

6995
Throw `PrachtHttpError` for structured error responses. Pair it with an `ErrorBoundary` export to render a fallback UI:
@@ -258,6 +284,10 @@ export function Component() {
258284
}
259285
```
260286

287+
Manual revalidation bypasses route-state browser caching, including
288+
`loaderCache`, so refresh buttons and post-mutation reloads fetch fresh loader
289+
data.
290+
261291
### useNavigation()
262292

263293
Reactive pending state for the current navigation or `<Form>` submission — the

packages/cli/src/app-graph.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const METHOD_ORDER: HttpMethod[] = [...HTTP_METHODS];
1010
export interface AppGraphRoute {
1111
file: string;
1212
id: string;
13+
loaderCache: number | false | null;
1314
loaderFile: string | null;
1415
middleware: string[];
1516
path: string;
@@ -33,6 +34,7 @@ export interface AppGraph {
3334
interface ResolvedRouteEntry {
3435
file: string;
3536
id: string;
37+
loaderCache?: number | false;
3638
loaderFile?: string;
3739
middleware: string[];
3840
path: string;
@@ -67,6 +69,7 @@ export function serializeResolvedRoutes(routes: ResolvedRouteEntry[]): AppGraphR
6769
return routes.map((route) => ({
6870
file: route.file,
6971
id: route.id,
72+
loaderCache: route.loaderCache ?? null,
7073
loaderFile: route.loaderFile ?? null,
7174
middleware: route.middleware,
7275
path: route.path,

packages/cli/test/mcp-server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export const app = defineApp({
173173
{
174174
file: "./routes/dashboard.tsx",
175175
id: "dashboard",
176+
loaderCache: null,
176177
loaderFile: null,
177178
middleware: [],
178179
path: "/dashboard",

0 commit comments

Comments
 (0)