@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.
- package/CLAUDE.md +11 -5
- package/README.md +19 -5
- package/package.json +6 -6
- package/src/asset.ts +5 -2
- package/src/css-modules.ts +58 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +2 -2
- package/src/head.ts +9 -10
- package/src/html.ts +13 -19
- package/src/hydrate.ts +3 -3
- package/src/index.ts +2 -2
- package/src/island-collector.ts +2 -1
- package/src/island.ts +1 -1
- package/src/modes.ts +14 -3
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-rules.ts +22 -6
- package/src/navigation-scroll.ts +51 -0
- package/src/navigation-swap.ts +10 -2
- package/src/navigation-tabs.ts +58 -0
- package/src/navigation.ts +25 -53
- package/src/registry.ts +30 -39
- package/src/render-html.ts +4 -4
- package/src/render-isr.ts +127 -65
- package/src/render-static.ts +20 -6
- package/src/render-stream.ts +6 -5
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +10 -18
- package/src/server.ts +1 -0
- package/src/static-path.ts +15 -2
- package/src/type-pins.tsx +4 -3
package/src/navigation-rules.ts
CHANGED
|
@@ -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
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
package/src/navigation-swap.ts
CHANGED
|
@@ -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
|
-
/**
|
|
60
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
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
|
-
|
|
177
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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`. */
|
package/src/render-html.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
IslandPropsInvalidError,
|
|
12
12
|
PrerenderFailedError,
|
|
13
13
|
} from './errors';
|
|
14
|
-
import {
|
|
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
|
|
66
|
-
if (typeof value === 'number' || typeof value === 'bigint') return
|
|
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
|
|
78
|
+
return escapeHtml(String(value));
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|