@ultimat3/render 26.0.0 → 26.1.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/src/navigation.ts CHANGED
@@ -10,25 +10,29 @@
10
10
  * over instead of letting `fetch` follow it, and a POST is never re-submitted — after a failed
11
11
  * one, only the server knows what landed.
12
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`).
13
+ * Loaded as one deferred classic script (`@ultimat3/cli` builds it to
14
+ * `/_x/assets/navigation/<hash>.js`) on documents whose surface opted in (`navigation: { client:
15
+ * [...] }` in `app.config.ts`).
15
16
  */
16
17
 
17
- import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
18
18
  import { navigationCache } from './navigation-cache';
19
19
  import {
20
20
  anchorOf,
21
21
  announcer,
22
22
  focusMain,
23
- formAction,
24
- formFields,
25
- formPairs,
26
23
  handOver,
24
+ intentUrl,
27
25
  linkFacts,
28
- type RunningTransition,
26
+ submitFacts,
29
27
  transition,
30
28
  } from './navigation-dom';
31
- import { type Answer, fetchDocument, metaOf } from './navigation-fetch';
29
+ import {
30
+ type Answer,
31
+ fetchDocument,
32
+ heldOrFetched,
33
+ metaOf,
34
+ responseFactsOf,
35
+ } from './navigation-fetch';
32
36
  import {
33
37
  type EntryState,
34
38
  entryOf,
@@ -36,25 +40,22 @@ import {
36
40
  scrollAfter,
37
41
  withoutFragment,
38
42
  } from './navigation-history';
43
+ import { modalController } from './navigation-modal';
44
+ import { addressOf, modalAddress, modalHistory } from './navigation-modal-rules';
45
+ import { pressTracker } from './navigation-press';
39
46
  import {
40
47
  answerMovesTab,
41
- type FormFacts,
42
48
  formVerdict,
43
49
  linkVerdict,
44
- mayPrefetch,
45
50
  NAVIGATE_EVENT,
46
51
  NAVIGATED_EVENT,
47
52
  NAVIGATING_ATTRIBUTE,
48
53
  NAVIGATION_ERROR_EVENT,
49
54
  NAVIGATION_MAX_HOPS,
50
55
  NAVIGATION_META,
51
- NAVIGATION_NO_PREFETCH_ATTRIBUTE,
52
56
  NAVIGATION_PREFETCH_DELAY_MS,
53
57
  NAVIGATION_PROGRESS_DELAY_MS,
54
- NAVIGATION_RELOAD_ATTRIBUTE,
55
- type ResponseFacts,
56
58
  responseVerdict,
57
- reusable,
58
59
  } from './navigation-rules';
59
60
  import { scrollKeeper } from './navigation-scroll';
60
61
  import {
@@ -74,11 +75,19 @@ export interface NavigateOptions {
74
75
  readonly history?: 'push' | 'replace' | 'none';
75
76
  /** Redirects already followed for this navigation. */
76
77
  readonly hops?: number;
78
+ /** The URL's hash addressed it: a modal, or nothing — never a page (`presentation`). */
79
+ readonly fromHash?: boolean;
77
80
  }
78
81
 
79
82
  export interface NavigationRouter {
80
83
  navigate(url: string, options?: NavigateOptions): Promise<void>;
81
84
  prefetch(url: string): void;
85
+ /** The page on screen, fetched again and swapped in where it is scrolled. */
86
+ refresh(): Promise<void>;
87
+ /** `path` as the modal over this page (`#<path>`); anything but a modal of this tab: nothing. */
88
+ openModal(path: string): Promise<void>;
89
+ /** As Escape: Back through the router's own entry, else the hash dropped in place. */
90
+ closeModal(): void;
82
91
  stop(): void;
83
92
  }
84
93
 
@@ -104,15 +113,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
104
113
  let rendered = withoutFragment(win.location.href);
105
114
  /** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
106
115
  let navigatingTo: string | undefined;
107
- /** The view transition still animating — skipped only for a click it swallowed (`onClick`). */
108
- let animating: RunningTransition | undefined;
109
- /** Between a press and its click: the focus the press gives a link is not a hover. */
110
- let pressed = false;
111
- /** The press landed while a transition was painting over the page (see `onClick`). */
112
- let pressedOverTransition = false;
116
+ const press = pressTracker(doc);
113
117
  /** The pending prefetch of a hover, cancelled by anything that navigates. */
114
118
  let intent: number | undefined;
115
119
  let untrusted = false;
120
+ let backing = false; // a Back this router started is traversing: the URL is still the one left
116
121
  let inflight: AbortController | undefined;
