@ultimat3/render 10.0.0 → 11.0.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/CLAUDE.md CHANGED
@@ -57,6 +57,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
57
57
  | Island collection | per render, passed as `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context — two concurrent requests would bill one page for the other's JS, and `assertNoPerRequestState` refuses a live context under `static` anyway. |
58
58
  | A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. This package's copy stopped at `kb`, so a 5 MB route read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa`'s own copy said `5mb` for the same bytes — two halves of one build disagreeing about the size of one artifact. Still on this barrel, because `@ultimat3/cli`'s budget reporter reads it beside the route table. |
59
59
  | Island bytes | **not this package's**, `As of 2026-08-23`. `routeJsBytes`, `graphFor`, `checkBudget`, `checkBudgets`, `assertBudget` and the `Island` / `BundleGraph` / `RouteBytes` / `BudgetReport` types were exported from the barrel and called by NOTHING outside this package's own tests — the near-miss is `@ultimat3/cli`'s own `checkBudgets` in `packages/cli/src/budgets.ts`, which measures the EMITTED document and is the gate that runs. Deleted rather than wired, because two answers to "what does this route weigh" — one of them never asked — is axiom 1, and a build error nothing calls is not a build error. What survives here is the budget GRAMMAR (`parseByteBudget`, which the CLI does import) and `defaultIslandBudget`, which `registerRoute` reaches. **Breaking**: the six functions and five types are gone from the public API. |
60
+ | The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit, one per non-empty subset of the three strategies, seven in all. It is emitted INLINE in every document carrying an island, and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so under the enforced policy a container serves (`dev: false`) **no island ever booted** — invisible in `x dev`, where the policy is report-only. `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot, the mirror of `style-csp.ts`. Hashes and not a nonce, for `cspHashSource`'s own reason: a `render: 'static'` page is a file on disk. Never restate the concatenation — `runtimeBody` is the one place the served text and the hashed text are the same string. **Still uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>`, whose body is per-response and cannot be hashed. Unreachable today (`dev-render.ts` passes `holes: []`), and the first real hole needs a nonce, not a hash. |
60
61
  | Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`, so a document assembler has exactly one thing left to remember: `hydrateRuntime(directives)`. |
61
62
  | Island boot | `el.__x` holds the boot PROMISE, never a boolean. As a flag, a second interaction while the chunk was still loading got a resolved promise back and the replay queue flushed into an island that had not mounted — the events went nowhere and the listeners were already removed. |
62
63
  | Island mount markers | `data-x-mounted=""` when `mount()` RESOLVED, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`, because the server does not know the answer. `el.__x` alone cannot carry this: it is assigned when `import()` is CALLED, so a chunk still downloading and one whose `mount()` threw were the same observable, and telling those apart is what `x shot` gates on. Two attributes rather than two values of one, so `[data-x-mounted]` never counts a failure as a success. The rejection handler RETHROWS — swallowing it resolves `el.__x` and the interaction runtime flushes its replay queue into an island that never mounted, which is the row above reintroduced one layer out. Costs 129 B of shared prelude: `idle` 774, `visible` 846, `interaction` 1,067 (`As of 2026-08-23`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from). |
@@ -72,6 +73,9 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
72
73
  | "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below both `modes.ts` and `render-isr.ts` (importing the latter from the former is a cycle through `registry.ts`). `hasRevalidateTrigger` accepted any non-empty string, so `revalidate: { ttl: '5 minutes' }` passed registration and parsed to `null` at serve time: generated ONCE, served for the life of the process, while the CDN was told `s-maxage=60` — the exact costume the `isr`-needs-a-trigger check refuses. |
