@ultimat3/render 22.7.1 → 22.8.1
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 +6 -2
- package/package.json +10 -8
- package/src/errors.ts +18 -0
- package/src/hydrate.ts +5 -1
- package/src/index.ts +49 -1
- package/src/modes.ts +5 -5
- package/src/navigation-cache.ts +93 -0
- package/src/navigation-controller-fixture.ts +91 -0
- package/src/navigation-dom-fixture.ts +424 -0
- package/src/navigation-dom.ts +158 -0
- package/src/navigation-entry.ts +7 -0
- package/src/navigation-fetch.ts +77 -0
- package/src/navigation-history.ts +59 -0
- package/src/navigation-rules.ts +306 -0
- package/src/navigation-swap.ts +297 -0
- package/src/navigation-tags.ts +38 -0
- package/src/navigation.ts +500 -0
- package/src/route.ts +21 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an opted-in document carries for the client router: which surface rendered it, which build
|
|
3
|
+
* (the skew check — the same meta realtime's sync tags write, deduped by key), and the router's
|
|
4
|
+
* script. A surface that did not opt in gets none of the three, so it pays no byte.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CLIENT_BUILD_META } from '@ultimat3/core';
|
|
8
|
+
import type { HeadTag } from './head';
|
|
9
|
+
import { NAVIGATION_META } from './navigation-rules';
|
|
10
|
+
|
|
11
|
+
export interface ClientNavigationHead {
|
|
12
|
+
/** The surface this document belongs to — a link that lands on another is a full load. */
|
|
13
|
+
readonly surface: string;
|
|
14
|
+
readonly buildId: string;
|
|
15
|
+
/** `/_x/navigation/<hash>.js`, content-addressed and served `immutable`. */
|
|
16
|
+
readonly scriptUrl: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function clientNavigationTags(head: ClientNavigationHead): readonly HeadTag[] {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
kind: 'meta',
|
|
23
|
+
key: `meta:${NAVIGATION_META}`,
|
|
24
|
+
attrs: { name: NAVIGATION_META, content: head.surface },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
kind: 'meta',
|
|
28
|
+
key: `meta:${CLIENT_BUILD_META}`,
|
|
29
|
+
attrs: { name: CLIENT_BUILD_META, content: head.buildId },
|
|
30
|
+
},
|
|
31
|
+
// Deferred, in `<head>`: it runs once, after parsing, and survives every swap of the body.
|
|
32
|
+
{
|
|
33
|
+
kind: 'script',
|
|
34
|
+
key: 'script:ultimate-navigation',
|
|
35
|
+
attrs: { src: head.scriptUrl, defer: true },
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client router: soft navigation over server-rendered documents. A same-surface link or form
|
|
3
|
+
* fetches the next document the server renders anyway (its own mode, its own policy, its own cache
|
|
4
|
+
* headers) and swaps it in, so the tab keeps its islands, its socket and its state between pages.
|
|
5
|
+
* NOT a render mode — every page is still rendered by its route; without this script, or with
|
|
6
|
+
* `data-x-reload`, the same markup is an ordinary full-page web app.
|
|
7
|
+
*
|
|
8
|
+
* One rule over every branch: NOTHING THE ROUTER SENT IS SENT AGAIN. The server answers a request
|
|
9
|
+
* a route may not take before running it (`@ultimat3/http`'s navigation gate), hands a redirect
|
|
10
|
+
* over instead of letting `fetch` follow it, and a POST is never re-submitted — after a failed
|
|
11
|
+
* one, only the server knows what landed.
|
|
12
|
+
*
|
|
13
|
+
* Loaded as one deferred classic script (`@ultimat3/cli` builds it to `/_x/navigation/<hash>.js`)
|
|
14
|
+
* on documents whose surface opted in (`navigation: { client: [...] }` in `app.config.ts`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
CLIENT_BUILD_META,
|
|
19
|
+
CLIENT_SCOPE_META,
|
|
20
|
+
onClientWrite,
|
|
21
|
+
onRescope,
|
|
22
|
+
} from '@ultimat3/core/page';
|
|
23
|
+
import { navigationCache } from './navigation-cache';
|
|
24
|
+
import {
|
|
25
|
+
anchorOf,
|
|
26
|
+
announcer,
|
|
27
|
+
fieldText,
|
|
28
|
+
focusMain,
|
|
29
|
+
formFields,
|
|
30
|
+
handOver,
|
|
31
|
+
linkFacts,
|
|
32
|
+
type RunningTransition,
|
|
33
|
+
transition,
|
|
34
|
+
} from './navigation-dom';
|
|
35
|
+
import { type Answer, fetchDocument, metaOf } from './navigation-fetch';
|
|
36
|
+
import {
|
|
37
|
+
type EntryState,
|
|
38
|
+
entryOf,
|
|
39
|
+
STATE_KEY,
|
|
40
|
+
saveEntryScroll,
|
|
41
|
+
scrollAfter,
|
|
42
|
+
withoutFragment,
|
|
43
|
+
} from './navigation-history';
|
|
44
|
+
import {
|
|
45
|
+
answerMovesTab,
|
|
46
|
+
type FormFacts,
|
|
47
|
+
formVerdict,
|
|
48
|
+
linkVerdict,
|
|
49
|
+
mayPrefetch,
|
|
50
|
+
NAVIGATE_EVENT,
|
|
51
|
+
NAVIGATED_EVENT,
|
|
52
|
+
NAVIGATING_ATTRIBUTE,
|
|
53
|
+
NAVIGATION_ERROR_EVENT,
|
|
54
|
+
NAVIGATION_META,
|
|
55
|
+
NAVIGATION_NO_PREFETCH_ATTRIBUTE,
|
|
56
|
+
NAVIGATION_PREFETCH_DELAY_MS,
|
|
57
|
+
NAVIGATION_PROGRESS_DELAY_MS,
|
|
58
|
+
NAVIGATION_RELOAD_ATTRIBUTE,
|
|
59
|
+
type ResponseFacts,
|
|
60
|
+
responseVerdict,
|
|
61
|
+
reusable,
|
|
62
|
+
} from './navigation-rules';
|
|
63
|
+
import {
|
|
64
|
+
documentHead,
|
|
65
|
+
loadStylesheets,
|
|
66
|
+
missingStylesheets,
|
|
67
|
+
notePersisted,
|
|
68
|
+
runScripts,
|
|
69
|
+
swapDocument,
|
|
70
|
+
} from './navigation-swap';
|
|
71
|
+
|
|
72
|
+
export interface NavigateOptions {
|
|
73
|
+
readonly method?: 'GET' | 'POST';
|
|
74
|
+
readonly body?: BodyInit;
|
|
75
|
+
/** `push` for a new visit, `replace` for the same URL, `none` for a back/forward. */
|
|
76
|
+
readonly history?: 'push' | 'replace' | 'none';
|
|
77
|
+
/** Redirects already followed for this navigation. */
|
|
78
|
+
readonly hops?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface NavigationRouter {
|
|
82
|
+
navigate(url: string, options?: NavigateOptions): Promise<void>;
|
|
83
|
+
prefetch(url: string): void;
|
|
84
|
+
stop(): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
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
|
+
interface RouterWindow extends Window {
|
|
91
|
+
__xNavigation?: NavigationRouter;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Starts the router on a document that opted in, once per tab; `undefined` on one that did not —
|
|
96
|
+
* a page without `ultimate-navigation` is never intercepted, whatever script it loaded.
|
|
97
|
+
*/
|
|
98
|
+
export function startNavigation(win: RouterWindow = window): NavigationRouter | undefined {
|
|
99
|
+
const doc = win.document;
|
|
100
|
+
if (win.__xNavigation !== undefined) return win.__xNavigation;
|
|
101
|
+
if (metaOf(doc, NAVIGATION_META) === null || !('DOMParser' in win)) return undefined;
|
|
102
|
+
|
|
103
|
+
const cache = navigationCache<Answer>();
|
|
104
|
+
const ran = new Set(
|
|
105
|
+
[...doc.querySelectorAll('script[src]')].map(
|
|
106
|
+
(s) => new URL(s.getAttribute('src') ?? '', doc.baseURI).href,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
let rendered = withoutFragment(win.location.href);
|
|
110
|
+
/** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
|
|
111
|
+
let navigatingTo: string | undefined;
|
|
112
|
+
/** The view transition still animating, which a press skips to the new page. */
|
|
113
|
+
let animating: RunningTransition | undefined;
|
|
114
|
+
/** Between a press and its click: the focus the press gives a link is not a hover. */
|
|
115
|
+
let pressed = false;
|
|
116
|
+
/** The press landed while a transition was painting over the page (see `onClick`). */
|
|
117
|
+
let pressedOverTransition = false;
|
|
118
|
+
/** The pending prefetch of a hover, cancelled by anything that navigates. */
|
|
119
|
+
let intent: number | undefined;
|
|
120
|
+
let untrusted = false;
|
|
121
|
+
let inflight: AbortController | undefined;
|
|
122
|
+
const live = announcer(doc);
|
|
123
|
+
const owned = documentHead(doc);
|
|
124
|
+
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
|
+
};
|
|
147
|
+
|
|
148
|
+
const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
|
|
149
|
+
fetchDocument(win, doc, url, purpose, init);
|
|
150
|
+
|
|
151
|
+
const prefetch = (url: string): void => {
|
|
152
|
+
const key = withoutFragment(url);
|
|
153
|
+
if (untrusted || key === rendered || key === navigatingTo || cache.get(url) !== undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Cached whatever it answers: an empty `204` (the route did not opt in) is remembered too, so
|
|
157
|
+
// a second hover does not ask again. Only a reusable page ever answers a click (`reusable`).
|
|
158
|
+
cache.set(url, request(url, 'prefetch'));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Cancelable; its default is a GET of the page the visitor is on. Nothing is re-sent. */
|
|
162
|
+
const failed = (url: string, method: string, reason: string): void => {
|
|
163
|
+
const detail = { url, method, reason };
|
|
164
|
+
const event = new CustomEvent(NAVIGATION_ERROR_EVENT, { detail, cancelable: true });
|
|
165
|
+
if (doc.dispatchEvent(event)) win.location.assign(win.location.href);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const answerFor = (
|
|
169
|
+
url: string,
|
|
170
|
+
method: 'GET' | 'POST',
|
|
171
|
+
options: NavigateOptions,
|
|
172
|
+
signal: AbortSignal,
|
|
173
|
+
) => {
|
|
174
|
+
if (method === 'POST') {
|
|
175
|
+
forget();
|
|
176
|
+
return request(url, 'soft', {
|
|
177
|
+
method,
|
|
178
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
179
|
+
signal,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const held = cache.peek(url);
|
|
183
|
+
cache.delete(url);
|
|
184
|
+
if (held === undefined) return request(url, 'soft', { signal });
|
|
185
|
+
return held.value.then(
|
|
186
|
+
(answer) =>
|
|
187
|
+
reusable({
|
|
188
|
+
status: answer.status,
|
|
189
|
+
html: answer.html !== null,
|
|
190
|
+
location: answer.location,
|
|
191
|
+
noStore: answer.noStore,
|
|
192
|
+
ageMs: held.ageMs,
|
|
193
|
+
})
|
|
194
|
+
? answer
|
|
195
|
+
: request(url, 'soft', { signal }),
|
|
196
|
+
() => request(url, 'soft', { signal }),
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
|
|
201
|
+
const method = options.method ?? 'GET';
|
|
202
|
+
// After an answer for another principal or build was shown in place, this tab is no longer
|
|
203
|
+
// trusted to swap: every navigation is a real load (`answerMovesTab`).
|
|
204
|
+
if (untrusted && method === 'GET') {
|
|
205
|
+
win.location.assign(url);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const detail = { url, method };
|
|
209
|
+
if (!doc.dispatchEvent(new CustomEvent(NAVIGATE_EVENT, { detail, cancelable: true }))) return;
|
|
210
|
+
inflight?.abort();
|
|
211
|
+
const mine = new AbortController();
|
|
212
|
+
inflight = mine;
|
|
213
|
+
win.clearTimeout(intent);
|
|
214
|
+
navigatingTo = withoutFragment(url);
|
|
215
|
+
if (options.history !== 'none') saveScroll();
|
|
216
|
+
const progress = win.setTimeout(
|
|
217
|
+
() => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
|
|
218
|
+
NAVIGATION_PROGRESS_DELAY_MS,
|
|
219
|
+
);
|
|
220
|
+
try {
|
|
221
|
+
let answer: Answer;
|
|
222
|
+
try {
|
|
223
|
+
answer = await answerFor(url, method, options, mine.signal);
|
|
224
|
+
} catch {
|
|
225
|
+
if (mine.signal.aborted) return;
|
|
226
|
+
// A GET that never arrived is the browser's to try; a POST that failed is never re-sent.
|
|
227
|
+
if (method === 'GET') win.location.assign(url);
|
|
228
|
+
else failed(url, method, 'the request failed on the network');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (mine.signal.aborted) return;
|
|
232
|
+
const next =
|
|
233
|
+
answer.html === null ? null : new DOMParser().parseFromString(answer.html, 'text/html');
|
|
234
|
+
const facts: ResponseFacts = {
|
|
235
|
+
method,
|
|
236
|
+
requested: url,
|
|
237
|
+
status: answer.status,
|
|
238
|
+
opaqueRedirect: answer.opaqueRedirect,
|
|
239
|
+
location: answer.location,
|
|
240
|
+
contentType: answer.contentType,
|
|
241
|
+
hops: options.hops ?? 0,
|
|
242
|
+
surface: metaOf(doc, NAVIGATION_META),
|
|
243
|
+
nextSurface: next === null ? null : metaOf(next, NAVIGATION_META),
|
|
244
|
+
build: metaOf(doc, CLIENT_BUILD_META),
|
|
245
|
+
nextBuild: answer.build ?? (next === null ? null : metaOf(next, CLIENT_BUILD_META)),
|
|
246
|
+
scope: metaOf(doc, CLIENT_SCOPE_META),
|
|
247
|
+
nextScope: next === null ? null : metaOf(next, CLIENT_SCOPE_META),
|
|
248
|
+
};
|
|
249
|
+
const verdict = responseVerdict(facts);
|
|
250
|
+
switch (verdict.kind) {
|
|
251
|
+
case 'stay':
|
|
252
|
+
return;
|
|
253
|
+
case 'failed':
|
|
254
|
+
failed(url, method, verdict.reason);
|
|
255
|
+
return;
|
|
256
|
+
case 'load':
|
|
257
|
+
// A principal change the router saw, or one the server may have signalled by handing
|
|
258
|
+
// over the very URL it was asked for (it cannot say which): every tab forgets.
|
|
259
|
+
if (verdict.reason === 'another principal' || verdict.url === url) forget();
|
|
260
|
+
win.location.assign(verdict.url);
|
|
261
|
+
return;
|
|
262
|
+
case 'follow':
|
|
263
|
+
await navigate(verdict.url, {
|
|
264
|
+
history: options.history === 'none' ? 'replace' : 'push',
|
|
265
|
+
hops: (options.hops ?? 0) + 1,
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
case 'hand-over':
|
|
269
|
+
if (answer.body !== null) handOver(win, answer.body, answer.disposition);
|
|
270
|
+
return;
|
|
271
|
+
default:
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (next === null) return;
|
|
275
|
+
const landed = `${withoutFragment(url)}${new URL(url).hash}`;
|
|
276
|
+
try {
|
|
277
|
+
await loadStylesheets(doc, missingStylesheets(doc, next));
|
|
278
|
+
if (mine.signal.aborted) return;
|
|
279
|
+
let swapped: ReturnType<typeof swapDocument> | undefined;
|
|
280
|
+
const track = (running: RunningTransition | undefined): void => {
|
|
281
|
+
animating = running;
|
|
282
|
+
};
|
|
283
|
+
await transition(
|
|
284
|
+
win,
|
|
285
|
+
() => {
|
|
286
|
+
// A newer navigation started while the sheets loaded: this answer is no longer asked for.
|
|
287
|
+
if (mine.signal.aborted) return;
|
|
288
|
+
swapped = swapDocument(doc, next, owned);
|
|
289
|
+
// Emptied here and filled after the scripts, so a page with the same title still reads out.
|
|
290
|
+
live.textContent = '';
|
|
291
|
+
swapped.body.append(live);
|
|
292
|
+
rendered = withoutFragment(landed);
|
|
293
|
+
if (options.history !== 'none') {
|
|
294
|
+
const same = landed === win.location.href || options.history === 'replace';
|
|
295
|
+
const state = { [STATE_KEY]: { scroll: [0, 0], doc: rendered } satisfies EntryState };
|
|
296
|
+
if (same) win.history.replaceState(state, '', landed);
|
|
297
|
+
else win.history.pushState(state, '', landed);
|
|
298
|
+
}
|
|
299
|
+
// Inside the swap, so a view transition's "after" frame is already where the page lands.
|
|
300
|
+
scrollAfter(win, doc, landed, options.history);
|
|
301
|
+
},
|
|
302
|
+
track,
|
|
303
|
+
);
|
|
304
|
+
const done = swapped;
|
|
305
|
+
if (done === undefined) return;
|
|
306
|
+
if (method === 'POST' && answerMovesTab(facts)) {
|
|
307
|
+
untrusted = true;
|
|
308
|
+
forget();
|
|
309
|
+
}
|
|
310
|
+
await runScripts(done.headScripts, ran, (fresh) => {
|
|
311
|
+
doc.head.append(fresh);
|
|
312
|
+
owned.add(fresh);
|
|
313
|
+
});
|
|
314
|
+
await runScripts([...done.body.querySelectorAll('script')], ran, (fresh, inert) =>
|
|
315
|
+
inert.replaceWith(fresh),
|
|
316
|
+
);
|
|
317
|
+
focusMain(doc);
|
|
318
|
+
live.textContent = doc.title;
|
|
319
|
+
doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
|
|
320
|
+
} catch {
|
|
321
|
+
// A swap that failed part-way leaves a page nobody rendered: a GET is loaded for real; a
|
|
322
|
+
// POST's answer cannot be asked for again, so the visitor is told and shown this page.
|
|
323
|
+
if (method === 'GET') win.location.assign(landed);
|
|
324
|
+
else failed(url, method, 'the answer could not be shown');
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
win.clearTimeout(progress);
|
|
328
|
+
if (inflight === mine) {
|
|
329
|
+
inflight = undefined;
|
|
330
|
+
navigatingTo = undefined;
|
|
331
|
+
doc.documentElement.removeAttribute(NAVIGATING_ATTRIBUTE);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const onPress = (): void => {
|
|
337
|
+
pressed = true;
|
|
338
|
+
pressedOverTransition = animating !== undefined;
|
|
339
|
+
// 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. And a press during the animation skips to the new page.
|
|
341
|
+
win.clearTimeout(intent);
|
|
342
|
+
animating?.skipTransition?.();
|
|
343
|
+
};
|
|
344
|
+
const onRelease = (): void => {
|
|
345
|
+
pressed = false;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const onClick = (event: MouseEvent): void => {
|
|
349
|
+
win.clearTimeout(intent);
|
|
350
|
+
// While a view transition paints, the browser hit-tests every press to `<html>`: the press
|
|
351
|
+
// skipped the animation (`onPress`), the release landed on the real element, and the click —
|
|
352
|
+
// aimed at their common ancestor — reached nothing. The visitor's first click after a swap was
|
|
353
|
+
// lost. It is given to the element under the pointer NOW, whatever it is: a link, a submit
|
|
354
|
+
// button, an island's own control.
|
|
355
|
+
if (pressedOverTransition && event.target === doc.documentElement) {
|
|
356
|
+
pressedOverTransition = false;
|
|
357
|
+
const hit = doc.elementFromPoint(event.clientX, event.clientY);
|
|
358
|
+
if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const anchor = anchorOf(event.target);
|
|
362
|
+
if (anchor === null) return;
|
|
363
|
+
const verdict = linkVerdict(linkFacts(win, anchor, event));
|
|
364
|
+
if (verdict.kind !== 'soft') return;
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
void navigate(verdict.url);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const onSubmit = (event: SubmitEvent): void => {
|
|
370
|
+
const form = event.target;
|
|
371
|
+
// An untrusted tab (`answerMovesTab`) leaves every form to the browser.
|
|
372
|
+
if (!(form instanceof HTMLFormElement) || untrusted) return;
|
|
373
|
+
const submitter = event.submitter;
|
|
374
|
+
const fields = formFields(form, submitter);
|
|
375
|
+
const say = (attr: string, fallback: string): string =>
|
|
376
|
+
submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
|
|
377
|
+
const facts: FormFacts = {
|
|
378
|
+
action: new URL(say('action', win.location.href) || win.location.href, doc.baseURI).href,
|
|
379
|
+
current: win.location.href,
|
|
380
|
+
method: say('method', 'get').toLowerCase(),
|
|
381
|
+
enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
|
|
382
|
+
target: say('target', ''),
|
|
383
|
+
defaultPrevented: event.defaultPrevented,
|
|
384
|
+
reload:
|
|
385
|
+
form.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) ||
|
|
386
|
+
submitter?.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) === true,
|
|
387
|
+
// `unknown`: a browser yields a `File` for a file input, whatever a server-side type says.
|
|
388
|
+
hasFile: [...fields.values()].some(
|
|
389
|
+
(value: unknown) => value instanceof File && value.name !== '',
|
|
390
|
+
),
|
|
391
|
+
fields: [...fields].map(
|
|
392
|
+
([name, value]: [string, unknown]) => [name, fieldText(value)] as const,
|
|
393
|
+
),
|
|
394
|
+
};
|
|
395
|
+
const verdict = formVerdict(facts);
|
|
396
|
+
if (verdict.kind === 'native') return;
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
if (verdict.kind === 'get') {
|
|
399
|
+
void navigate(verdict.url);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const body =
|
|
403
|
+
verdict.encoding === 'multipart'
|
|
404
|
+
? fields
|
|
405
|
+
: new URLSearchParams(facts.fields.map(([name, value]) => [name, value]));
|
|
406
|
+
void navigate(verdict.url, { method: 'POST', body });
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const onIntent = (event: Event): void => {
|
|
410
|
+
// The focus a press gives a link: its click is a moment away, and it navigates.
|
|
411
|
+
if (event.type === 'focusin' && pressed) return;
|
|
412
|
+
const anchor = anchorOf(event.target);
|
|
413
|
+
if (anchor === null) return;
|
|
414
|
+
const verdict = linkVerdict(linkFacts(win, anchor));
|
|
415
|
+
const connection = (
|
|
416
|
+
win.navigator as { connection?: { saveData?: boolean; effectiveType?: string } }
|
|
417
|
+
).connection;
|
|
418
|
+
if (
|
|
419
|
+
verdict.kind !== 'soft' ||
|
|
420
|
+
!mayPrefetch({
|
|
421
|
+
url: verdict.url,
|
|
422
|
+
noPrefetch: anchor.hasAttribute(NAVIGATION_NO_PREFETCH_ATTRIBUTE),
|
|
423
|
+
saveData: connection?.saveData,
|
|
424
|
+
effectiveType: connection?.effectiveType,
|
|
425
|
+
})
|
|
426
|
+
) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
win.clearTimeout(intent);
|
|
430
|
+
const delay = event.type === 'pointerover' ? NAVIGATION_PREFETCH_DELAY_MS : 0;
|
|
431
|
+
intent = win.setTimeout(() => prefetch(verdict.url), delay);
|
|
432
|
+
};
|
|
433
|
+
const onLeave = (): void => win.clearTimeout(intent);
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Back/forward. The router's own entries name the document they show; any other entry (an app's
|
|
437
|
+
* own `pushState`) shows the document of its PATH. Either way, when that is not the document on
|
|
438
|
+
* screen it is fetched; when it is, only the scroll moves — the app keeps its own history.
|
|
439
|
+
*/
|
|
440
|
+
const onPop = (event: PopStateEvent): void => {
|
|
441
|
+
// A save still pending belongs to the entry just left.
|
|
442
|
+
win.clearTimeout(settle);
|
|
443
|
+
const url = win.location.href;
|
|
444
|
+
const entry = entryOf(event.state);
|
|
445
|
+
const wanted = entry?.doc ?? withoutFragment(url);
|
|
446
|
+
const same =
|
|
447
|
+
entry !== undefined
|
|
448
|
+
? wanted === rendered
|
|
449
|
+
: new URL(url).pathname === new URL(rendered).pathname;
|
|
450
|
+
if (!same) {
|
|
451
|
+
void navigate(url, { history: 'none' });
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (entry !== undefined) {
|
|
455
|
+
win.scrollTo({
|
|
456
|
+
left: entry.scroll[0],
|
|
457
|
+
top: entry.scroll[1],
|
|
458
|
+
behavior: 'instant' as ScrollBehavior,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// `window`, bubble phase: after every handler on the page — Solid delegates to `document` — has
|
|
464
|
+
// had its chance to `preventDefault`, which the rules then honour.
|
|
465
|
+
win.addEventListener('click', onClick);
|
|
466
|
+
win.addEventListener('submit', onSubmit);
|
|
467
|
+
win.addEventListener('scroll', onScroll, { passive: true });
|
|
468
|
+
doc.addEventListener('pointerover', onIntent, { passive: true });
|
|
469
|
+
doc.addEventListener('pointerout', onLeave, { passive: true });
|
|
470
|
+
doc.addEventListener('focusin', onIntent);
|
|
471
|
+
doc.addEventListener('touchstart', onIntent, { passive: true });
|
|
472
|
+
win.addEventListener('popstate', onPop);
|
|
473
|
+
doc.addEventListener('pointerdown', onPress, { capture: true, passive: true });
|
|
474
|
+
doc.addEventListener('pointerup', onRelease, { capture: true, passive: true });
|
|
475
|
+
doc.addEventListener('pointercancel', onRelease, { capture: true, passive: true });
|
|
476
|
+
|
|
477
|
+
const router: NavigationRouter = {
|
|
478
|
+
navigate,
|
|
479
|
+
prefetch,
|
|
480
|
+
stop() {
|
|
481
|
+
win.removeEventListener('click', onClick);
|
|
482
|
+
win.removeEventListener('submit', onSubmit);
|
|
483
|
+
win.removeEventListener('scroll', onScroll);
|
|
484
|
+
doc.removeEventListener('pointerover', onIntent);
|
|
485
|
+
doc.removeEventListener('pointerout', onLeave);
|
|
486
|
+
doc.removeEventListener('focusin', onIntent);
|
|
487
|
+
doc.removeEventListener('touchstart', onIntent);
|
|
488
|
+
win.removeEventListener('popstate', onPop);
|
|
489
|
+
doc.removeEventListener('pointerdown', onPress, { capture: true });
|
|
490
|
+
doc.removeEventListener('pointerup', onRelease, { capture: true });
|
|
491
|
+
doc.removeEventListener('pointercancel', onRelease, { capture: true });
|
|
492
|
+
offWrite();
|
|
493
|
+
offRescope();
|
|
494
|
+
channel?.close();
|
|
495
|
+
delete win.__xNavigation;
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
win.__xNavigation = router;
|
|
499
|
+
return router;
|
|
500
|
+
}
|
package/src/route.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { RouteMeta } from '@ultimat3/seo';
|
|
|
22
22
|
import {
|
|
23
23
|
RouteLoadInvalidError,
|
|
24
24
|
RouteMetaMissingError,
|
|
25
|
+
RouteNavigationInvalidError,
|
|
25
26
|
RouteOfflineMissingError,
|
|
26
27
|
RoutePostInvalidError,
|
|
27
28
|
} from './errors';
|
|
@@ -193,7 +194,18 @@ export type LoadRequirement<TData> = RouteContext extends TData
|
|
|
193
194
|
*/
|
|
194
195
|
export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
|
|
195
196
|
|
|
196
|
-
/**
|
|
197
|
+
/**
|
|
198
|
+
* How the client router treats this page (`navigation: { client }` surfaces only). Absent: swapped
|
|
199
|
+
* in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
|
|
200
|
+
* page whose GET does nothing but render. `'document'`: always a real document load, the route run
|
|
201
|
+
* once by the browser — for a GET that records something (an open, a download, a token consumed).
|
|
202
|
+
* The server enforces both before `load` (`@ultimat3/http`'s navigation gate).
|
|
203
|
+
*/
|
|
204
|
+
export type RouteNavigationMode = 'prefetch' | 'document';
|
|
205
|
+
|
|
206
|
+
export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = ['prefetch', 'document'];
|
|
207
|
+
|
|
208
|
+
/** The input shape of `defineRoute` — exactly the contract's twelve keys, nothing else. */
|
|
197
209
|
export interface RouteDefinition<TData = RouteData> {
|
|
198
210
|
readonly render: RenderMode;
|
|
199
211
|
readonly revalidate?: RevalidateConfig;
|
|
@@ -224,6 +236,8 @@ export interface RouteDefinition<TData = RouteData> {
|
|
|
224
236
|
* Never on `static`: a file on disk has no process to answer a `POST`.
|
|
225
237
|
*/
|
|
226
238
|
readonly post?: string;
|
|
239
|
+
/** See `RouteNavigationMode`. Refused on a surface without client navigation, at boot. */
|
|
240
|
+
readonly navigation?: RouteNavigationMode;
|
|
227
241
|
}
|
|
228
242
|
|
|
229
243
|
/**
|
|
@@ -304,6 +318,11 @@ export function defineRoute<TData = RouteData>(
|
|
|
304
318
|
}
|
|
305
319
|
|
|
306
320
|
if (def.post !== undefined) assertPostBinding(def.post, def.render);
|
|
321
|
+
if (def.navigation !== undefined && !ROUTE_NAVIGATION_MODES.includes(def.navigation)) {
|
|
322
|
+
throw new RouteNavigationInvalidError(
|
|
323
|
+
`navigation: ${renderCauseValue(def.navigation)} is not one of ${ROUTE_NAVIGATION_MODES.join(', ')}`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
307
326
|
|
|
308
327
|
const declaredMeta = def.meta;
|
|
309
328
|
const declaredLoad = def.load;
|
|
@@ -338,6 +357,7 @@ export function defineRoute<TData = RouteData>(
|
|
|
338
357
|
...(def.policy ? { policy: def.policy } : {}),
|
|
339
358
|
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
340
359
|
...(def.post === undefined ? {} : { post: def.post }),
|
|
360
|
+
...(def.navigation === undefined ? {} : { navigation: def.navigation }),
|
|
341
361
|
};
|
|
342
362
|
|
|
343
363
|
assertModeShape(config);
|