117
122
  const live = announcer(doc);
118
123
  const owned = documentHead(doc);
@@ -120,6 +125,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
120
125
  const tabs = tabSync(win, () => cache.clear());
121
126
  const forget = tabs.forget;
122
127
  const scroll = scrollKeeper(win, () => rendered);
128
+ const modal = modalController(win, () => rendered, ran, owned);
123
129
 
124
130
  const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
125
131
  fetchDocument(win, doc, url, purpose, init);
@@ -138,7 +144,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
138
144
  const failed = (url: string, method: string, reason: string): void => {
139
145
  const detail = { url, method, reason };
140
146
  const event = new CustomEvent(NAVIGATION_ERROR_EVENT, { detail, cancelable: true });
141
- if (doc.dispatchEvent(event)) win.location.assign(win.location.href);
147
+ if (!doc.dispatchEvent(event)) return;
148
+ // Under a modal the URL differs only by its hash, which `assign` merely scrolls to: reloaded,
149
+ // the page and its modal are both asked for again (the entry is the router's own GET).
150
+ if (modal.address === undefined) win.location.assign(win.location.href);
151
+ else win.location.reload();
142
152
  };
143
153
 
144
154
  const answerFor = (
@@ -154,22 +164,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
154
164
  const body = options.body === undefined ? {} : { body: options.body };
155
165
  return request(url, 'soft', { method, ...body, signal }).finally(forget);
156
166
  }
157
- const held = cache.peek(url);
158
- cache.delete(url);
159
- if (held === undefined) return request(url, 'soft', { signal });
160
- return held.value.then(
161
- (answer) =>
162
- reusable({
163
- status: answer.status,
164
- html: answer.html !== null,
165
- location: answer.location,
166
- noStore: answer.noStore,
167
- ageMs: held.ageMs,
168
- })
169
- ? answer
170
- : request(url, 'soft', { signal }),
171
- () => request(url, 'soft', { signal }),
172
- );
167
+ return heldOrFetched(cache, url, () => request(url, 'soft', { signal }));
173
168
  };
174
169
 
175
170
  const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
@@ -183,7 +178,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
183
178
  // After an answer for another principal or build was shown in place, this tab is no longer
184
179
  // trusted to swap: every navigation is a real load (`answerMovesTab`).
185
180
  if (untrusted && method === 'GET') {
186
- win.location.assign(url);
181
+ if (options.fromHash === true) modal.clear();
182
+ else win.location.assign(url);
187
183
  return;
188
184
  }
189
185
  const detail = { url, method };
@@ -212,22 +208,20 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
212
208
  if (mine.signal.aborted) return;
213
209
  const next =
214
210
  answer.html === null ? null : new DOMParser().parseFromString(answer.html, 'text/html');
215
- const facts: ResponseFacts = {
211
+ const facts = responseFactsOf(doc, next, answer, { method, requested: url, hops });
212
+ const verdict = responseVerdict(facts);
213
+ const landed = `${withoutFragment(url)}${new URL(url).hash}`;
214
+ const shown = modal.presentationOf(next, landed, {
215
+ fromHash: options.fromHash === true,
216
216
  method,
217
- requested: url,
217
+ verdict: verdict.kind,
218
218
  status: answer.status,
219
- opaqueRedirect: answer.opaqueRedirect,
220
- location: answer.location,
221
- contentType: answer.contentType,
222
- hops,
223
- surface: metaOf(doc, NAVIGATION_META),
224
- nextSurface: next === null ? null : metaOf(next, NAVIGATION_META),
225
- build: metaOf(doc, CLIENT_BUILD_META),
226
- nextBuild: answer.build ?? (next === null ? null : metaOf(next, CLIENT_BUILD_META)),
227
- scope: metaOf(doc, CLIENT_SCOPE_META),
228
- nextScope: next === null ? null : metaOf(next, CLIENT_SCOPE_META),
229
- };
230
- const verdict = responseVerdict(facts);
219
+ movesTab: answerMovesTab(facts),
220
+ });
221
+ if (shown === 'degrade') {
222
+ modal.clear();
223
+ return;
224
+ }
231
225
  switch (verdict.kind) {
232
226
  case 'stay':
233
227
  return;
@@ -242,7 +236,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
242
236
  return;
243
237
  case 'follow':
244
238
  await navigate(verdict.url, {
245
- history: options.history === 'none' ? 'replace' : 'push',
239
+ // Out of a modal, a redirect's target takes the modal's entry: Back never reopens it.
240
+ history: options.history === 'none' || modal.address !== undefined ? 'replace' : 'push',
246
241
  hops: hops + 1,
247
242
  });
248
243
  return;
@@ -253,18 +248,26 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
253
248
  break;
254
249
  }
