@ultimat3/render 23.0.0 → 24.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.
@@ -70,7 +70,8 @@ export type NativeReason =
70
70
  | 'hash'
71
71
  | 'method'
72
72
  | 'encoding'
73
- | 'file';
73
+ | 'file'
74
+ | 'unparsable';
74
75
 
75
76
  export type LinkVerdict =
76
77
  | { readonly kind: 'soft'; readonly url: string }
@@ -97,9 +98,12 @@ const native = (reason: NativeReason): { readonly kind: 'native'; reason: Native
97
98
  reason,
98
99
  });
99
100
 
100
- /** Same document, fragment only: the browser scrolls, no fetch. */
101
- const hashOnly = (to: URL, from: URL): boolean =>
102
- to.hash !== '' && to.pathname === from.pathname && to.search === from.search;
101
+ /**
102
+ * Same document, fragment only: the browser scrolls, no fetch. Asked of the HREF, not `to.hash`:
103
+ * an empty fragment (`<a href="#">`) parses to `hash === ''`, and was soft-navigated and re-fetched.
104
+ */
105
+ const hashOnly = (href: string, to: URL, from: URL): boolean =>
106
+ href.includes('#') && to.pathname === from.pathname && to.search === from.search;
103
107
 
104
108
  /**
105
109
  * The link rules, in the order a reader checks them. The SURFACE is not decided here: the URL
@@ -113,12 +117,15 @@ export function linkVerdict(facts: LinkFacts): LinkVerdict {
113
117
  if (facts.download) return native('download');
114
118
  if (facts.rel.split(/\s+/).includes('external')) return native('external');
115
119
  if (facts.reload) return native('opt-out');
120
+ // `a.href` is the raw attribute when it does not parse (`href="http://"`): the browser decides
121
+ // what such a link does, where `new URL` threw a bare `TypeError` out of the click handler.
122
+ if (!URL.canParse(facts.href)) return native('unparsable');
116
123
  const to = new URL(facts.href);
117
124
  const from = new URL(facts.current);
118
125
  if (to.origin !== from.origin || (to.protocol !== 'http:' && to.protocol !== 'https:')) {
119
126
  return native('cross-origin');
120
127
  }
121
- if (hashOnly(to, from)) return native('hash');
128
+ if (hashOnly(facts.href, to, from)) return native('hash');
122
129
  return { kind: 'soft', url: to.href };
123
130
  }
124
131
 
@@ -153,7 +160,7 @@ export type FormVerdict =
153
160
 
154
161
  /** What a `submit` tells the router — the submitter's `formmethod`/`formaction` already applied. */