73
74
  | A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts` — never `error instanceof Error ? error.message : String(error)`. A component that throws `Object.create(null)` escaped as a bare `TypeError` and one whose `message` getter throws as a bare `Error`, where `X_PRERENDER_FAILED` naming the file belongs. `bun run error-render` does NOT see this shape: it follows a direct interpolation, not a value laundered through a file-local `describe()` helper. |
74
75
  | `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `new URL(ctx.url)` inside the catch made a relative `ctx.url` — what a prerender pass, `x build` and every test harness hand in — throw a bare `TypeError` on the one path whose job is a coded error. `pathnameOf` never throws. |
76
+ | The ISR key | `isrKey(url, locale)` — pathname, the negotiated LOCALE in the reserved `__x_locale` parameter, then the query, params sorted. The locale is a REQUIRED argument, so every call site has to answer: a document is rendered with `<html lang>` and every `t()` in the request's own locale, so one entry per path served visitor 2 the document negotiated for visitor 1 — for the whole TTL, and `s-maxage` told the CDN to do the same. `examples/dummy` ships en + es and three `isr` routes, so it was all three. A parameter and not a prefix because `routePathOf` splits a key at its `?`: a `es:/blog` key matches no route, so `descriptorFor` answers `undefined` and a declared `ttl` silently becomes tag-only. The time zone is deliberately NOT a dimension — unbounded where a locale set is declared — so a date on an `isr` page belongs in a zone the page itself names. `toResult` emits `vary: accept-language` for the CDN half; the rest of the shared key is added by `@ultimat3/http`'s `cache-headers` stage, which sees the actor this function cannot. |
77
+ | A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set` — the same mechanism `CacheStack`'s read-through fill uses, never a second one grown here. `regenerate` rendered and then wrote `{ stale: false }` unconditionally, so a `markStale` arriving in between was ERASED by HTML built from pre-write rows; for a tag-only route `isFresh` is then true forever and the process serves it for the rest of its life. `registerPath` runs BEFORE the render for the other half: `revalidateByTags` reads the graph, so a bust could not see a cold path whose first render was still in flight. |
78
+ | Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })`. `set` means "this page was just generated" and the default store orders eviction by exactly that, so the read-modify-write made the STALEST page the newest: a tag bust protected the pages that most needed regenerating and evicted the freshest one instead. **Breaking**: `markStale` is a required member of `IsrStore`. |
75
79
  | ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first — a route table supports `:params` and `*`, so `/blog/:slug` retains one full HTML string per slug ever requested, 404-shaped ones included. |
76
80
  | Route path from file | ONE reader of the surface segment: `locateSurface()` answers which surface AND where it starts. `registry.ts` sliced at `indexOf('app/')` instead, which matched inside `myapp/`, so every route under `apps/myapp/app/` served at `/app/…`. Never re-derive the offset from the surface NAME. |
77
81
  | An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts` is the one reader. `decodeURIComponent('%zz')` is a bare `URIError`, so a typo in a path segment was a 500 where `@ultimat3/http`'s router already answers "this branch does not match". A literal route matching the same text still wins. `router-client.ts` was the second reader and went with `createRouter`. |
@@ -81,6 +85,7 @@ for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upwa
81
85
  | Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
82
86
  | A gated route is never a cached one | `modes.ts` refuses `policy` on BOTH `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `isr` was not refused until 2026-08 and `dev-render.ts` keys the cache on `url.pathname` alone — no actor, no query string — so a gated ISR route rendered the first actor's document and served it to every later actor who passed the same policy. Keying on more is a trap, not a fix: the key would have to enumerate everything a `policy` and a `load` can read. `ssr` is the one gated mode there is. |
83
87
  | Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
88
+ | Who owns `cache-control` | a render mode states the MODE's intent; `@ultimat3/http`'s `cache-headers` stage makes the final answer and may overrule it. `ssrHeaders` writes `s-maxage=30` for any route without a `policy` — and `meta.auth` is `'public' \| 'required'`, so the page that greets a signed-in visitor by name is a `'public'` route whose own header offered it to a CDN. This package cannot see the actor and must never try: the fix is not a second actor check here. |
84
89
  | Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. The client half runs in an island chunk, which `@ultimat3/cli`'s `solid-loader.ts` compiles with `babel-preset-solid`: Solid's reactivity is a COMPILE-time contract, so nothing this package could inject would substitute for it. `router-client.ts` was the one file built on that premise ("inject primitives") and it never had a caller. |