255
250
  if (next === null) return;
256
- const landed = `${withoutFragment(url)}${new URL(url).hash}`;
251
+ // A page answered while a modal is open: the modal goes, and so does its history entry.
252
+ const leave = shown === 'page' ? modal.leaveFor(options.history, landed) : undefined;
253
+ const history = leave === undefined ? options.history : leave === 'back' ? 'none' : leave;
257
254
  try {
258
255
  // Owned from the moment they are appended: a navigation aborted after this point left its
259
256
  // sheets in the head for good, styling every page after it — the next swap retires an
260
257
  // owned sheet its page does not link.
261
258
  await loadStylesheets(doc, missingStylesheets(doc, next), owned);
262
259
  if (mine.signal.aborted) return;
260
+ if (shown === 'modal') {
261
+ await modal.present(next, addressOf(landed), landed, modalHistory(method, history));
262
+ doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
263
+ return;
264
+ }
265
+ if (leave === 'back') {
266
+ backing = true;
267
+ win.history.back();
268
+ }
269
+ modal.dismiss();
263
270
  let swapped: ReturnType<typeof swapDocument> | undefined;
264
- const track = (running: RunningTransition, finished: boolean): void => {
265
- if (!finished) animating = running;
266
- else if (animating === running) animating = undefined;
267
- };
268
271
  await transition(
269
272
  win,
270
273
  () => {
@@ -275,16 +278,16 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
275
278
  live.textContent = '';
276
279
  swapped.body.append(live);
277
280
  rendered = withoutFragment(landed);
278
- if (options.history !== 'none') {
279
- const same = landed === win.location.href || options.history === 'replace';
281
+ if (history !== 'none') {
282
+ const same = landed === win.location.href || history === 'replace';
280
283
  const state = { [STATE_KEY]: { scroll: [0, 0], doc: rendered } satisfies EntryState };
281
284
  if (same) win.history.replaceState(state, '', landed);
282
285
  else win.history.pushState(state, '', landed);
283
286
  }
284
287
  // Inside the swap, so a view transition's "after" frame is already where the page lands.
285
- scrollAfter(win, doc, landed, options.history);
288
+ scrollAfter(win, doc, landed, history);
286
289
  },
287
- track,
290
+ press.track,
288
291
  );
289
292
  const done = swapped;
290
293
  if (done === undefined) return;
@@ -302,6 +305,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
302
305
  focusMain(doc);
303
306
  live.textContent = doc.title;
304
307
  doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
308
+ reconcile();
305
309
  } catch {
306
310
  // A swap that failed part-way leaves a page nobody rendered: a GET is loaded for real; a
307
311
  // POST's answer cannot be asked for again, so the visitor is told and shown this page.
@@ -319,64 +323,30 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
319
323
  };
320
324
 
321
325
  const onPress = (): void => {
322
- pressed = true;
323
- pressedOverTransition = animating !== undefined;
326
+ press.onPress();
324
327
  // A press is not a hover: the click it becomes navigates, and a guess on its heels is a
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.
328
+ // second request for the same page.
327
329
  win.clearTimeout(intent);
328
330
  };
329
- const onRelease = (): void => {
330
- pressed = false;
331
- };
332
331
 
333
332
  const onClick = (event: MouseEvent): void => {
334
333
  win.clearTimeout(intent);
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.
341
- if (pressedOverTransition && event.target === doc.documentElement) {
342
- pressedOverTransition = false;
343
- animating?.skipTransition?.();
344
- const hit = doc.elementFromPoint(event.clientX, event.clientY);
345
- if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
346
- return;
347
- }
334
+ if (press.overlayClick(event)) return;
348
335
  const anchor = anchorOf(event.target);
349
336
  if (anchor === null) return;
350
337
  const verdict = linkVerdict(linkFacts(win, anchor, event));
351
338
  if (verdict.kind !== 'soft') return;
352
339
  event.preventDefault();
353
- void navigate(verdict.url);
340
+ // A link to the page beneath an open modal (its Cancel) closes the modal: nothing is fetched.
341
+ if (modal.address !== undefined && withoutFragment(verdict.url) === rendered) modal.close();
342
+ else void navigate(verdict.url);
354
343
  };
355
344
 
356
345
  const onSubmit = (event: SubmitEvent): void => {
357
346
  const form = event.target;
358
347
  // An untrusted tab (`answerMovesTab`) leaves every form to the browser.
359
348
  if (!(form instanceof HTMLFormElement) || untrusted) return;
360
- const submitter = event.submitter;
361
- const fields = formFields(form, submitter);
362
- const say = (attr: string, fallback: string): string =>
363
- submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
364
- const facts: FormFacts = {
365
- action: formAction(say('action', ''), win.location.href, doc.baseURI),
366
- current: win.location.href,
367
- method: say('method', 'get').toLowerCase(),
368
- enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
369
- target: say('target', ''),
370
- defaultPrevented: event.defaultPrevented,
371
- reload:
372
- form.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) ||
373
- submitter?.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) === true,
374
- // `unknown`: a browser yields a `File` for a file input, whatever a server-side type says.
375
- hasFile: [...fields.values()].some(
376
- (value: unknown) => value instanceof File && value.name !== '',
377
- ),
378
- fields: formPairs(fields),
379
- };
349
+ const { facts, fields } = submitFacts(win, form, event.submitter, event.defaultPrevented);
380
350
  const verdict = formVerdict(facts);
