@ultimat3/render 22.15.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.
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,19 +5,32 @@
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';
17
20
  import { assertModeInvariants, defaultIslandBudget } from './modes';
21
+ import type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
22
+ import {
23
+ clearMountedRoutes,
24
+ mountCollision,
25
+ mountedAt,
26
+ mountedRoutes,
27
+ setMountedRoutes,
28
+ } from './mounted-routes';
18
29
  import type { RouteConfig, RouteData } from './route';
19
30
  import { isRouteConfig, tagKeys } from './route';
20
31
  import type { RouteComponent } from './route-component';
32
+ import type { CompiledPattern } from './route-pattern';
33
+ import { compilePattern } from './route-pattern';
21
34
  import type { Surface } from './surfaces';
22
35
  import { locateSurface } from './surfaces';
23
36
 
@@ -82,16 +95,14 @@ export interface RouteDescriptor {
82
95
  */
83
96
  readonly islandSources: readonly string[];
84
97
  readonly budgetJs: string | null;
85
- readonly budgetLcp: number | null;
98
+ /**
99
+ * Present on a route NO surface file declares: who mounted it and every permission that gates
100
+ * it. Absent on a file route — so a reader never has to ask whether `file` is a path.
101
+ */
102
+ readonly mount?: RouteMount;
86
103
  }
87
104
 
88
- export interface CompiledPattern {
89
- readonly source: string;
90
- readonly regex: RegExp;
91
- readonly keys: readonly string[];
92
- /** Higher wins when two patterns match the same pathname. */
93
- readonly specificity: number;
94
- }
105
+ export type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
95
106
 
96
107
  /**
97
108
  * `apps/web/site/blog/[slug]/page.tsx` → `{ surface: 'site', path: '/blog/:slug' }`.
@@ -194,34 +205,6 @@ function toUrlSegment(segment: string): string {
194
205
  return segment;
195
206
  }
196
207
 
197
- export function compilePattern(path: string): CompiledPattern {
198
- const keys: string[] = [];
199
- let specificity = 0;
200
- const segments = path.split('/').filter((s) => s.length > 0);
201
-
202
- const parts = segments.map((segment) => {
203
- if (segment.startsWith('*')) {
204
- keys.push(segment.slice(1));
205
- specificity += 1;
206
- return '(.*)';
207
- }
208
- if (segment.startsWith(':')) {
209
- keys.push(segment.slice(1));
210
- specificity += 10;
211
- return '([^/]+)';
212
- }
213
- specificity += 100;
214
- return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
215
- });
216
-
217
- return {
218
- source: path,
219
- regex: new RegExp(`^/${parts.join('/')}/?$`),
220
- keys,
221
- specificity,
222
- };
223
- }
224
-
225
208
  const routes = new Map<string, RouteEntry>();
226
209
  /** The table `describeRoutes()` last built, dropped whenever a route registers or the registry clears. */
227
210
  let described: readonly RouteDescriptor[] | undefined;
@@ -241,6 +224,27 @@ export interface RegisterRouteInput<TData = RouteData> {
241
224
  readonly component?: RouteComponent;
242
225
  }
243
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
+
244
248
  /** Register a route and enforce every invariant that needs the surrounding module. */
245
249
  export function registerRoute<TData = RouteData>(
246
250
  input: RegisterRouteInput<TData>,
@@ -277,6 +281,8 @@ export function registerRoute<TData = RouteData>(
277
281
  suspenseBoundaries,
278
282
  });
279
283
 
284
+ assertPurgeableTags(input.file, config.revalidate?.tags);
285
+
280
286
  const existing = routes.get(path);