85
90
  | Root element | `ROOT_ELEMENT_ID` (`render-html.ts`), the id every document's body wraps its component in. It was `SPA_ROOT_ID` in `render-spa.ts`, naming a mode that never used it and that no longer exists. |
86
91
  | The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once — `index.ts` until the barrel split, and it cannot be there again: the client barrel would carry `sass` and `node:url`. A plugin only affects modules loaded after it, so a second install point is a page that renders in one entry point and not another. Anything that loads an app's `.tsx` reaches `@ultimat3/render/server` first, which is why `packages/cli/src/app-load.ts` imports it for the side effect and nothing else. |
package/README.md CHANGED
@@ -363,7 +363,7 @@ side effect. Anything that loads an app's source — `x dev`, `x build`, `server
363
363
  | `renderSsr`†, `streamResult`† | the per-request modes |
364
364
  | `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
365
365
  | `installRenderLoader`†, `compileStylesheet`† | the `.tsx`/`.scss` loaders, installed on import |
366
- | `emitIslandAttributes`, `hydrateRuntime` | the four hydration strategies |
366
+ | `emitIslandAttributes`, `hydrateRuntime`, `HYDRATE_RUNTIME_BODIES` | the four hydration strategies, and every body the runtime script can hold — a host hashes that list into `script-src`, because the runtime is emitted inline and no `render: 'static'` file can receive a nonce |
367
367
  | `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
368
368
  | `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
369
369
  | `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "10.0.0",
3
+ "version": "11.0.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,10 +36,10 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "10.0.0",
40
- "@ultimat3/core": "10.0.0",
41
- "@ultimat3/i18n": "10.0.0",
42
- "@ultimat3/seo": "10.0.0",
39
+ "@ultimat3/cache": "11.0.0",
40
+ "@ultimat3/core": "11.0.0",
41
+ "@ultimat3/i18n": "11.0.0",
42
+ "@ultimat3/seo": "11.0.0",
43
43
  "sass": "1.102.0"
44
44
  }
45
45
  }
package/src/hydrate.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { HydrateStrategy } from '@ultimat3/core';
9
+ import { HYDRATE_STRATEGIES } from '@ultimat3/core';
9
10
  import { escapeAttribute, escapeJsonContent } from './html';
10
11
 