381
351
  if (verdict.kind === 'native') return;
382
352
  event.preventDefault();
@@ -393,27 +363,12 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
393
363
 
394
364
  const onIntent = (event: Event): void => {
395
365
  // The focus a press gives a link: its click is a moment away, and it navigates.
396
- if (event.type === 'focusin' && pressed) return;
397
- const anchor = anchorOf(event.target);
398
- if (anchor === null) return;
399
- const verdict = linkVerdict(linkFacts(win, anchor));
400
- const connection = (
401
- win.navigator as { connection?: { saveData?: boolean; effectiveType?: string } }
402
- ).connection;
403
- if (
404
- verdict.kind !== 'soft' ||
405
- !mayPrefetch({
406
- url: verdict.url,
407
- noPrefetch: anchor.hasAttribute(NAVIGATION_NO_PREFETCH_ATTRIBUTE),
408
- saveData: connection?.saveData,
409
- effectiveType: connection?.effectiveType,
410
- })
411
- ) {
412
- return;
413
- }
366
+ if (event.type === 'focusin' && press.pressed()) return;
367
+ const url = intentUrl(win, event.target);
368
+ if (url === undefined) return;
414
369
  win.clearTimeout(intent);
415
370
  const delay = event.type === 'pointerover' ? NAVIGATION_PREFETCH_DELAY_MS : 0;
416
- intent = win.setTimeout(() => prefetch(verdict.url), delay);
371
+ intent = win.setTimeout(() => prefetch(url), delay);
417
372
  };
418
373
  const onLeave = (): void => win.clearTimeout(intent);
419
374
 
@@ -424,7 +379,10 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
424
379
  */
425
380
  const onPop = (event: PopStateEvent): void => {
426
381
  scroll.cancel();
427
- const url = win.location.href;
382
+ backing = false;
383
+ // The page is the PATH: a modal's hash is the modal's (`reconcile`), never the page's fragment.
384
+ const at = win.location.href;
385
+ const url = modalAddress(new URL(at).hash, at) === null ? at : withoutFragment(at);
428
386
  const entry = entryOf(event.state);
429
387
  const wanted = entry?.doc ?? withoutFragment(url);
430
388
  const same =
@@ -442,40 +400,76 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
442
400
  behavior: 'instant' as ScrollBehavior,
443
401
  });
444
402
  }
403
+ reconcile();
445
404
  };
446
405
 
406
+ /**
407
+ * The modal follows the URL's hash, once the page on screen is the URL's: opened (fetched, as a
408
+ * hash address, so a stale one degrades), kept, or dismissed. On start, on Back/Forward, on a
409
+ * typed hash, and after every page swap — a Back onto a modal's entry swaps its page first.
410
+ */
411
+ function reconcile(): void {
412
+ // Until that Back lands, the hash still names the modal just left: it would be opened again.
413
+ if (backing) return;
414
+ const at = win.location.href;
415
+ if (withoutFragment(at) !== rendered) return;
416
+ const wanted = modalAddress(new URL(at).hash, at);
417
+ if (wanted === null) modal.dismiss();
418
+ else if (wanted !== modal.address) {
419
+ void navigate(new URL(wanted, at).href, { history: 'none', fromHash: true });
420
+ }
421
+ }
422
+
447
423
  // `window`, bubble phase: after every handler on the page — Solid delegates to `document` — has
448
424
  // had its chance to `preventDefault`, which the rules then honour.