155
162
  export interface FormFacts {
156
- /** Resolved action URL. */
163
+ /** Resolved action URL — or the raw attribute when it does not resolve (`'unparsable'`). */
157
164
  readonly action: string;
158
165
  readonly current: string;
159
166
  /** Lower case: `get`, `post` or `dialog`. */
@@ -175,6 +182,7 @@ export function formVerdict(facts: FormFacts): FormVerdict {
175
182
  if (facts.method !== 'get' && facts.method !== 'post') return native('method');
176
183
  // `text/plain` is the browser's own encoding, with no `fetch` body that reproduces it byte for byte.
177
184
  if (facts.method === 'post' && facts.enctype === 'text/plain') return native('encoding');
185
+ if (!URL.canParse(facts.action)) return native('unparsable');
178
186
  const to = new URL(facts.action);
179
187
  if (to.origin !== new URL(facts.current).origin) return native('cross-origin');
180
188
  if (facts.method === 'get') {
@@ -246,6 +254,14 @@ export function responseVerdict(facts: ResponseFacts): ResponseVerdict {
246
254
  }
247
255
  if (facts.status === 204) {
248
256
  if (facts.location === null) return { kind: 'stay' };
257
+ // The caller ASSIGNS this to `window.location`, so only http(s) is a place to go: a scheme
258
+ // that runs in the page would be script in the app's origin. `@ultimat3/http`'s `locationFor`
259
+ // refuses it at the source; this is the same rule for an answer something else wrote. The
260
+ // requested url is loaded as a document instead — a POST is never re-sent, so it fails.
261
+ if (!/^https?:\/\/./i.test(facts.location) || !URL.canParse(facts.location)) {
262
+ const reason = 'a location that is not http(s)';
263
+ return get ? load(facts.requested, reason) : { kind: 'failed', reason };
264
+ }
249
265
  if (origin(facts.location) !== origin(facts.requested))
250
266
  return load(facts.location, 'another origin');
251
267
  // The server asked for THIS url as a document: the route did not run, the browser runs it.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The client router's scroll keeping: the browser's own restoration is off, so the router restores
3
+ * a saved offset when a document LOADS onto one of its entries (reload, or Back after a full load),
4
+ * then saves the offset into the entry on screen as the visitor scrolls.
5
+ */
6
+
7
+ import { entryOf, saveEntryScroll } from './navigation-history';
8
+
9
+ export interface ScrollKeeper {
10
+ /** The offset, into the entry of the document on screen now. */
11
+ save(): void;
12
+ /** Drops a save still pending — it belongs to the entry just left. */
13
+ cancel(): void;
14
+ stop(): void;
15
+ }
16
+
17
+ /** How long a scroll must rest before it is saved: a browser caps `replaceState` calls. */
18
+ const SCROLL_SETTLE_MS = 150;
19
+
20
+ export function scrollKeeper(win: Window, rendered: () => string): ScrollKeeper {
21
+ win.history.scrollRestoration = 'manual';
22
+ // BEFORE the first save. With restoration manual, a reload or a Back that is a full load lands
23
+ // at the top, and the save below then overwrote the entry's offset with that 0 — the place the
24
+ // visitor left was lost twice. Only this document's own entry: one an app pushed has no offset.
25
+ const held = entryOf(win.history.state);
26
+ if (held !== undefined && held.doc === rendered()) {
27
+ win.scrollTo({
28
+ left: held.scroll[0],
29
+ top: held.scroll[1],
30
+ behavior: 'instant' as ScrollBehavior,
31
+ });
32
+ }
33
+ const save = (): void => saveEntryScroll(win, rendered());
34
+ save();
35
+ // Saved as the visitor scrolls, once the scroll settles — so FORWARD restores too.
36
+ let settle: number | undefined;
37
+ const cancel = (): void => win.clearTimeout(settle);
38
+ const onScroll = (): void => {
39
+ cancel();
40
+ settle = win.setTimeout(save, SCROLL_SETTLE_MS);
41
+ };
42
+ win.addEventListener('scroll', onScroll, { passive: true });
43
+ return {
44
+ save,
45
+ cancel,
46
+ stop() {
47
+ cancel();
48
+ win.removeEventListener('scroll', onScroll);
49
+ },
50
+ };
51
+ }
@@ -56,8 +56,15 @@ export function missingStylesheets(doc: Document, next: Document): readonly stri
56
56
  .filter((href) => !loaded.has(href));
57
57
  }
58
58
 
59
- /** Resolves once every sheet loaded OR failed — a missing sheet must not hang the navigation. */
60
- export function loadStylesheets(doc: Document, hrefs: readonly string[]): Promise<void> {
59
+ /**
60
+ * Resolves once every sheet loaded OR failed — a missing sheet must not hang the navigation. Each
61
+ * sheet joins `owned` as it is appended, so a swap that never happens still leaves it retirable.
62
+ */
63
+ export function loadStylesheets(
64
+ doc: Document,
65
+ hrefs: readonly string[],
66
+ owned: WeakSet<Element>,
67
+ ): Promise<void> {
61
68
  return Promise.all(
62
69
  hrefs.map(
63
70
  (href) =>
@@ -68,6 +75,7 @@ export function loadStylesheets(doc: Document, hrefs: readonly string[]): Promis
68
75
  link.onload = () => done();
69
76
  link.onerror = () => done();
70
77
  doc.head.append(link);
78
+ owned.add(link);
71
79
  }),
72
80
  ),
73
81
  ).then(() => undefined);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The client router's tie to the origin's other tabs: one `BroadcastChannel`, so a write or a new
3
+ * principal in one tab empties every tab's prefetch cache. Closed while the page sits in the
4
+ * back/forward cache (an open channel keeps a page out of it); reopened, cache emptied, on return.
5
+ */
6
+
7
+ import { onClientWrite, onRescope } from '@ultimat3/core/page';
8
+
9
+ /** One channel per origin: a principal change or a write in one tab empties every tab's cache. */
10
+ export const NAVIGATION_CHANNEL = 'ultimate:navigation';
11
+
12
+ export interface TabSync {
13
+ /** This tab and every other: a write or a new principal makes every held page suspect. */
14
+ forget(): void;
15
+ stop(): void;
16
+ }
17
+
18
+ /** `clear` empties this tab's cache; called for this tab's own events and every other tab's. */
19
+ export function tabSync(win: Window, clear: () => void): TabSync {
20
+ const open = (): BroadcastChannel | undefined => {
21
+ if (!('BroadcastChannel' in win)) return undefined;
22
+ const opened = new BroadcastChannel(NAVIGATION_CHANNEL);
23
+ opened.onmessage = clear;
24
+ return opened;
25
+ };
26
+ let channel = open();
27
+ const forget = (): void => {
28
+ clear();
29
+ channel?.postMessage('clear');
30
+ };
31
+ // `pagehide`, not `unload`: it fires for a page entering the back/forward cache too, and a page
32
+ // holding an open channel is refused by it — Back then reloads the page from the network.
33
+ const onHide = (): void => {
34
+ channel?.close();
35
+ channel = undefined;
36
+ };
37
+ // Restored from that cache: every message sent while it was frozen was missed, so nothing held
38
+ // can be trusted. A `pageshow` of a fresh load finds the channel open and leaves it.
39
+ const onShow = (event: PageTransitionEvent): void => {
40
+ if (!event.persisted || channel !== undefined) return;
41
+ channel = open();
42
+ clear();
43
+ };
44
+ win.addEventListener('pagehide', onHide);
45
+ win.addEventListener('pageshow', onShow);
46
+ const offWrite = onClientWrite(forget);
47
+ const offRescope = onRescope(forget);
48
+ return {
49
+ forget,
50
+ stop() {
51
+ win.removeEventListener('pagehide', onHide);
52
+ win.removeEventListener('pageshow', onShow);
53
+ offWrite();
54
+ offRescope();
55
+ onHide();
56
+ },
57
+ };
58
+ }
package/src/navigation.ts CHANGED
@@ -14,19 +14,15 @@
14
14
  * on documents whose surface opted in (`navigation: { client: [...] }` in `app.config.ts`).
15
15
  */
16
16
 
17
- import {
18
- CLIENT_BUILD_META,
19
- CLIENT_SCOPE_META,
20
- onClientWrite,
21
- onRescope,
22
- } from '@ultimat3/core/page';
17
+ import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
23
18
  import { navigationCache } from './navigation-cache';
24
19
  import {
25
20
  anchorOf,
26
21
  announcer,
27
- fieldText,
28
22
  focusMain,
23
+ formAction,
29
24
  formFields,
25
+ formPairs,
30
26
  handOver,
31
27
  linkFacts,
32
28
  type RunningTransition,
@@ -37,7 +33,6 @@ import {
37
33
  type EntryState,
38
34
  entryOf,
39
35
  STATE_KEY,
40
- saveEntryScroll,
41
36
  scrollAfter,
42
37
  withoutFragment,
43
38
  } from './navigation-history';
@@ -60,6 +55,7 @@ import {
60
55
  responseVerdict,
61
56
  reusable,
62
57
  } from './navigation-rules';
58
+ import { scrollKeeper } from './navigation-scroll';
63
59
  import {
64
60
  documentHead,
65
61
  loadStylesheets,
@@ -68,6 +64,7 @@ import {
68
64
  runScripts,
69
65
  swapDocument,
70
66
  } from './navigation-swap';
67
+ import { tabSync } from './navigation-tabs';
71
68
 
72
69
  export interface NavigateOptions {
73
70
  readonly method?: 'GET' | 'POST';
@@ -84,9 +81,6 @@ export interface NavigationRouter {
84
81
  stop(): void;
85
82
  }
86
83
 
87
- /** One channel per origin: a principal change or a write in one tab empties every tab's cache. */
88
- const CHANNEL = 'ultimate:navigation';
89
-
90
84
  interface RouterWindow extends Window {
91
85
  __xNavigation?: NavigationRouter;
92
86
  }
@@ -122,28 +116,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
122
116
  const live = announcer(doc);
123
117
  const owned = documentHead(doc);
124
118
  notePersisted(doc);
125
- win.history.scrollRestoration = 'manual';
126
-
127
- const channel = 'BroadcastChannel' in win ? new BroadcastChannel(CHANNEL) : undefined;
128
- if (channel !== undefined) channel.onmessage = () => cache.clear();
129
- /** This tab and every other: a write or a new principal makes every held page suspect. */
130
- const forget = (): void => {
131
- cache.clear();
132
- channel?.postMessage('clear');
133
- };
134
- const offWrite = onClientWrite(forget);
135
- const offRescope = onRescope(forget);
136
-
137
- const saveScroll = (): void => saveEntryScroll(win, rendered);
138
-
139
- saveScroll();
140
- // Saved as the visitor scrolls, once the scroll settles — so FORWARD restores too, and a
141
- // browser's cap on `replaceState` calls is never approached.
142
- let settle: number | undefined;
143
- const onScroll = (): void => {
144
- win.clearTimeout(settle);
145
- settle = win.setTimeout(saveScroll, 150);
146
- };
119
+ const tabs = tabSync(win, () => cache.clear());
120
+ const forget = tabs.forget;
121
+ const scroll = scrollKeeper(win, () => rendered);
147
122
 
148
123
  const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
149
124
  fetchDocument(win, doc, url, purpose, init);
@@ -172,12 +147,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
172
147
  signal: AbortSignal,
173
148
  ) => {
174
149
  if (method === 'POST') {
150
+ // Before AND once it settles, landed or not: a guess sent while the write was in flight
151
+ // may have rendered the old state, and a write that failed here may still have committed.
175
152
  forget();
176
- return request(url, 'soft', {
177
- method,
178
- ...(options.body === undefined ? {} : { body: options.body }),
179
- signal,
180
- });
153
+ const body = options.body === undefined ? {} : { body: options.body };
154
+ return request(url, 'soft', { method, ...body, signal }).finally(forget);
181
155
  }
182
156
  const held = cache.peek(url);
183
157
  cache.delete(url);
@@ -212,7 +186,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
212
186
  inflight = mine;
213
187
  win.clearTimeout(intent);
214
188
  navigatingTo = withoutFragment(url);
215
- if (options.history !== 'none') saveScroll();
189
+ if (options.history !== 'none') scroll.save();
216
190
  const progress = win.setTimeout(
217
191
  () => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
218
192
  NAVIGATION_PROGRESS_DELAY_MS,
@@ -274,11 +248,15 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
274
248
  if (next === null) return;
275
249
  const landed = `${withoutFragment(url)}${new URL(url).hash}`;
276
250
  try {
277
- await loadStylesheets(doc, missingStylesheets(doc, next));
251
+ // Owned from the moment they are appended: a navigation aborted after this point left its
252
+ // sheets in the head for good, styling every page after it — the next swap retires an
253
+ // owned sheet its page does not link.
254
+ await loadStylesheets(doc, missingStylesheets(doc, next), owned);
278
255
  if (mine.signal.aborted) return;
279
256
  let swapped: ReturnType<typeof swapDocument> | undefined;
280
- const track = (running: RunningTransition | undefined): void => {
281
- animating = running;
257
+ const track = (running: RunningTransition, finished: boolean): void => {
258
+ if (!finished) animating = running;
259
+ else if (animating === running) animating = undefined;
282
260
  };
283
261
  await transition(
284
262
  win,
@@ -375,7 +353,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
375
353
  const say = (attr: string, fallback: string): string =>
376
354
  submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
377
355
  const facts: FormFacts = {
378
- action: new URL(say('action', win.location.href) || win.location.href, doc.baseURI).href,
356
+ action: formAction(say('action', ''), win.location.href, doc.baseURI),
379
357
  current: win.location.href,
380
358
  method: say('method', 'get').toLowerCase(),
381
359
  enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
@@ -388,9 +366,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
388
366
  hasFile: [...fields.values()].some(
389
367
  (value: unknown) => value instanceof File && value.name !== '',
390
368
  ),
391
- fields: [...fields].map(
392
- ([name, value]: [string, unknown]) => [name, fieldText(value)] as const,
393
- ),
369
+ fields: formPairs(fields),
394
370
  };
395
371
  const verdict = formVerdict(facts);
396
372
  if (verdict.kind === 'native') return;
@@ -438,8 +414,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
438
414
  * screen it is fetched; when it is, only the scroll moves — the app keeps its own history.
439
415
  */
440
416
  const onPop = (event: PopStateEvent): void => {
441
- // A save still pending belongs to the entry just left.
442
- win.clearTimeout(settle);
417
+ scroll.cancel();
443
418
  const url = win.location.href;
444
419
  const entry = entryOf(event.state);
445
420
  const wanted = entry?.doc ?? withoutFragment(url);
@@ -464,7 +439,6 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
464
439
  // had its chance to `preventDefault`, which the rules then honour.
465
440
  win.addEventListener('click', onClick);
466
441
  win.addEventListener('submit', onSubmit);
467
- win.addEventListener('scroll', onScroll, { passive: true });
468
442
  doc.addEventListener('pointerover', onIntent, { passive: true });
469
443
  doc.addEventListener('pointerout', onLeave, { passive: true });
470
444
  doc.addEventListener('focusin', onIntent);
@@ -480,7 +454,6 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
480
454
  stop() {
481
455
  win.removeEventListener('click', onClick);
482
456
  win.removeEventListener('submit', onSubmit);
483
- win.removeEventListener('scroll', onScroll);
484
457
  doc.removeEventListener('pointerover', onIntent);
485
458
  doc.removeEventListener('pointerout', onLeave);
486
459
  doc.removeEventListener('focusin', onIntent);
@@ -489,9 +462,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
489
462
  doc.removeEventListener('pointerdown', onPress, { capture: true });
490
463
  doc.removeEventListener('pointerup', onRelease, { capture: true });
491
464
  doc.removeEventListener('pointercancel', onRelease, { capture: true });
492
- offWrite();
493
- offRescope();
494
- channel?.close();
465
+ scroll.stop();
466
+ tabs.stop();
495
467
  delete win.__xNavigation;
496
468
  },
497
469
  };
package/src/registry.ts CHANGED
@@ -5,12 +5,15 @@
5
5
  * from. Nothing downstream may keep its own list of routes.
6
6
  */
7
7
 
8
+ import type { CacheTag } from '@ultimat3/cache';
9
+ import { serializeTag, surrogateKeys } from '@ultimat3/cache';
8
10
  import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
9
- import { finiteCount } from '@ultimat3/core';
11
+ import { finiteCount, isUltimateError, renderFixLiteral } from '@ultimat3/core';
10
12
  import { byCodeUnit } from './code-unit-order';
11
13
  import {
12
14
  RouteDuplicateError,
13
15
  RouteFileInvalidError,
16
+ RouteModeInvalidError,
14
17
  RouteUnnormalizedError,
15
18
  SurfaceBoundaryError,
16
19
  } from './errors';
@@ -26,6 +29,8 @@ import {
26
29
  import type { RouteConfig, RouteData } from './route';
27
30
  import { isRouteConfig, tagKeys } from './route';
28
31
  import type { RouteComponent } from './route-component';
32
+ import type { CompiledPattern } from './route-pattern';
33
+ import { compilePattern } from './route-pattern';
29
34
  import type { Surface } from './surfaces';
30
35
  import { locateSurface } from './surfaces';
31
36
 
@@ -90,7 +95,6 @@ export interface RouteDescriptor {
90
95
  */
91
96
  readonly islandSources: readonly string[];
92
97
  readonly budgetJs: string | null;
93
- readonly budgetLcp: number | null;
94
98
  /**
95
99
  * Present on a route NO surface file declares: who mounted it and every permission that gates
96
100
  * it. Absent on a file route — so a reader never has to ask whether `file` is a path.
@@ -100,14 +104,6 @@ export interface RouteDescriptor {
100
104
 
101
105
  export type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
102
106
 
103
- export interface CompiledPattern {
104
- readonly source: string;
105
- readonly regex: RegExp;
106
- readonly keys: readonly string[];
107
- /** Higher wins when two patterns match the same pathname. */
108
- readonly specificity: number;
109
- }
110
-
111
107
  /**
112
108
  * `apps/web/site/blog/[slug]/page.tsx` → `{ surface: 'site', path: '/blog/:slug' }`.
113
109
  *
@@ -209,34 +205,6 @@ function toUrlSegment(segment: string): string {
209
205
  return segment;
210
206
  }
211
207
 
212
- export function compilePattern(path: string): CompiledPattern {
213
- const keys: string[] = [];
214
- let specificity = 0;
215
- const segments = path.split('/').filter((s) => s.length > 0);
216
-
217
- const parts = segments.map((segment) => {
218
- if (segment.startsWith('*')) {
219
- keys.push(segment.slice(1));
220
- specificity += 1;
221
- return '(.*)';
222
- }
223
- if (segment.startsWith(':')) {
224
- keys.push(segment.slice(1));
225
- specificity += 10;
226
- return '([^/]+)';
227
- }
228
- specificity += 100;
229
- return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
230
- });
231
-
232
- return {
233
- source: path,
234
- regex: new RegExp(`^/${parts.join('/')}/?$`),
235
- keys,
236
- specificity,
237
- };
238
- }
239
-
240
208
  const routes = new Map<string, RouteEntry>();
241
209
  /** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
242
210
  let described: readonly RouteDescriptor[] | undefined;
@@ -256,6 +224,27 @@ export interface RegisterRouteInput<TData = RouteData> {
256
224
  readonly component?: RouteComponent;
257
225
  }
258
226
 
227
+ /**
228
+ * A `revalidate.tags` entry goes out as a purge key on every response of the route
229
+ * (`render-isr.ts`), so one a CDN would split is refused HERE, naming the file. Asked of
230
+ * `@ultimat3/cache`'s own screen rather than restated: found at serve time it is a 500 on a
231
+ * public page, found at purge time it is a document nothing can clear.
232
+ */
233
+ function assertPurgeableTags(file: string, tags: readonly CacheTag[] | undefined): void {
234
+ for (const owned of tags ?? []) {
235
+ try {
236
+ surrogateKeys([owned], file);
237
+ } catch (error) {
238
+ if (!isUltimateError(error) || error.code !== 'X_CACHE_PURGE_FAILED') throw error;
239
+ const wire = renderFixLiteral(serializeTag(owned), '<the tag>');
240
+ throw new RouteModeInvalidError(
241
+ `${file} declares revalidate tag ${wire}, which cannot be a CDN purge key — a key is split on whitespace and commas and capped at 1024 bytes, so every response of the route would carry one no purge can name`,
242
+ `edit revalidate.tags in ${file}: replace ${wire} with a tag whose entity and id carry no whitespace or comma`,
243
+ );
244
+ }
245
+ }
246
+ }
247
+
259
248
  /** Register a route and enforce every invariant that needs the surrounding module. */
260
249
  export function registerRoute<TData = RouteData>(
261
250
  input: RegisterRouteInput<TData>,
@@ -292,6 +281,8 @@ export function registerRoute<TData = RouteData>(
292
281
  suspenseBoundaries,
293
282
  });
294
283
 
284
+ assertPurgeableTags(input.file, config.revalidate?.tags);
285
+
295
286
  const existing = routes.get(path);
296
287
  if (existing !== undefined && existing.file !== input.file) {
297
288
  throw new RouteDuplicateError(
@@ -359,6 +350,7 @@ export function registerMountedRoutes(
359
350
  for (const route of declared) {
360
351
  const file = routes.get(route.path)?.file;
361
352
  if (file !== undefined) throw mountCollision(route.path, file, mount);
353
+ assertPurgeableTags(mount.file, route.config.revalidate?.tags);
362
354
  }
363
355
  setMountedRoutes(mount, declared);
364
356
  described = undefined;
@@ -439,7 +431,6 @@ const descriptorOf = (
439
431
  islands: config.islands.map((spec) => spec.moduleId),
440
432
  islandSources: config.islands.map((spec) => spec.src),
441
433
  budgetJs: config.budget.js ?? null,
442
- budgetLcp: config.budget.lcp ?? null,
443
434
  });
444
435
 
445
436
  /** See `RouteDescriptor.personal`. */
@@ -11,7 +11,7 @@ import {
11
11
  IslandPropsInvalidError,
12
12
  PrerenderFailedError,
13
13
  } from './errors';
14
- import { escapeText, renderAttributes, VOID_ELEMENTS } from './html';
14
+ import { escapeHtml, renderAttributes, VOID_ELEMENTS } from './html';
15
15
  import { emitIslandAttributes, emitIslandProps } from './hydrate';
16
16
  import type { IslandNode } from './island';
17
17
  import { isIslandNode } from './island';
@@ -62,8 +62,8 @@ export interface RenderHtmlOptions {
62
62
  async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): Promise<string> {
63
63
  assertDepth(depth);
64
64
  if (value === null || value === undefined || value === false || value === true) return '';
65
- if (typeof value === 'string') return escapeText(value);
66
- if (typeof value === 'number' || typeof value === 'bigint') return escapeText(String(value));
65
+ if (typeof value === 'string') return escapeHtml(value);
66
+ if (typeof value === 'number' || typeof value === 'bigint') return escapeHtml(String(value));
67
67
  if (value instanceof Promise) return unwrap(await value, depth, walk);
68
68
  // Before the array branch, never after: an island node IS an array — the only object shape the
69
69
  // configured `JSX.Element` admits — so the generic branch would render its empty contents and
@@ -75,7 +75,7 @@ async function unwrap(value: unknown, depth: number, walk: RenderHtmlOptions): P
75
75
  }
76
76
  if (isJsxNode(value)) return renderNode(value.type, value.props, depth, walk);
77
77
  if (typeof value === 'function') return unwrap((value as () => unknown)(), depth + 1, walk);
78
- return escapeText(String(value));
78
+ return escapeHtml(String(value));
79
79
  }
80
80
 
81
81
  /**