@ultimat3/render 22.8.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "22.8.0",
3
+ "version": "22.8.1",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,11 +38,11 @@
38
38
  "test": "bun test"
39
39
  },
40
40
  "dependencies": {
41
- "@ultimat3/cache": "22.8.0",
42
- "@ultimat3/core": "22.8.0",
43
- "@ultimat3/http": "22.8.0",
44
- "@ultimat3/i18n": "22.8.0",
45
- "@ultimat3/seo": "22.8.0",
41
+ "@ultimat3/cache": "22.8.1",
42
+ "@ultimat3/core": "22.8.1",
43
+ "@ultimat3/http": "22.8.1",
44
+ "@ultimat3/i18n": "22.8.1",
45
+ "@ultimat3/seo": "22.8.1",
46
46
  "sass": "1.104.0"
47
47
  }
48
48
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The controller's test harness beside the fake DOM: a page with the links every test follows, a
3
+ * tab whose `fetch` the test answers (every call recorded), an event dispatched the way a browser
4
+ * does, and the router the test started — stopped after each by `stopRouter()`.
5
+ */
6
+ import { startNavigation } from './navigation';
7
+ import {
8
+ FakeDocument,
9
+ type FakeElement,
10
+ type FakeWindow,
11
+ fakeWindow,
12
+ h,
13
+ htmlAnswer,
14
+ routerHead,
15
+ } from './navigation-dom-fixture';
16
+
17
+ export type Handler = (init: RequestInit | undefined) => Response | Promise<Response>;
18
+
19
+ export const page = (
20
+ title: string,
21
+ meta: { scope?: string; build?: string } = {},
22
+ body: FakeElement[] = [],
23
+ ) =>
24
+ new FakeDocument(routerHead(title, [], meta), [
25
+ h('main', {}, [
26
+ h('h1', {}, title),
27
+ h('a', { id: 'to-b', href: '/b' }, 'b'),
28
+ h('a', { id: 'to-c', href: '/c' }, 'c'),
29
+ h('a', { id: 'to-slow', href: '/slow' }, 'slow'),
30
+ h('a', { id: 'plain', href: '/b', 'data-x-no-prefetch': '' }, 'plain'),
31
+ ...body,
32
+ ]),
33
+ h('script', {}, `hydrate(${title})`),
34
+ ]);
35
+
36
+ /** The router the current test started; `stopRouter()` in an `afterEach` lets it go. */
37
+ let router: ReturnType<typeof startNavigation>;
38
+ export const currentRouter = (): ReturnType<typeof startNavigation> => router;
39
+ export function stopRouter(): void {
40
+ router?.stop();
41
+ router = undefined;
42
+ }
43
+
44
+ /** A tab on `/a` whose `fetch` answers from `routes`; `calls` records `METHOD path purpose`. */
45
+ export function tab(routes: Record<string, Handler>, doc = page('A')) {
46
+ const calls: string[] = [];
47
+ const fetch = (url: string, init?: RequestInit): Promise<Response> => {
48
+ const u = new URL(url);
49
+ const headers = (init?.headers ?? {}) as Record<string, string>;
50
+ calls.push(
51
+ `${init?.method ?? 'GET'} ${u.pathname}${u.search} ${headers['x-ultimate-navigation']}`,
52
+ );
53
+ const handler = routes[u.pathname];
54
+ if (handler === undefined) return Promise.reject(new TypeError('network'));
55
+ return Promise.resolve(handler(init));
56
+ };
57
+ const win = fakeWindow(doc, 'https://app.test/a', fetch);
58
+ router = startNavigation(win as unknown as Window);
59
+ return { win, doc, calls };
60
+ }
61
+
62
+ /** An event the way a browser dispatches it: cancelable, with its target and fields. */
63
+ export function fire(
64
+ on: EventTarget,
65
+ type: string,
66
+ target: unknown,
67
+ fields: Record<string, unknown> = {},
68
+ ): Event {
69
+ const event = new Event(type, { cancelable: true, bubbles: true });
70
+ Object.defineProperty(event, 'target', { value: target });
71
+ for (const [key, value] of Object.entries({
72
+ button: 0,
73
+ metaKey: false,
74
+ ctrlKey: false,
75
+ shiftKey: false,
76
+ altKey: false,
77
+ ...fields,
78
+ })) {
79
+ Object.defineProperty(event, key, { value });
80
+ }
81
+ on.dispatchEvent(event);
82
+ return event;
83
+ }
84
+
85
+ export const click = (win: FakeWindow, id: string, fields?: Record<string, unknown>) =>
86
+ fire(win, 'click', win.document.getElementById(id), fields);
87
+
88
+ export const answers = {
89
+ b: () => htmlAnswer('page:B', page('B')),
90
+ c: () => htmlAnswer('page:C', page('C')),
91
+ };
@@ -257,6 +257,11 @@ export class FakeDocument extends EventTarget {
257
257
  node.adopt(this);
258
258
  return node;
259
259
  }