281
287
  if (existing !== undefined && existing.file !== input.file) {
282
288
  throw new RouteDuplicateError(
@@ -284,6 +290,8 @@ export function registerRoute<TData = RouteData>(
284
290
  `rename or delete one of them — the route table is keyed by URL`,
285
291
  );
286
292
  }
293
+ const claimed = mountedAt(path);
294
+ if (claimed !== undefined) throw mountCollision(path, input.file, claimed.mount);
287
295
 
288
296
  const entry: RouteEntry<TData> = {
289
297
  file: input.file,
@@ -329,8 +337,28 @@ function withIslandBudget<TData>(config: RouteConfig<TData>, surface: Surface):
329
337
  return Object.freeze(derived);
330
338
  }
331
339
 
340
+ /**
341
+ * Declare the routes a package MOUNTS — screens no surface file exists for, like the admin's — so
342
+ * the one route list carries them. Described, never rendered here: the mounting host serves them,
343
+ * and `routeEntries()` stays the pages a file declares. Re-declaring a `key` replaces its set,
344
+ * because `x dev` re-evaluates the module that declared it on every save.
345
+ */
346
+ export function registerMountedRoutes(
347
+ mount: RouteMountInput,
348
+ declared: readonly MountedRouteInput[],
349
+ ): void {
350
+ for (const route of declared) {
351
+ const file = routes.get(route.path)?.file;
352
+ if (file !== undefined) throw mountCollision(route.path, file, mount);
353
+ assertPurgeableTags(mount.file, route.config.revalidate?.tags);
354
+ }
355
+ setMountedRoutes(mount, declared);
356
+ described = undefined;
357
+ }
358
+
332
359
  export function clearRoutes(): void {
333
360
  routes.clear();
361
+ clearMountedRoutes();
334
362
  described = undefined;
335
363
  }
336
364
 
@@ -361,26 +389,50 @@ export function describeRoutes(): readonly RouteDescriptor[] {
361
389
  }
362
390
 
363
391
  function buildDescriptors(): RouteDescriptor[] {
364
- return routeEntries().map((entry) => ({
365
- path: entry.path,
366
- file: entry.file,
367
- surface: entry.surface,
368
- mode: entry.config.render,
369
- offline: entry.config.offline,
370
- hydrate: entry.config.hydrate,
371
- revalidateTags: tagKeys(entry.config.revalidate?.tags),
372
- revalidateTtl: entry.config.revalidate?.ttl ?? null,
373
- prerenderable: entry.config.prerender !== undefined,
374
- dynamic: entry.pattern.keys.length > 0,
375
- hasPolicy: entry.config.policy !== undefined,
376
- personal: isPersonal(entry.config),
377
- islands: entry.islands,
378
- islandSources: entry.config.islands.map((spec) => spec.src),
379
- budgetJs: entry.config.budget.js ?? null,
380
- budgetLcp: entry.config.budget.lcp ?? null,
381
- }));
392
+ const files = routeEntries().map((entry) =>
393
+ descriptorOf(entry.path, entry.file, entry.surface, entry.config, entry.pattern.keys.length),
394
+ );
395
+ const mounts = mountedRoutes().map(
396
+ (route): RouteDescriptor => ({
397
+ ...descriptorOf(
398
+ route.path,
399
+ route.mount.file,
400
+ route.mount.surface,
401
+ route.config,
402
+ compilePattern(route.path).keys.length,
403
+ ),
404
+ mount: { by: route.mount.by, permissions: [...route.permissions] },
405
+ }),
406
+ );
407
+ return mounts.length === 0
408
+ ? files
409
+ : [...files, ...mounts].sort((a, b) => byCodeUnit(a.path, b.path));
382
410
  }
383
411
 
412
+ const descriptorOf = (
413
+ path: string,
414
+ file: string,
415
+ surface: Surface,
416
+ config: RouteConfig,
417
+ params: number,
418
+ ): RouteDescriptor => ({
419
+ path,
420
+ file,
421
+ surface,
422
+ mode: config.render,
423
+ offline: config.offline,
424
+ hydrate: config.hydrate,
425
+ revalidateTags: tagKeys(config.revalidate?.tags),
426
+ revalidateTtl: config.revalidate?.ttl ?? null,
427
+ prerenderable: config.prerender !== undefined,
428
+ dynamic: params > 0,
429
+ hasPolicy: config.policy !== undefined,
430
+ personal: isPersonal(config),
431
+ islands: config.islands.map((spec) => spec.moduleId),
432
+ islandSources: config.islands.map((spec) => spec.src),
433
+ budgetJs: config.budget.js ?? null,
434
+ });
435
+
384
436
  /** See `RouteDescriptor.personal`. */
385
437
  function isPersonal(config: RouteConfig): boolean {
386
438
  if (config.policy !== undefined || config.render === 'stream') return true;
@@ -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
  /**