@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.
@@ -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
- NAVIGATION_HEADER,
11
- NAVIGATION_LOCATION_HEADER,
12
- NAVIGATION_META,
13
- NAVIGATION_SCOPE_HEADER,
14
- NAVIGATION_SURFACE_HEADER,
15
- } from './navigation-rules';
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
- [NAVIGATION_HEADER]: purpose,
45
- [NAVIGATION_SURFACE_HEADER]: metaOf(doc, NAVIGATION_META) ?? '',
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[NAVIGATION_SCOPE_HEADER] = scope;
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(NAVIGATION_LOCATION_HEADER);
65
+ const location = response.headers.get(CLIENT_NAVIGATION_LOCATION_HEADER);
65
66
  return {
66
67
  status: response.status,
67
68
  opaqueRedirect,
@@ -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, header and
5
- * event name the router shares with the documents the server renders and the apps that listen.
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
- * Sent on every router fetch: `soft` when a visitor asked for the page, `prefetch` when the router
22
- * guessed they would. The server answers a route that did not opt in before running it.
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
- /** Same document, fragment only: the browser scrolls, no fetch. */
101
- const hashOnly = (to: URL, from: URL): boolean =>
102
- to.hash !== '' && to.pathname === from.pathname && to.search === from.search;
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
- if (!/^\s*text\/html\b/i.test(facts.contentType)) return { kind: 'hand-over' };
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
+ }
@@ -56,8 +56,15 @@ export function missingStylesheets(doc: Document, next: Document): readonly stri
56
56
  .filter((href) => !loaded.has(href));
57
57
  }
58
58
 
59
- /** Resolves once every sheet loaded OR failed — a missing sheet must not hang the navigation. */
60
- export function loadStylesheets(doc: Document, hrefs: readonly string[]): Promise<void> {
59
+ /**
60
+ * Resolves once every sheet loaded OR failed — a missing sheet must not hang the navigation. Each
61
+ * sheet joins `owned` as it is appended, so a swap that never happens still leaves it retirable.
62
+ */
63
+ export function loadStylesheets(
64
+ doc: Document,
65
+ hrefs: readonly string[],
66
+ owned: WeakSet<Element>,
67
+ ): Promise<void> {
61
68
  return Promise.all(
62
69
  hrefs.map(
63
70
  (href) =>
@@ -68,6 +75,7 @@ export function loadStylesheets(doc: Document, hrefs: readonly string[]): Promis
68
75
  link.onload = () => done();
69
76
  link.onerror = () => done();
70
77
  doc.head.append(link);
78
+ owned.add(link);
71
79
  }),
72
80
  ),
73
81
  ).then(() => undefined);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The client router's tie to the origin's other tabs: one `BroadcastChannel`, so a write or a new
3
+ * principal in one tab empties every tab's prefetch cache. Closed while the page sits in the
4
+ * back/forward cache (an open channel keeps a page out of it); reopened, cache emptied, on return.
5
+ */
6
+
7
+ import { onClientWrite, onRescope } from '@ultimat3/core/page';
8
+
9
+ /** One channel per origin: a principal change or a write in one tab empties every tab's cache. */
10
+ export const NAVIGATION_CHANNEL = 'ultimate:navigation';
11
+
12
+ export interface TabSync {
13
+ /** This tab and every other: a write or a new principal makes every held page suspect. */
14
+ forget(): void;
15
+ stop(): void;
16
+ }
17
+
18
+ /** `clear` empties this tab's cache; called for this tab's own events and every other tab's. */
19
+ export function tabSync(win: Window, clear: () => void): TabSync {
20
+ const open = (): BroadcastChannel | undefined => {
21
+ if (!('BroadcastChannel' in win)) return undefined;
22
+ const opened = new BroadcastChannel(NAVIGATION_CHANNEL);
23
+ opened.onmessage = clear;
24
+ return opened;
25
+ };
26
+ let channel = open();
27
+ const forget = (): void => {
28
+ clear();
29
+ channel?.postMessage('clear');
30
+ };
31
+ // `pagehide`, not `unload`: it fires for a page entering the back/forward cache too, and a page
32
+ // holding an open channel is refused by it — Back then reloads the page from the network.
33
+ const onHide = (): void => {
34
+ channel?.close();
35
+ channel = undefined;
36
+ };
37
+ // Restored from that cache: every message sent while it was frozen was missed, so nothing held
38
+ // can be trusted. A `pageshow` of a fresh load finds the channel open and leaves it.
39
+ const onShow = (event: PageTransitionEvent): void => {
40
+ if (!event.persisted || channel !== undefined) return;
41
+ channel = open();
42
+ clear();
43
+ };
44
+ win.addEventListener('pagehide', onHide);
45
+ win.addEventListener('pageshow', onShow);
46
+ const offWrite = onClientWrite(forget);
47
+ const offRescope = onRescope(forget);
48
+ return {
49
+ forget,
50
+ stop() {
51
+ win.removeEventListener('pagehide', onHide);
52
+ win.removeEventListener('pageshow', onShow);
53
+ offWrite();
54
+ offRescope();
55
+ onHide();
56
+ },
57
+ };
58
+ }
package/src/navigation.ts CHANGED
@@ -14,19 +14,15 @@
14
14
  * on documents whose surface opted in (`navigation: { client: [...] }` in `app.config.ts`).
15
15
  */
16
16
 
17
- import {
18
- CLIENT_BUILD_META,
19
- CLIENT_SCOPE_META,
20
- onClientWrite,
21
- onRescope,
22
- } from '@ultimat3/core/page';
17
+ import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
23
18
  import { navigationCache } from './navigation-cache';
24
19
  import {
25
20
  anchorOf,
26
21
  announcer,
27
- fieldText,
28
22
  focusMain,
23
+ formAction,
29
24
  formFields,
25
+ formPairs,
30
26
  handOver,
31
27
  linkFacts,
32
28
  type RunningTransition,
@@ -37,7 +33,6 @@ import {
37
33
  type EntryState,
38
34
  entryOf,
39
35
  STATE_KEY,
40
- saveEntryScroll,
41
36
  scrollAfter,
42
37
  withoutFragment,
43
38
  } from './navigation-history';
@@ -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, which a press skips to the new page. */
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
- 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
- };
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
- return request(url, 'soft', {
177
- method,
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') saveScroll();
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: options.hops ?? 0,
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: (options.hops ?? 0) + 1,
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
- await loadStylesheets(doc, missingStylesheets(doc, next));
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 | undefined): void => {
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. And a press during the animation skips to the new 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>`: 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.
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: new URL(say('action', win.location.href) || win.location.href, doc.baseURI).href,
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: [...fields].map(
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
- // A save still pending belongs to the entry just left.
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
- offWrite();
493
- offRescope();
494
- channel?.close();
474
+ scroll.stop();
475
+ tabs.stop();
495
476
  delete win.__xNavigation;
496
477
  },
497
478
  };