@ultimat3/render 23.0.0 → 25.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 +18 -11
- package/README.md +38 -15
- package/package.json +8 -8
- package/src/asset.ts +5 -2
- package/src/client-path-style-tag.ts +0 -3
- package/src/client-scope-tag.ts +0 -3
- package/src/client-sync-tags.ts +0 -3
- package/src/code-unit-order.ts +1 -1
- package/src/css-modules.ts +59 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +3 -3
- package/src/head.ts +12 -20
- package/src/html.ts +10 -22
- package/src/hydrate.ts +34 -13
- package/src/index.ts +12 -25
- package/src/island-collector.ts +13 -5
- package/src/island-hold.ts +56 -0
- package/src/island-props.ts +1 -1
- package/src/island.ts +6 -2
- package/src/modes.ts +14 -3
- package/src/module-loader.ts +19 -14
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-fetch.ts +12 -11
- package/src/navigation-rules.ts +37 -27
- 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 +44 -63
- package/src/registry.ts +37 -46
- package/src/render-html.ts +8 -6
- package/src/render-isr.ts +131 -69
- package/src/render-ssr.ts +2 -1
- package/src/render-static.ts +21 -7
- package/src/render-stream.ts +7 -7
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +13 -19
- package/src/server.ts +2 -1
- package/src/static-path.ts +15 -2
- package/src/stylesheet-path.ts +34 -0
- package/src/type-pins.tsx +4 -3
package/src/navigation-fetch.ts
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* page's text, or a non-page's bytes to hand over (a prefetch's are dropped unread).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
|
|
9
8
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
CLIENT_BUILD_META,
|
|
10
|
+
CLIENT_NAVIGATION_HEADER,
|
|
11
|
+
CLIENT_NAVIGATION_LOCATION_HEADER,
|
|
12
|
+
CLIENT_NAVIGATION_SCOPE_HEADER,
|
|
13
|
+
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
14
|
+
CLIENT_SCOPE_META,
|
|
15
|
+
} from '@ultimat3/core/page';
|
|
16
|
+
import { NAVIGATION_META } from './navigation-rules';
|
|
16
17
|
|
|
17
18
|
/** One fetched answer, read once — a prefetch and the click after it share it. */
|
|
18
19
|
export interface Answer {
|
|
@@ -41,11 +42,11 @@ export function fetchDocument(
|
|
|
41
42
|
): Promise<Answer> {
|
|
42
43
|
const headers: Record<string, string> = {
|
|
43
44
|
accept: 'text/html,application/xhtml+xml',
|
|
44
|
-
[
|
|
45
|
-
[
|
|
45
|
+
[CLIENT_NAVIGATION_HEADER]: purpose,
|
|
46
|
+
[CLIENT_NAVIGATION_SURFACE_HEADER]: metaOf(doc, NAVIGATION_META) ?? '',
|
|
46
47
|
};
|
|
47
48
|
const scope = metaOf(doc, CLIENT_SCOPE_META);
|
|
48
|
-
if (scope !== null) headers[
|
|
49
|
+
if (scope !== null) headers[CLIENT_NAVIGATION_SCOPE_HEADER] = scope;
|
|
49
50
|
// GET only: a skewed GET is refused before any route runs; a POST is never refused mid-submit.
|
|
50
51
|
const build = metaOf(doc, CLIENT_BUILD_META);
|
|
51
52
|
if ((init.method ?? 'GET') === 'GET' && build !== null) headers[CLIENT_BUILD_META] = build;
|
|
@@ -61,7 +62,7 @@ export function fetchDocument(
|
|
|
61
62
|
if (purpose === 'prefetch') await response.body?.cancel();
|
|
62
63
|
else body = await response.blob();
|
|
63
64
|
}
|
|
64
|
-
const location = response.headers.get(
|
|
65
|
+
const location = response.headers.get(CLIENT_NAVIGATION_LOCATION_HEADER);
|
|
65
66
|
return {
|
|
66
67
|
status: response.status,
|
|
67
68
|
opaqueRedirect,
|
package/src/navigation-rules.ts
CHANGED
|
@@ -1,33 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which clicks and submits the client router takes, and which it leaves to the browser — as pure
|
|
3
3
|
* functions over plain facts, so every rule is testable without a DOM and the router
|
|
4
|
-
* (`navigation.ts`) only gathers the facts. Also the one declaration of every attribute
|
|
5
|
-
*
|
|
4
|
+
* (`navigation.ts`) only gathers the facts. Also the one declaration of every attribute and event
|
|
5
|
+
* name the router shares with the documents the server renders and the apps that listen; the
|
|
6
|
+
* router's headers are core's (`CLIENT_NAVIGATION_*_HEADER`), imported where they are sent.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
-
CLIENT_NAVIGATION_HEADER,
|
|
10
|
-
CLIENT_NAVIGATION_LOCATION_HEADER,
|
|
11
|
-
CLIENT_NAVIGATION_SCOPE_HEADER,
|
|
12
|
-
CLIENT_NAVIGATION_SURFACE_HEADER,
|
|
13
|
-
} from '@ultimat3/core/page';
|
|
14
|
-
|
|
15
9
|
/**
|
|
16
10
|
* `<meta name="ultimate-navigation" content="<app>:<surface>">` — present only on an opted-in
|
|
17
11
|
* surface. The app's name is part of it, so two apps on one origin (web and admin) are two routers.
|
|
18
12
|
*/
|
|
19
13
|
export const NAVIGATION_META = 'ultimate-navigation';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
24
|
-
export const NAVIGATION_HEADER = CLIENT_NAVIGATION_HEADER;
|
|
25
|
-
/** The `<app>:<surface>` of the document the router runs in, on every router fetch. */
|
|
26
|
-
export const NAVIGATION_SURFACE_HEADER = CLIENT_NAVIGATION_SURFACE_HEADER;
|
|
27
|
-
/** The document's principal (`ultimate-scope`), when it carries one. */
|
|
28
|
-
export const NAVIGATION_SCOPE_HEADER = CLIENT_NAVIGATION_SCOPE_HEADER;
|
|
29
|
-
/** On a `204`: load this URL with a real navigation — the server's hand-over. */
|
|
30
|
-
export const NAVIGATION_LOCATION_HEADER = CLIENT_NAVIGATION_LOCATION_HEADER;
|
|
14
|
+
// The four router HEADERS are core's (`CLIENT_NAVIGATION_HEADER`, `…_SURFACE_HEADER`,
|
|
15
|
+
// `…_SCOPE_HEADER`, `…_LOCATION_HEADER`), imported by the fetcher and by `@ultimat3/http`'s gate —
|
|
16
|
+
// 25.0.0 deleted the `NAVIGATION_*_HEADER` aliases this file published for them (`X_HELPER_COPY`).
|
|
31
17
|
/** On a link or form: always a full document load. */
|
|
32
18
|
export const NAVIGATION_RELOAD_ATTRIBUTE = 'data-x-reload';
|
|
33
19
|
/** On a link: followed softly, never fetched before it is clicked. */
|
|
@@ -70,7 +56,8 @@ export type NativeReason =
|
|
|
70
56
|
| 'hash'
|
|
71
57
|
| 'method'
|
|
72
58
|
| 'encoding'
|
|
73
|
-
| 'file'
|
|
59
|
+
| 'file'
|
|
60
|
+
| 'unparsable';
|
|
74
61
|
|
|
75
62
|
export type LinkVerdict =
|
|
76
63
|
| { readonly kind: 'soft'; readonly url: string }
|
|
@@ -97,9 +84,12 @@ const native = (reason: NativeReason): { readonly kind: 'native'; reason: Native
|
|
|
97
84
|
reason,
|
|
98
85
|
});
|
|
99
86
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Same document, fragment only: the browser scrolls, no fetch. Asked of the HREF, not `to.hash`:
|
|
89
|
+
* an empty fragment (`<a href="#">`) parses to `hash === ''`, and was soft-navigated and re-fetched.
|
|
90
|
+
*/
|
|
91
|
+
const hashOnly = (href: string, to: URL, from: URL): boolean =>
|
|
92
|
+
href.includes('#') && to.pathname === from.pathname && to.search === from.search;
|
|
103
93
|
|
|
104
94
|
/**
|
|
105
95
|
* The link rules, in the order a reader checks them. The SURFACE is not decided here: the URL
|
|
@@ -113,12 +103,15 @@ export function linkVerdict(facts: LinkFacts): LinkVerdict {
|
|
|
113
103
|
if (facts.download) return native('download');
|
|
114
104
|
if (facts.rel.split(/\s+/).includes('external')) return native('external');
|
|
115
105
|
if (facts.reload) return native('opt-out');
|
|
106
|
+
// `a.href` is the raw attribute when it does not parse (`href="http://"`): the browser decides
|
|
107
|
+
// what such a link does, where `new URL` threw a bare `TypeError` out of the click handler.
|
|
108
|
+
if (!URL.canParse(facts.href)) return native('unparsable');
|
|
116
109
|
const to = new URL(facts.href);
|
|
117
110
|
const from = new URL(facts.current);
|
|
118
111
|
if (to.origin !== from.origin || (to.protocol !== 'http:' && to.protocol !== 'https:')) {
|
|
119
112
|
return native('cross-origin');
|
|
120
113
|
}
|
|
121
|
-
if (hashOnly(to, from)) return native('hash');
|
|
114
|
+
if (hashOnly(facts.href, to, from)) return native('hash');
|
|
122
115
|
return { kind: 'soft', url: to.href };
|
|
123
116
|
}
|
|
124
117
|
|
|
@@ -153,7 +146,7 @@ export type FormVerdict =
|
|
|
153
146
|
|
|
154
147
|
/** What a `submit` tells the router — the submitter's `formmethod`/`formaction` already applied. */
|
|
155
148
|
export interface FormFacts {
|
|
156
|
-
/** Resolved action URL. */
|
|
149
|
+
/** Resolved action URL — or the raw attribute when it does not resolve (`'unparsable'`). */
|
|
157
150
|
readonly action: string;
|
|
158
151
|
readonly current: string;
|
|
159
152
|
/** Lower case: `get`, `post` or `dialog`. */
|
|
@@ -175,6 +168,7 @@ export function formVerdict(facts: FormFacts): FormVerdict {
|
|
|
175
168
|
if (facts.method !== 'get' && facts.method !== 'post') return native('method');
|
|
176
169
|
// `text/plain` is the browser's own encoding, with no `fetch` body that reproduces it byte for byte.
|
|
177
170
|
if (facts.method === 'post' && facts.enctype === 'text/plain') return native('encoding');
|
|
171
|
+
if (!URL.canParse(facts.action)) return native('unparsable');
|
|
178
172
|
const to = new URL(facts.action);
|
|
179
173
|
if (to.origin !== new URL(facts.current).origin) return native('cross-origin');
|
|
180
174
|
if (facts.method === 'get') {
|
|
@@ -246,6 +240,14 @@ export function responseVerdict(facts: ResponseFacts): ResponseVerdict {
|
|
|
246
240
|
}
|
|
247
241
|
if (facts.status === 204) {
|
|
248
242
|
if (facts.location === null) return { kind: 'stay' };
|
|
243
|
+
// The caller ASSIGNS this to `window.location`, so only http(s) is a place to go: a scheme
|
|
244
|
+
// that runs in the page would be script in the app's origin. `@ultimat3/http`'s `locationFor`
|
|
245
|
+
// refuses it at the source; this is the same rule for an answer something else wrote. The
|
|
246
|
+
// requested url is loaded as a document instead — a POST is never re-sent, so it fails.
|
|
247
|
+
if (!/^https?:\/\/./i.test(facts.location) || !URL.canParse(facts.location)) {
|
|
248
|
+
const reason = 'a location that is not http(s)';
|
|
249
|
+
return get ? load(facts.requested, reason) : { kind: 'failed', reason };
|
|
250
|
+
}
|
|
249
251
|
if (origin(facts.location) !== origin(facts.requested))
|
|
250
252
|
return load(facts.location, 'another origin');
|
|
251
253
|
// The server asked for THIS url as a document: the route did not run, the browser runs it.
|
|
@@ -259,7 +261,15 @@ export function responseVerdict(facts: ResponseFacts): ResponseVerdict {
|
|
|
259
261
|
facts.build !== null && facts.nextBuild !== null && facts.build !== facts.nextBuild;
|
|
260
262
|
// A GET refused for skew (409 before any route ran) or rendered by another build: load it.
|
|
261
263
|
if (get && skewed) return load(facts.requested, 'another build');
|
|
262
|
-
|
|
264
|
+
const html = /^\s*text\/html\b/i.test(facts.contentType);
|
|
265
|
+
// An error that is not a page (an offline service worker's empty 503, a JSON 404) is nothing to
|
|
266
|
+
// show — handed over, it became a `blob:` URL in the address bar. A GET is the browser's to
|
|
267
|
+
// load (a worker answers a real navigation with its offline page); a POST is never re-sent.
|
|
268
|
+
if (facts.status >= 400 && !html) {
|
|
269
|
+
const reason = 'an error answer that is not a page';
|
|
270
|
+
return get ? load(facts.requested, reason) : { kind: 'failed', reason };
|
|
271
|
+
}
|
|
272
|
+
if (!html) return { kind: 'hand-over' };
|
|
263
273
|
// A POST answered in place IS the page the browser would have shown, whatever rendered it —
|
|
264
274
|
// and when that is another principal's or build's, the caller stops trusting this tab
|
|
265
275
|
// (`answerMovesTab`): every later navigation is a real load.
|
|
@@ -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';
|
|
@@ -51,6 +46,7 @@ import {
|
|
|
51
46
|
NAVIGATED_EVENT,
|
|
52
47
|
NAVIGATING_ATTRIBUTE,
|
|
53
48
|
NAVIGATION_ERROR_EVENT,
|
|
49
|
+
NAVIGATION_MAX_HOPS,
|
|
54
50
|
NAVIGATION_META,
|
|
55
51
|
NAVIGATION_NO_PREFETCH_ATTRIBUTE,
|
|
56
52
|
NAVIGATION_PREFETCH_DELAY_MS,
|
|
@@ -60,6 +56,7 @@ import {
|
|
|
60
56
|
responseVerdict,
|
|
61
57
|
reusable,
|
|
62
58
|
} from './navigation-rules';
|
|
59
|
+
import { scrollKeeper } from './navigation-scroll';
|
|
63
60
|
import {
|
|
64
61
|
documentHead,
|
|
65
62
|
loadStylesheets,
|
|
@@ -68,6 +65,7 @@ import {
|
|
|
68
65
|
runScripts,
|
|
69
66
|
swapDocument,
|
|
70
67
|
} from './navigation-swap';
|
|
68
|
+
import { tabSync } from './navigation-tabs';
|
|
71
69
|
|
|
72
70
|
export interface NavigateOptions {
|
|
73
71
|
readonly method?: 'GET' | 'POST';
|
|
@@ -84,9 +82,6 @@ export interface NavigationRouter {
|
|
|
84
82
|
stop(): void;
|
|
85
83
|
}
|
|
86
84
|
|
|
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
85
|
interface RouterWindow extends Window {
|
|
91
86
|
__xNavigation?: NavigationRouter;
|
|
92
87
|
}
|
|
@@ -109,7 +104,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
109
104
|
let rendered = withoutFragment(win.location.href);
|
|
110
105
|
/** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
|
|
111
106
|
let navigatingTo: string | undefined;
|
|
112
|
-
/** The view transition still animating
|
|
107
|
+
/** The view transition still animating — skipped only for a click it swallowed (`onClick`). */
|
|
113
108
|
let animating: RunningTransition | undefined;
|
|
114
109
|
/** Between a press and its click: the focus the press gives a link is not a hover. */
|
|
115
110
|
let pressed = false;
|
|
@@ -122,28 +117,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
122
117
|
const live = announcer(doc);
|
|
123
118
|
const owned = documentHead(doc);
|
|
124
119
|
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
|
-
};
|
|
120
|
+
const tabs = tabSync(win, () => cache.clear());
|
|
121
|
+
const forget = tabs.forget;
|
|
122
|
+
const scroll = scrollKeeper(win, () => rendered);
|
|
147
123
|
|
|
148
124
|
const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
|
|
149
125
|
fetchDocument(win, doc, url, purpose, init);
|
|
@@ -172,12 +148,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
172
148
|
signal: AbortSignal,
|
|
173
149
|
) => {
|
|
174
150
|
if (method === 'POST') {
|
|
151
|
+
// Before AND once it settles, landed or not: a guess sent while the write was in flight
|
|
152
|
+
// may have rendered the old state, and a write that failed here may still have committed.
|
|
175
153
|
forget();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
...(options.body === undefined ? {} : { body: options.body }),
|
|
179
|
-
signal,
|
|
180
|
-
});
|
|
154
|
+
const body = options.body === undefined ? {} : { body: options.body };
|
|
155
|
+
return request(url, 'soft', { method, ...body, signal }).finally(forget);
|
|
181
156
|
}
|
|
182
157
|
const held = cache.peek(url);
|
|
183
158
|
cache.delete(url);
|
|
@@ -198,6 +173,12 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
198
173
|
};
|
|
199
174
|
|
|
200
175
|
const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
|
|
176
|
+
// The redirect chain's only exit is `hops >= NAVIGATION_MAX_HOPS`, which `NaN` never meets.
|
|
177
|
+
// `hops` is this router's own recursion counter (nothing outside it can reach `navigate`), so a
|
|
178
|
+
// non-count is a broken caller, not input: it fails CLOSED as an exhausted chain — one request,
|
|
179
|
+
// then the browser loads the page — instead of shipping the error renderer (~2 KB) to every page.
|
|
180
|
+
const asked = options.hops ?? 0;
|
|
181
|
+
const hops = Number.isSafeInteger(asked) && asked >= 0 ? asked : NAVIGATION_MAX_HOPS;
|
|
201
182
|
const method = options.method ?? 'GET';
|
|
202
183
|
// After an answer for another principal or build was shown in place, this tab is no longer
|
|
203
184
|
// trusted to swap: every navigation is a real load (`answerMovesTab`).
|
|
@@ -212,7 +193,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
212
193
|
inflight = mine;
|
|
213
194
|
win.clearTimeout(intent);
|
|
214
195
|
navigatingTo = withoutFragment(url);
|
|
215
|
-
if (options.history !== 'none')
|
|
196
|
+
if (options.history !== 'none') scroll.save();
|
|
216
197
|
const progress = win.setTimeout(
|
|
217
198
|
() => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
|
|
218
199
|
NAVIGATION_PROGRESS_DELAY_MS,
|
|
@@ -238,7 +219,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
238
219
|
opaqueRedirect: answer.opaqueRedirect,
|
|
239
220
|
location: answer.location,
|
|
240
221
|
contentType: answer.contentType,
|
|
241
|
-
hops
|
|
222
|
+
hops,
|
|
242
223
|
surface: metaOf(doc, NAVIGATION_META),
|
|
243
224
|
nextSurface: next === null ? null : metaOf(next, NAVIGATION_META),
|
|
244
225
|
build: metaOf(doc, CLIENT_BUILD_META),
|
|
@@ -262,7 +243,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
262
243
|
case 'follow':
|
|
263
244
|
await navigate(verdict.url, {
|
|
264
245
|
history: options.history === 'none' ? 'replace' : 'push',
|
|
265
|
-
hops:
|
|
246
|
+
hops: hops + 1,
|
|
266
247
|
});
|
|
267
248
|
return;
|
|
268
249
|
case 'hand-over':
|
|
@@ -274,11 +255,15 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
274
255
|
if (next === null) return;
|
|
275
256
|
const landed = `${withoutFragment(url)}${new URL(url).hash}`;
|
|
276
257
|
try {
|
|
277
|
-
|
|
258
|
+
// Owned from the moment they are appended: a navigation aborted after this point left its
|
|
259
|
+
// sheets in the head for good, styling every page after it — the next swap retires an
|
|
260
|
+
// owned sheet its page does not link.
|
|
261
|
+
await loadStylesheets(doc, missingStylesheets(doc, next), owned);
|
|
278
262
|
if (mine.signal.aborted) return;
|
|
279
263
|
let swapped: ReturnType<typeof swapDocument> | undefined;
|
|
280
|
-
const track = (running: RunningTransition
|
|
281
|
-
animating = running;
|
|
264
|
+
const track = (running: RunningTransition, finished: boolean): void => {
|
|
265
|
+
if (!finished) animating = running;
|
|
266
|
+
else if (animating === running) animating = undefined;
|
|
282
267
|
};
|
|
283
268
|
await transition(
|
|
284
269
|
win,
|
|
@@ -337,9 +322,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
337
322
|
pressed = true;
|
|
338
323
|
pressedOverTransition = animating !== undefined;
|
|
339
324
|
// A press is not a hover: the click it becomes navigates, and a guess on its heels is a
|
|
340
|
-
// second request for the same page.
|
|
325
|
+
// second request for the same page. A press alone never skips the running transition — that
|
|
326
|
+
// snapped every named element to its end mid-glide (#621); only the click below may.
|
|
341
327
|
win.clearTimeout(intent);
|
|
342
|
-
animating?.skipTransition?.();
|
|
343
328
|
};
|
|
344
329
|
const onRelease = (): void => {
|
|
345
330
|
pressed = false;
|
|
@@ -347,13 +332,15 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
347
332
|
|
|
348
333
|
const onClick = (event: MouseEvent): void => {
|
|
349
334
|
win.clearTimeout(intent);
|
|
350
|
-
// While a view transition paints, the browser hit-tests every press to `<html
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
335
|
+
// While a view transition paints, the browser hit-tests every press to `<html>` — Chrome does
|
|
336
|
+
// so whatever `pointer-events` the `::view-transition` overlay has — so the click reached
|
|
337
|
+
// nothing: the visitor's first click after a swap was lost. It is given to the element under
|
|
338
|
+
// the pointer, whatever it is: a link, a submit button, an island's own control. Only a
|
|
339
|
+
// skipped transition hit-tests the page, so the skip happens HERE, at the click, never at
|
|
340
|
+
// the press; when the animation already ended between the two, there is nothing to skip.
|
|
355
341
|
if (pressedOverTransition && event.target === doc.documentElement) {
|
|
356
342
|
pressedOverTransition = false;
|
|
343
|
+
animating?.skipTransition?.();
|
|
357
344
|
const hit = doc.elementFromPoint(event.clientX, event.clientY);
|
|
358
345
|
if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
|
|
359
346
|
return;
|
|
@@ -375,7 +362,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
375
362
|
const say = (attr: string, fallback: string): string =>
|
|
376
363
|
submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
|
|
377
364
|
const facts: FormFacts = {
|
|
378
|
-
action:
|
|
365
|
+
action: formAction(say('action', ''), win.location.href, doc.baseURI),
|
|
379
366
|
current: win.location.href,
|
|
380
367
|
method: say('method', 'get').toLowerCase(),
|
|
381
368
|
enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
|
|
@@ -388,9 +375,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
388
375
|
hasFile: [...fields.values()].some(
|
|
389
376
|
(value: unknown) => value instanceof File && value.name !== '',
|
|
390
377
|
),
|
|
391
|
-
fields:
|
|
392
|
-
([name, value]: [string, unknown]) => [name, fieldText(value)] as const,
|
|
393
|
-
),
|
|
378
|
+
fields: formPairs(fields),
|
|
394
379
|
};
|
|
395
380
|
const verdict = formVerdict(facts);
|
|
396
381
|
if (verdict.kind === 'native') return;
|
|
@@ -438,8 +423,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
438
423
|
* screen it is fetched; when it is, only the scroll moves — the app keeps its own history.
|
|
439
424
|
*/
|
|
440
425
|
const onPop = (event: PopStateEvent): void => {
|
|
441
|
-
|
|
442
|
-
win.clearTimeout(settle);
|
|
426
|
+
scroll.cancel();
|
|
443
427
|
const url = win.location.href;
|
|
444
428
|
const entry = entryOf(event.state);
|
|
445
429
|
const wanted = entry?.doc ?? withoutFragment(url);
|
|
@@ -464,7 +448,6 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
464
448
|
// had its chance to `preventDefault`, which the rules then honour.
|
|
465
449
|
win.addEventListener('click', onClick);
|
|
466
450
|
win.addEventListener('submit', onSubmit);
|
|
467
|
-
win.addEventListener('scroll', onScroll, { passive: true });
|
|
468
451
|
doc.addEventListener('pointerover', onIntent, { passive: true });
|
|
469
452
|
doc.addEventListener('pointerout', onLeave, { passive: true });
|
|
470
453
|
doc.addEventListener('focusin', onIntent);
|
|
@@ -480,7 +463,6 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
480
463
|
stop() {
|
|
481
464
|
win.removeEventListener('click', onClick);
|
|
482
465
|
win.removeEventListener('submit', onSubmit);
|
|
483
|
-
win.removeEventListener('scroll', onScroll);
|
|
484
466
|
doc.removeEventListener('pointerover', onIntent);
|
|
485
467
|
doc.removeEventListener('pointerout', onLeave);
|
|
486
468
|
doc.removeEventListener('focusin', onIntent);
|
|
@@ -489,9 +471,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
489
471
|
doc.removeEventListener('pointerdown', onPress, { capture: true });
|
|
490
472
|
doc.removeEventListener('pointerup', onRelease, { capture: true });
|
|
491
473
|
doc.removeEventListener('pointercancel', onRelease, { capture: true });
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
channel?.close();
|
|
474
|
+
scroll.stop();
|
|
475
|
+
tabs.stop();
|
|
495
476
|
delete win.__xNavigation;
|
|
496
477
|
},
|
|
497
478
|
};
|