449
- win.addEventListener('click', onClick);
450
- win.addEventListener('submit', onSubmit);
451
- doc.addEventListener('pointerover', onIntent, { passive: true });
452
- doc.addEventListener('pointerout', onLeave, { passive: true });
453
- doc.addEventListener('focusin', onIntent);
454
- doc.addEventListener('touchstart', onIntent, { passive: true });
455
- win.addEventListener('popstate', onPop);
456
- doc.addEventListener('pointerdown', onPress, { capture: true, passive: true });
457
- doc.addEventListener('pointerup', onRelease, { capture: true, passive: true });
458
- doc.addEventListener('pointercancel', onRelease, { capture: true, passive: true });
425
+ const passive = { passive: true };
426
+ const pressing = { capture: true, passive: true };
427
+ const listeners: readonly (readonly [EventTarget, string, EventListener, object?])[] = [
428
+ [win, 'click', onClick as EventListener],
429
+ [win, 'submit', onSubmit as EventListener],
430
+ [doc, 'pointerover', onIntent, passive],
431
+ [doc, 'pointerout', onLeave, passive],
432
+ [doc, 'focusin', onIntent],
433
+ [doc, 'touchstart', onIntent, passive],
434
+ [win, 'popstate', onPop as EventListener],
435
+ [win, 'hashchange', reconcile],
436
+ [doc, 'pointerdown', onPress, pressing],
437
+ [doc, 'pointerup', press.onRelease, pressing],
438
+ [doc, 'pointercancel', press.onRelease, pressing],
439
+ ];
440
+ for (const [target, type, listener, options] of listeners) {
441
+ target.addEventListener(type, listener, options);
442
+ }
459
443
 
460
444
  const router: NavigationRouter = {
461
445
  navigate,
462
446
  prefetch,
447
+ refresh() {
448
+ // A fresh answer for the page on screen, landing where the visitor is scrolled.
449
+ cache.delete(rendered);
450
+ scroll.save();
451
+ return navigate(rendered, { history: 'none' });
452
+ },
453
+ openModal(path) {
454
+ const address = modalAddress(`#${path}`, win.location.href);
455
+ if (address === null) return Promise.resolve();
456
+ return navigate(new URL(address, win.location.href).href, {
457
+ fromHash: true,
458
+ history: 'push',
459
+ });
460
+ },
461
+ closeModal: () => modal.close(),
463
462
  stop() {
464
- win.removeEventListener('click', onClick);
465
- win.removeEventListener('submit', onSubmit);
466
- doc.removeEventListener('pointerover', onIntent);
467
- doc.removeEventListener('pointerout', onLeave);
468
- doc.removeEventListener('focusin', onIntent);
469
- doc.removeEventListener('touchstart', onIntent);
470
- win.removeEventListener('popstate', onPop);
471
- doc.removeEventListener('pointerdown', onPress, { capture: true });
472
- doc.removeEventListener('pointerup', onRelease, { capture: true });
473
- doc.removeEventListener('pointercancel', onRelease, { capture: true });
463
+ for (const [target, type, listener, options] of listeners) {
464
+ target.removeEventListener(type, listener, options);
465
+ }
466
+ modal.dismiss();
474
467
  scroll.stop();
475
468
  tabs.stop();
476
469
  delete win.__xNavigation;
477
470
  },
478
471
  };
479
472
  win.__xNavigation = router;
473
+ reconcile();
480
474
  return router;
481
475
  }
package/src/route.ts CHANGED
@@ -191,11 +191,19 @@ export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
191
191
  * in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
192
192
  * page whose GET does nothing but render. `'document'`: always a real document load, the route run
193
193
  * once by the browser — for a GET that records something (an open, a download, a token consumed).
194
- * The server enforces both before `load` (`@ultimat3/http`'s navigation gate).
194
+ * `'modal'`: presented OVER the page a visitor came from, addressed by the hash
195
+ * (`/runs#/runs/new`) — a create form, a confirmation; a full load of its own URL is still the
196
+ * whole page. Never fetched before a click. The server enforces all three before `load`
197
+ * (`@ultimat3/http`'s navigation gate) and names a modal in its document
198
+ * (`NAVIGATION_PRESENTATION_META`).
195
199
  */
196
- export type RouteNavigationMode = 'prefetch' | 'document';
200
+ export type RouteNavigationMode = 'prefetch' | 'document' | 'modal';
197
201
 
198
- export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = ['prefetch', 'document'];
202
+ export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
203
+ 'prefetch',
204
+ 'document',
205
+ 'modal',
206
+ ];
199
207
 
200
208
  /** The input shape of `defineRoute` — exactly the contract's twelve keys, nothing else. */
201
209
  export interface RouteDefinition<TData = RouteData> {