11
12
  export interface IslandDirective {
@@ -85,7 +86,10 @@ export function emitIslandAttributes(directive: IslandDirective): string {
85
86
  export function emitIslandProps(directive: IslandDirective): string {
86
87
  if (directive.props === undefined || directive.strategy === 'never') return '';
87
88
  return (
88
- `<script type="application/json" data-x-props="${directive.islandId}">` +
89
+ // The id goes through the SAME escaper every other attribute in this file does. Safe today —
90
+ // `islandModuleId` reduces to `[a-z0-9-]` — which is exactly why it costs nothing to route it
91
+ // now, rather than after an id starts being derived from something an author did not type.
92
+ `<script type="application/json" data-x-props="${escapeAttribute(directive.islandId)}">` +
89
93
  `${escapeJsonContent(JSON.stringify(directive.props))}</script>`
90
94
  );
91
95
  }
@@ -169,6 +173,44 @@ const RUNTIME_PARTS: Readonly<Record<Exclude<HydrateStrategy, 'never'>, string>>
169
173
  interaction: RUNTIME_INTERACTION,
170
174
  };
171
175
 
176
+ /**
177
+ * Emission order, and the order the subsets below are enumerated in. Derived from core's one
178
+ * declaration of the vocabulary, never restated: a second literal of this set is what
179
+ * `bun run render-modes` refuses, and it refuses it on the MEMBERS, not on the name.
180
+ */
181
+ const RUNTIME_ORDER: readonly Exclude<HydrateStrategy, 'never'>[] = HYDRATE_STRATEGIES.filter(
182
+ (strategy): strategy is Exclude<HydrateStrategy, 'never'> => strategy !== 'never',
183
+ );
184
+
185
+ /**
186
+ * The TEXT of the runtime script for exactly these strategies — the body a CSP `script-src` hash
187
+ * is taken over. Split out of `hydrateRuntime` so the served document and the policy that admits
188
+ * it read one function: a second copy of this concatenation is a hash that stops matching the
189
+ * moment the runtime changes, and the failure it produces is an island that never boots on a page
190
+ * that otherwise looks correct.
191
+ */
192
+ const runtimeBody = (needed: ReadonlySet<Exclude<HydrateStrategy, 'never'>>): string =>
193
+ [
194
+ RUNTIME_PRELUDE,
195
+ ...RUNTIME_ORDER.filter((strategy) => needed.has(strategy)).map(
196
+ (strategy) => RUNTIME_PARTS[strategy],
197
+ ),
198
+ ].join('\n');
199
+
200
+ /**
201
+ * Every body `hydrateRuntime` can emit: one per non-empty subset of the three strategies, seven in
202
+ * all, deterministic. A policy that admits inline script by HASH has to enumerate them before the
203
+ * socket opens — the alternative is a per-response nonce, which a `render: 'static'` page (a file
204
+ * on disk) can never receive. Enumerated rather than derived from the route table because the
205
+ * runtime is a function of the SET a document needs, and a table read at boot cannot answer for
206
+ * a document assembled later.
207
+ */
208
+ export const HYDRATE_RUNTIME_BODIES: readonly string[] = Array.from(
209
+ { length: 2 ** RUNTIME_ORDER.length - 1 },
210
+ (_unused, index) =>
211
+ runtimeBody(new Set(RUNTIME_ORDER.filter((_s, bit) => (((index + 1) >> bit) & 1) === 1))),
212
+ );
213
+
172
214
  /**
173
215
  * Emit only the runtime the page's islands actually use. A page of `never` islands gets
174
216
  * the empty string — an unused strategy must not cost a byte.
@@ -176,10 +218,7 @@ const RUNTIME_PARTS: Readonly<Record<Exclude<HydrateStrategy, 'never'>, string>>
176
218
  export function hydrateRuntime(directives: readonly IslandDirective[]): string {
177
219
  const needed = requiredStrategies(directives);
178
220
  if (needed.size === 0) return '';
179
- const parts = (['idle', 'visible', 'interaction'] as const)
180
- .filter((strategy) => needed.has(strategy))
181
- .map((strategy) => RUNTIME_PARTS[strategy]);
182
- return `<script type="module">${RUNTIME_PRELUDE}\n${parts.join('\n')}</script>`;
221
+ return `<script type="module">${runtimeBody(needed)}</script>`;
183
222
  }
184
223
 
185
224
  /** Rough emitted size of the hydration runtime for a page, for the budget check. */
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ export {
57
57
  DEFAULT_REPLAY_EVENTS,
58
58
  emitIslandAttributes,
59
59
  emitIslandProps,
60
+ HYDRATE_RUNTIME_BODIES,
60
61
  hydrateRuntime,
61
62
  hydrateRuntimeBytes,
62
63
  IDLE_HYDRATE_TIMEOUT_MS,
package/src/render-isr.ts CHANGED
@@ -9,8 +9,10 @@ import type { CacheTag, Revalidator } from '@ultimat3/cache';
9
9
  import {
10
10
  dependentsOfKind,
11
11
  invalidateTags,
12
+ markInvalidated,
12
13
  registerDependent,
13
14
  registerRevalidator,
15
+ sampleFence,
14
16
  unregisterDependent,
15
17
  } from '@ultimat3/cache';
16
18
  import { logger, renderThrowable } from '@ultimat3/core';
@@ -40,6 +42,13 @@ export interface IsrEntry {
40
42
  export interface IsrStore {
41
43
  get(path: string): IsrEntry | undefined;
42
44
  set(entry: IsrEntry): void;
45
+ /**
46
+ * Mark a held page stale IN PLACE — `false` when the store does not hold it. Its own method and
47
+ * not `set({ ...entry, stale: true })`, because `set` means "this page was just generated" and a
48
+ * store is entitled to order its eviction by that: the read-modify-write made the STALEST page
49
+ * the newest, so a tag bust protected exactly the pages that most needed regenerating.
50
+ */
51
+ markStale(path: string): boolean;
43
52
  delete(path: string): void;
44
53
  paths(): readonly string[];
45
54
  }
@@ -73,6 +82,14 @@ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
73
82
  map.delete(oldest.value);
74
83
  }
75
84
  },
85
+ // In place: `map.set` on a key the Map already holds keeps its position, and that position is
86
+ // the eviction order. Never `delete` + `set` here — that is the bug this method exists to fix.
87
+ markStale: (path) => {
88
+ const entry = map.get(path);
89
+ if (entry === undefined) return false;
90
+ map.set(path, { ...entry, stale: true });
91
+ return true;
92
+ },
76
93
  delete: (path) => {
77
94
  map.delete(path);
78
95
  },
@@ -81,20 +98,36 @@ export function memoryIsrStore(options: MemoryIsrStoreOptions = {}): IsrStore {
81
98
  }
82
99
 
83
100
  /**
84
- * The ISR store key for one request URL: pathname plus the query, **params sorted**.
101
+ * The reserved query parameter the negotiated locale rides in. A parameter and not a prefix
102
+ * because `routePathOf` splits a key at its `?`: a `es:/blog` key would match no route, so
103
+ * `descriptorFor` would answer `undefined` and a declared `revalidate: { ttl }` would silently
104
+ * become tag-only. Reserved spelling, so an app's own `?locale=` stays its own dimension.
105
+ */
106
+ export const ISR_LOCALE_PARAM = '__x_locale';
107
+
108
+ /**
109
+ * The ISR store key for one request URL: pathname, the negotiated LOCALE, and the query, **params
110
+ * sorted**.
85
111
  *
86
112
  * Exported because deriving it is the caller's job and there may only be ONE derivation — a
87
113
  * server that keyed on `url.pathname` while the store believed it held a whole URL is the shape
88
114
  * of #171. Sorting makes `?a=1&b=2` and `?b=2&a=1` one entry rather than two renders of one page.
89
115
  *
116
+ * The locale is REQUIRED, and required as an argument rather than read from the ambient context so
117
+ * that every call site has to answer: a document is rendered with `<html lang>` and every `t()` in
118
+ * the request's own locale, so one entry per path served visitor 2 the document negotiated for
119
+ * visitor 1 — for the whole TTL, and with `s-maxage` telling the CDN to do the same. The time zone
120
+ * is deliberately NOT a dimension: it is unbounded where a locale set is declared, and an `isr`
121
+ * page is a shared artifact, so a date on one belongs in an explicit zone the page itself names.
122
+ *
90
123
  * A query-carrying URL therefore gets its own entry, which is correct and is not free: a crawler
91
124
  * appending `?utm_source=…` mints one entry per value. That is bounded, not unbounded —
92
125
  * `DEFAULT_ISR_MAX_ENTRIES` evicts least-recently-generated first — and a bounded cache that
93
126
  * thrashes is the right failure next to an unbounded one that answers the wrong document.
94
127
  */
95
- export function isrKey(url: URL): string {
96
- if (url.search === '') return url.pathname;
128
+ export function isrKey(url: URL, locale: string): string {
97
129
  const params = new URLSearchParams(url.search);
130
+ params.set(ISR_LOCALE_PARAM, locale);
98
131
  params.sort();
99
132
  return `${url.pathname}?${params.toString()}`;
100
133
  }
@@ -213,6 +246,19 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
213
246
 
214
247
  const descriptor = descriptorFor(path);
215
248
  const work = (async (): Promise<IsrEntry> => {
249
+ // BEFORE the render, not after: `revalidateByTags` reads the graph, so a bust arriving while
250
+ // a cold path's first render was in flight could not see the page it was invalidating —
251
+ // which is the window in which the bust that matters most arrives.
252
+ registerPath(path, descriptor);
253
+ // Sampled before the render for the reason `@ultimat3/cache`'s read-through fill samples
254
+ // before its `load()` (`tiers.ts`): the HTML below is built from rows read in the past, and
255
+ // a `markStale` landing in between was then ERASED by `store.set({ stale: false })`. For a
256
+ // tag-only route `isFresh` is true forever, so the process went on serving pre-write HTML
257
+ // for the rest of its life. One mechanism, not a second one grown here.
258
+ const fence = sampleFence({
259
+ key: path,
260
+ tags: (descriptor?.revalidateTags ?? []).map(parseWireTag),
261
+ });
216
262
  const html = await render(path);
217
263
  const entry: IsrEntry = {
218
264
  path,
@@ -222,8 +268,10 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
222
268
  ttlMs: parseTtlMs(descriptor?.revalidateTtl),
223
269
  stale: false,
224
270
  };
225
- store.set(entry);
226
- registerPath(path, descriptor);
271
+ // Refused, never published stale-flagged: the next request re-renders from rows that now
272
+ // include the write, where a stored-but-stale entry would serve this pre-write body once
273
+ // more before doing the same thing.
274
+ if (fence.isValid()) store.set(entry);
227
275
  forgetEvictedPaths();
228
276
  return entry;
229
277
  })();
@@ -234,10 +282,11 @@ export function createIsrController(options: IsrControllerOptions = {}): IsrCont
234
282
  }
235
283
 
236
284
  function markStale(path: string): boolean {
237
- const entry = store.get(path);
238
- if (entry === undefined) return false;
239
- store.set({ ...entry, stale: true });
240
- return true;
285
+ // The mark is recorded whether or not the store holds the page: a regeneration already in
286
+ // flight for a path this store has never held is exactly the case the fence above exists for,
287
+ // and `invalidateTags`' own fanout only marks the TAGS.
288
+ markInvalidated({ key: path });
289
+ return store.markStale(path);
241
290
  }
242
291
 
243
292
  return {
@@ -362,6 +411,11 @@ function toResult(entry: IsrEntry, buildId: string, servedStale = false): Render
362
411
  const headers: Record<string, string> = {
363
412
  ...staticHeaders(entry.hash, buildId),
364
413
  'cache-control': cacheControl(entry.ttlMs),
414
+ // The store keys on the locale; a shared cache in front of it has to as well, or the CDN
415
+ // repeats the bug this entry was split to fix. `ssrHeaders`' own line, for the same reason.
416
+ // The rest of the shared key — the cookie, the zone — is added by `@ultimat3/http`'s
417
+ // `cache-headers` stage, which sees the actor this function cannot.
418
+ vary: 'accept-language',
365
419
  };
366
420
  if (servedStale) headers['x-ultimate-isr'] = 'stale';
367
421
  return { status: 200, headers, body: entry.html };
package/src/server.ts CHANGED
@@ -43,6 +43,7 @@ export type {
43
43
  export {
44
44
  createIsrController,
45
45
  DEFAULT_ISR_MAX_ENTRIES,
46
+ ISR_LOCALE_PARAM,
46
47
  invalidateAndRevalidate,
47
48
  isrKey,
48
49
  memoryIsrStore,