260
+ /** What a test says is under the pointer, for a click the browser aimed at `<html>`. */
261
+ underPointer: FakeElement | null = null;
262
+ elementFromPoint(): FakeElement | null {
263
+ return this.underPointer;
264
+ }
260
265
  getElementById(id: string): FakeElement | null {
261
266
  return this.documentElement.querySelectorAll('[id]').find((el) => el.id === id) ?? null;
262
267
  }
@@ -375,6 +380,7 @@ export function fakeWindow(doc: FakeDocument, url: string, fetch: FakeWindow['fe
375
380
  globals['DOMParser'] = FakeParser;
376
381
  globals['HTMLFormElement'] = FakeForm;
377
382
  globals['FormData'] = FakeFormData;
383
+ globals['HTMLElement'] = FakeElement;
378
384
  return self;
379
385
  }
380
386
 
@@ -56,13 +56,13 @@ const hush = (): void => undefined;
56
56
  * leaves an unhandled rejection behind. A throw inside `apply` rejects `updateCallbackDone`, which
57
57
  * the caller awaits and turns into a full load.
58
58
  */
59
- export async function transition(win: Window, apply: () => void): Promise<void> {
59
+ export async function transition(
60
+ win: Window,
61
+ apply: () => void,
62
+ track?: (running: RunningTransition | undefined) => void,
63
+ ): Promise<void> {
60
64
  const doc = win.document as Document & {
61
- startViewTransition?: (update: () => void) => {
62
- updateCallbackDone: Promise<void>;
63
- ready: Promise<void>;
64
- finished: Promise<void>;
65
- };
65
+ startViewTransition?: (update: () => void) => RunningTransition;
66
66
  };
67
67
  const still = win.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
68
68
  if (doc.startViewTransition === undefined || still) {
@@ -70,11 +70,23 @@ export async function transition(win: Window, apply: () => void): Promise<void>
70
70
  return;
71
71
  }
72
72
  const running = doc.startViewTransition(apply);
73
+ track?.(running);
73
74
  running.ready.catch(hush);
74
- running.finished.catch(hush);
75
+ running.finished.then(
76
+ () => track?.(undefined),
77
+ () => track?.(undefined),
78
+ );
75
79
  await running.updateCallbackDone;
76
80
  }
77
81
 
82
+ /** The part of a `ViewTransition` the router uses. */
83
+ export interface RunningTransition {
84
+ readonly updateCallbackDone: Promise<void>;
85
+ readonly ready: Promise<void>;
86
+ readonly finished: Promise<void>;
87
+ skipTransition?(): void;
88
+ }
89
+
78
90
  /** Where a keyboard or screen-reader user lands: the page's `<main>`, else its first heading. */
79
91
  export function focusMain(doc: Document): void {
80
92
  const target = doc.querySelector<HTMLElement>('main') ?? doc.querySelector<HTMLElement>('h1');
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The client router's history entries: its own slot in `history.state` (the scroll offset and the
3
+ * document the entry shows), how an entry is read back, how the scroll is saved into the entry of
4
+ * the page ON SCREEN, and where a page lands. Split from `navigation.ts` so that file is the
5
+ * controller and nothing else.
6
+ */
7
+
8
+ /** The router's own slot in `history.state`, beside whatever an app keeps there. */
9
+ export const STATE_KEY = '__x';
10
+
11
+ export interface EntryState {
12
+ readonly scroll: readonly [number, number];
13
+ /** The document this entry shows — what back/forward compares with the one on screen. */
14
+ readonly doc: string;
15
+ }
16
+
17
+ /** Back/forward, as the router passes it: `none` restores; the others start at the top. */
18
+ export type HistoryMode = 'push' | 'replace' | 'none' | undefined;
19
+
20
+ export const withoutFragment = (url: string): string => url.split('#')[0] ?? url;
21
+
22
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
23
+ typeof value === 'object' && value !== null;
24
+
25
+ /** The router's entry in a `history.state`, or `undefined` for an entry an app pushed. */
26
+ export const entryOf = (state: unknown): EntryState | undefined => {
27
+ const entry = isRecord(state) ? state[STATE_KEY] : undefined;
28
+ return isRecord(entry) && typeof entry['doc'] === 'string'
29
+ ? (entry as unknown as EntryState)
30
+ : undefined;
31
+ };
32
+
33
+ /**
34
+ * The scroll offset, into the entry of the document `rendered` — never into another's: between a
35
+ * back/forward and its swap, the current entry is the next page's, and this offset is not its.
36
+ */
37
+ export function saveEntryScroll(win: Window, rendered: string): void {
38
+ const state = (win.history.state ?? {}) as Record<string, unknown>;
39
+ const held = entryOf(state)?.doc;
40
+ if (held !== undefined && held !== rendered) return;
41
+ const entry: EntryState = { scroll: [win.scrollX, win.scrollY], doc: held ?? rendered };
42
+ win.history.replaceState({ ...state, [STATE_KEY]: entry }, '');
43
+ }
44
+
45
+ /**
46
+ * Where the new page lands: the saved offset on back/forward, the fragment's element, else the
47
+ * top. `instant`, whatever the page's `scroll-behavior` says — `@ultimat3/ui`'s reset makes it
48
+ * `smooth`, and a new page is a new place, which a full load jumps to rather than glides to.
49
+ */
50
+ export function scrollAfter(win: Window, doc: Document, url: string, mode: HistoryMode): void {
51
+ const hash = new URL(url).hash;
52
+ const target = hash.length > 1 ? doc.getElementById(decodeURIComponent(hash.slice(1))) : null;
53
+ const saved = entryOf(win.history.state);
54
+ const to = (left: number, top: number): void =>
55
+ win.scrollTo({ left, top, behavior: 'instant' as ScrollBehavior });
56
+ if (mode === 'none' && saved !== undefined) to(saved.scroll[0], saved.scroll[1]);
57
+ else if (target !== null) target.scrollIntoView({ behavior: 'instant' as ScrollBehavior });
58
+ else to(0, 0);
59
+ }
package/src/navigation.ts CHANGED
@@ -29,9 +29,18 @@ import {
29
29
  formFields,
30
30
  handOver,
31
31
  linkFacts,
32
+ type RunningTransition,
32
33
  transition,
33
34
  } from './navigation-dom';
34
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';
35
44
  import {
36
45
  answerMovesTab,
37
46
  type FormFacts,
@@ -75,29 +84,9 @@ export interface NavigationRouter {
75
84
  stop(): void;
76
85
  }
77
86
 
78
- /** The router's own slot in `history.state`, beside whatever an app keeps there. */
79
- const STATE_KEY = '__x';
80
- interface EntryState {
81
- readonly scroll: readonly [number, number];
82
- /** The document this entry shows — what back/forward compares with the one on screen. */
83
- readonly doc: string;
84
- }
85
-
86
87
  /** One channel per origin: a principal change or a write in one tab empties every tab's cache. */
87
88
  const CHANNEL = 'ultimate:navigation';
88
89
 
89
- const withoutFragment = (url: string): string => url.split('#')[0] ?? url;
90
-
91
- const isRecord = (value: unknown): value is Record<string, unknown> =>
92
- typeof value === 'object' && value !== null;
93
-
94
- const entryOf = (state: unknown): EntryState | undefined => {
95
- const entry = isRecord(state) ? state[STATE_KEY] : undefined;
96
- return isRecord(entry) && typeof entry['doc'] === 'string'
97
- ? (entry as unknown as EntryState)
98
- : undefined;
99
- };
100
-
101
90
  interface RouterWindow extends Window {
102
91
  __xNavigation?: NavigationRouter;
103
92
  }
@@ -118,6 +107,16 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
118
107
  ),
119
108
  );
120
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;
121
120
  let untrusted = false;
122
121
  let inflight: AbortController | undefined;
123
122
  const live = announcer(doc);
@@ -135,15 +134,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
135
134
  const offWrite = onClientWrite(forget);
136
135
  const offRescope = onRescope(forget);
137
136
 
138
- const saveScroll = (): void => {
139
- const state = (win.history.state ?? {}) as Record<string, unknown>;
140
- const held = entryOf(state)?.doc;
141
- // Only into the entry of the document ON SCREEN: between a back/forward and its swap, the
142
- // current entry is the next page's, and this scroll offset is not its to keep.
143
- if (held !== undefined && held !== rendered) return;
144
- const entry: EntryState = { scroll: [win.scrollX, win.scrollY], doc: held ?? rendered };
145
- win.history.replaceState({ ...state, [STATE_KEY]: entry }, '');
146
- };
137
+ const saveScroll = (): void => saveEntryScroll(win, rendered);
138
+
147
139
  saveScroll();
148
140
  // Saved as the visitor scrolls, once the scroll settles — so FORWARD restores too, and a
149
141
  // browser's cap on `replaceState` calls is never approached.
@@ -157,7 +149,10 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
157
149
  fetchDocument(win, doc, url, purpose, init);
158
150
 
159
151
  const prefetch = (url: string): void => {
160
- if (untrusted || withoutFragment(url) === rendered || cache.get(url) !== undefined) return;
152
+ const key = withoutFragment(url);
153
+ if (untrusted || key === rendered || key === navigatingTo || cache.get(url) !== undefined) {
154
+ return;
155
+ }
161
156
  // Cached whatever it answers: an empty `204` (the route did not opt in) is remembered too, so
162
157
  // a second hover does not ask again. Only a reusable page ever answers a click (`reusable`).
163
158
  cache.set(url, request(url, 'prefetch'));
@@ -170,22 +165,6 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
170
165
  if (doc.dispatchEvent(event)) win.location.assign(win.location.href);
171
166
  };
172
167
 
173
- /**
174
- * Where the new page lands: the saved offset on back/forward, the fragment's element, else the
175
- * top. `instant`, whatever the page's `scroll-behavior` says — `@ultimat3/ui`'s reset makes it
176
- * `smooth`, and a new page is a new place, which a full load jumps to rather than glides to.
177
- */
178
- const scrollAfter = (url: string, mode: NavigateOptions['history']): void => {
179
- const hash = new URL(url).hash;
180
- const target = hash.length > 1 ? doc.getElementById(decodeURIComponent(hash.slice(1))) : null;
181
- const saved = entryOf(win.history.state);
182
- const to = (left: number, top: number): void =>
183
- win.scrollTo({ left, top, behavior: 'instant' as ScrollBehavior });
184
- if (mode === 'none' && saved !== undefined) to(saved.scroll[0], saved.scroll[1]);
185
- else if (target !== null) target.scrollIntoView({ behavior: 'instant' as ScrollBehavior });
186
- else to(0, 0);
187
- };
188
-
189
168
  const answerFor = (
190
169
  url: string,
191
170
  method: 'GET' | 'POST',
@@ -231,6 +210,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
231
210
  inflight?.abort();
232
211
  const mine = new AbortController();
233
212
  inflight = mine;
213
+ win.clearTimeout(intent);
214
+ navigatingTo = withoutFragment(url);
234
215
  if (options.history !== 'none') saveScroll();
235
216
  const progress = win.setTimeout(
236
217
  () => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
@@ -296,23 +277,30 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
296
277
  await loadStylesheets(doc, missingStylesheets(doc, next));
297
278
  if (mine.signal.aborted) return;
298
279
  let swapped: ReturnType<typeof swapDocument> | undefined;
299
- await transition(win, () => {
300
- // A newer navigation started while the sheets loaded: this answer is no longer asked for.
301
- if (mine.signal.aborted) return;
302
- swapped = swapDocument(doc, next, owned);
303
- // Emptied here and filled after the scripts, so a page with the same title still reads out.
304
- live.textContent = '';
305
- swapped.body.append(live);
306
- rendered = withoutFragment(landed);
307
- if (options.history !== 'none') {
308
- const same = landed === win.location.href || options.history === 'replace';
309
- const state = { [STATE_KEY]: { scroll: [0, 0], doc: rendered } satisfies EntryState };
310
- if (same) win.history.replaceState(state, '', landed);
311
- else win.history.pushState(state, '', landed);
312
- }
313
- // Inside the swap, so a view transition's "after" frame is already where the page lands.
314
- scrollAfter(landed, options.history);
315
- });
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
+ );
316
304
  const done = swapped;
317
305
  if (done === undefined) return;
318
306
  if (method === 'POST' && answerMovesTab(facts)) {
@@ -339,12 +327,37 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
339
327
  win.clearTimeout(progress);
340
328
  if (inflight === mine) {
341
329
  inflight = undefined;
330
+ navigatingTo = undefined;
342
331
  doc.documentElement.removeAttribute(NAVIGATING_ATTRIBUTE);
343
332
  }
344
333
  }
345
334
  };
346
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
+
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
+ }
348
361
  const anchor = anchorOf(event.target);
349
362
  if (anchor === null) return;
350
363
  const verdict = linkVerdict(linkFacts(win, anchor, event));
@@ -393,8 +406,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
393
406
  void navigate(verdict.url, { method: 'POST', body });
394
407
  };
395
408
 
396
- let intent: number | undefined;
397
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;
398
412
  const anchor = anchorOf(event.target);
399
413
  if (anchor === null) return;
400
414
  const verdict = linkVerdict(linkFacts(win, anchor));
@@ -456,6 +470,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
456
470
  doc.addEventListener('focusin', onIntent);
457
471
  doc.addEventListener('touchstart', onIntent, { passive: true });
458
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 });
459
476
 
460
477
  const router: NavigationRouter = {
461
478
  navigate,
@@ -469,6 +486,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
469
486
  doc.removeEventListener('focusin', onIntent);
470
487
  doc.removeEventListener('touchstart', onIntent);
471
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 });
472
492
  offWrite();
473
493
  offRescope();
474
494
  channel?.close();