@ultimat3/render 22.7.1 → 22.8.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.
@@ -0,0 +1,480 @@
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
+ transition,
33
+ } from './navigation-dom';
34
+ import { type Answer, fetchDocument, metaOf } from './navigation-fetch';
35
+ import {
36
+ answerMovesTab,
37
+ type FormFacts,
38
+ formVerdict,
39
+ linkVerdict,
40
+ mayPrefetch,
41
+ NAVIGATE_EVENT,
42
+ NAVIGATED_EVENT,
43
+ NAVIGATING_ATTRIBUTE,
44
+ NAVIGATION_ERROR_EVENT,
45
+ NAVIGATION_META,
46
+ NAVIGATION_NO_PREFETCH_ATTRIBUTE,
47
+ NAVIGATION_PREFETCH_DELAY_MS,
48
+ NAVIGATION_PROGRESS_DELAY_MS,
49
+ NAVIGATION_RELOAD_ATTRIBUTE,
50
+ type ResponseFacts,
51
+ responseVerdict,
52
+ reusable,
53
+ } from './navigation-rules';
54
+ import {
55
+ documentHead,
56
+ loadStylesheets,
57
+ missingStylesheets,
58
+ notePersisted,
59
+ runScripts,
60
+ swapDocument,
61
+ } from './navigation-swap';
62
+
63
+ export interface NavigateOptions {
64
+ readonly method?: 'GET' | 'POST';
65
+ readonly body?: BodyInit;
66
+ /** `push` for a new visit, `replace` for the same URL, `none` for a back/forward. */
67
+ readonly history?: 'push' | 'replace' | 'none';
68
+ /** Redirects already followed for this navigation. */
69
+ readonly hops?: number;
70
+ }
71
+
72
+ export interface NavigationRouter {
73
+ navigate(url: string, options?: NavigateOptions): Promise<void>;
74
+ prefetch(url: string): void;
75
+ stop(): void;
76
+ }
77
+
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
+ /** One channel per origin: a principal change or a write in one tab empties every tab's cache. */
87
+ const CHANNEL = 'ultimate:navigation';
88
+
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
+ interface RouterWindow extends Window {
102
+ __xNavigation?: NavigationRouter;
103
+ }
104
+
105
+ /**
106
+ * Starts the router on a document that opted in, once per tab; `undefined` on one that did not —
107
+ * a page without `ultimate-navigation` is never intercepted, whatever script it loaded.
108
+ */
109
+ export function startNavigation(win: RouterWindow = window): NavigationRouter | undefined {
110
+ const doc = win.document;
111
+ if (win.__xNavigation !== undefined) return win.__xNavigation;
112
+ if (metaOf(doc, NAVIGATION_META) === null || !('DOMParser' in win)) return undefined;
113
+
114
+ const cache = navigationCache<Answer>();
115
+ const ran = new Set(
116
+ [...doc.querySelectorAll('script[src]')].map(
117
+ (s) => new URL(s.getAttribute('src') ?? '', doc.baseURI).href,
118
+ ),
119
+ );
120
+ let rendered = withoutFragment(win.location.href);
121
+ let untrusted = false;
122
+ let inflight: AbortController | undefined;
123
+ const live = announcer(doc);
124
+ const owned = documentHead(doc);
125
+ notePersisted(doc);
126
+ win.history.scrollRestoration = 'manual';
127
+
128
+ const channel = 'BroadcastChannel' in win ? new BroadcastChannel(CHANNEL) : undefined;
129
+ if (channel !== undefined) channel.onmessage = () => cache.clear();
130
+ /** This tab and every other: a write or a new principal makes every held page suspect. */
131
+ const forget = (): void => {
132
+ cache.clear();
133
+ channel?.postMessage('clear');
134
+ };
135
+ const offWrite = onClientWrite(forget);
136
+ const offRescope = onRescope(forget);
137
+
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
+ };
147
+ saveScroll();
148
+ // Saved as the visitor scrolls, once the scroll settles — so FORWARD restores too, and a
149
+ // browser's cap on `replaceState` calls is never approached.
150
+ let settle: number | undefined;
151
+ const onScroll = (): void => {
152
+ win.clearTimeout(settle);
153
+ settle = win.setTimeout(saveScroll, 150);
154
+ };
155
+
156
+ const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
157
+ fetchDocument(win, doc, url, purpose, init);
158
+
159
+ const prefetch = (url: string): void => {
160
+ if (untrusted || withoutFragment(url) === rendered || cache.get(url) !== undefined) return;
161
+ // Cached whatever it answers: an empty `204` (the route did not opt in) is remembered too, so
162
+ // a second hover does not ask again. Only a reusable page ever answers a click (`reusable`).
163
+ cache.set(url, request(url, 'prefetch'));
164
+ };
165
+
166
+ /** Cancelable; its default is a GET of the page the visitor is on. Nothing is re-sent. */
167
+ const failed = (url: string, method: string, reason: string): void => {
168
+ const detail = { url, method, reason };
169
+ const event = new CustomEvent(NAVIGATION_ERROR_EVENT, { detail, cancelable: true });
170
+ if (doc.dispatchEvent(event)) win.location.assign(win.location.href);
171
+ };
172
+
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
+ const answerFor = (
190
+ url: string,
191
+ method: 'GET' | 'POST',
192
+ options: NavigateOptions,
193
+ signal: AbortSignal,
194
+ ) => {
195
+ if (method === 'POST') {
196
+ forget();
197
+ return request(url, 'soft', {
198
+ method,
199
+ ...(options.body === undefined ? {} : { body: options.body }),
200
+ signal,
201
+ });
202
+ }
203
+ const held = cache.peek(url);
204
+ cache.delete(url);
205
+ if (held === undefined) return request(url, 'soft', { signal });
206
+ return held.value.then(
207
+ (answer) =>
208
+ reusable({
209
+ status: answer.status,
210
+ html: answer.html !== null,
211
+ location: answer.location,
212
+ noStore: answer.noStore,
213
+ ageMs: held.ageMs,
214
+ })
215
+ ? answer
216
+ : request(url, 'soft', { signal }),
217
+ () => request(url, 'soft', { signal }),
218
+ );
219
+ };
220
+
221
+ const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
222
+ const method = options.method ?? 'GET';
223
+ // After an answer for another principal or build was shown in place, this tab is no longer
224
+ // trusted to swap: every navigation is a real load (`answerMovesTab`).
225
+ if (untrusted && method === 'GET') {
226
+ win.location.assign(url);
227
+ return;
228
+ }
229
+ const detail = { url, method };
230
+ if (!doc.dispatchEvent(new CustomEvent(NAVIGATE_EVENT, { detail, cancelable: true }))) return;
231
+ inflight?.abort();
232
+ const mine = new AbortController();
233
+ inflight = mine;
234
+ if (options.history !== 'none') saveScroll();
235
+ const progress = win.setTimeout(
236
+ () => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
237
+ NAVIGATION_PROGRESS_DELAY_MS,
238
+ );
239
+ try {
240
+ let answer: Answer;
241
+ try {
242
+ answer = await answerFor(url, method, options, mine.signal);
243
+ } catch {
244
+ if (mine.signal.aborted) return;
245
+ // A GET that never arrived is the browser's to try; a POST that failed is never re-sent.
246
+ if (method === 'GET') win.location.assign(url);
247
+ else failed(url, method, 'the request failed on the network');
248
+ return;
249
+ }
250
+ if (mine.signal.aborted) return;
251
+ const next =
252
+ answer.html === null ? null : new DOMParser().parseFromString(answer.html, 'text/html');
253
+ const facts: ResponseFacts = {
254
+ method,
255
+ requested: url,
256
+ status: answer.status,
257
+ opaqueRedirect: answer.opaqueRedirect,
258
+ location: answer.location,
259
+ contentType: answer.contentType,
260
+ hops: options.hops ?? 0,
261
+ surface: metaOf(doc, NAVIGATION_META),
262
+ nextSurface: next === null ? null : metaOf(next, NAVIGATION_META),
263
+ build: metaOf(doc, CLIENT_BUILD_META),
264
+ nextBuild: answer.build ?? (next === null ? null : metaOf(next, CLIENT_BUILD_META)),
265
+ scope: metaOf(doc, CLIENT_SCOPE_META),
266
+ nextScope: next === null ? null : metaOf(next, CLIENT_SCOPE_META),
267
+ };
268
+ const verdict = responseVerdict(facts);
269
+ switch (verdict.kind) {
270
+ case 'stay':
271
+ return;
272
+ case 'failed':
273
+ failed(url, method, verdict.reason);
274
+ return;
275
+ case 'load':
276
+ // A principal change the router saw, or one the server may have signalled by handing
277
+ // over the very URL it was asked for (it cannot say which): every tab forgets.
278
+ if (verdict.reason === 'another principal' || verdict.url === url) forget();
279
+ win.location.assign(verdict.url);
280
+ return;
281
+ case 'follow':
282
+ await navigate(verdict.url, {
283
+ history: options.history === 'none' ? 'replace' : 'push',
284
+ hops: (options.hops ?? 0) + 1,
285
+ });
286
+ return;
287
+ case 'hand-over':
288
+ if (answer.body !== null) handOver(win, answer.body, answer.disposition);
289
+ return;
290
+ default:
291
+ break;
292
+ }
293
+ if (next === null) return;
294
+ const landed = `${withoutFragment(url)}${new URL(url).hash}`;
295
+ try {
296
+ await loadStylesheets(doc, missingStylesheets(doc, next));
297
+ if (mine.signal.aborted) return;
298
+ 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
+ });
316
+ const done = swapped;
317
+ if (done === undefined) return;
318
+ if (method === 'POST' && answerMovesTab(facts)) {
319
+ untrusted = true;
320
+ forget();
321
+ }
322
+ await runScripts(done.headScripts, ran, (fresh) => {
323
+ doc.head.append(fresh);
324
+ owned.add(fresh);
325
+ });
326
+ await runScripts([...done.body.querySelectorAll('script')], ran, (fresh, inert) =>
327
+ inert.replaceWith(fresh),
328
+ );
329
+ focusMain(doc);
330
+ live.textContent = doc.title;
331
+ doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
332
+ } catch {
333
+ // A swap that failed part-way leaves a page nobody rendered: a GET is loaded for real; a
334
+ // POST's answer cannot be asked for again, so the visitor is told and shown this page.
335
+ if (method === 'GET') win.location.assign(landed);
336
+ else failed(url, method, 'the answer could not be shown');
337
+ }
338
+ } finally {
339
+ win.clearTimeout(progress);
340
+ if (inflight === mine) {
341
+ inflight = undefined;
342
+ doc.documentElement.removeAttribute(NAVIGATING_ATTRIBUTE);
343
+ }
344
+ }
345
+ };
346
+
347
+ const onClick = (event: MouseEvent): void => {
348
+ const anchor = anchorOf(event.target);
349
+ if (anchor === null) return;
350
+ const verdict = linkVerdict(linkFacts(win, anchor, event));
351
+ if (verdict.kind !== 'soft') return;
352
+ event.preventDefault();
353
+ void navigate(verdict.url);
354
+ };
355
+
356
+ const onSubmit = (event: SubmitEvent): void => {
357
+ const form = event.target;
358
+ // An untrusted tab (`answerMovesTab`) leaves every form to the browser.
359
+ 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: new URL(say('action', win.location.href) || win.location.href, doc.baseURI).href,
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: [...fields].map(
379
+ ([name, value]: [string, unknown]) => [name, fieldText(value)] as const,
380
+ ),
381
+ };
382
+ const verdict = formVerdict(facts);
383
+ if (verdict.kind === 'native') return;
384
+ event.preventDefault();
385
+ if (verdict.kind === 'get') {
386
+ void navigate(verdict.url);
387
+ return;
388
+ }
389
+ const body =
390
+ verdict.encoding === 'multipart'
391
+ ? fields
392
+ : new URLSearchParams(facts.fields.map(([name, value]) => [name, value]));
393
+ void navigate(verdict.url, { method: 'POST', body });
394
+ };
395
+
396
+ let intent: number | undefined;
397
+ const onIntent = (event: Event): void => {
398
+ const anchor = anchorOf(event.target);
399
+ if (anchor === null) return;
400
+ const verdict = linkVerdict(linkFacts(win, anchor));
401
+ const connection = (
402
+ win.navigator as { connection?: { saveData?: boolean; effectiveType?: string } }
403
+ ).connection;
404
+ if (
405
+ verdict.kind !== 'soft' ||
406
+ !mayPrefetch({
407
+ url: verdict.url,
408
+ noPrefetch: anchor.hasAttribute(NAVIGATION_NO_PREFETCH_ATTRIBUTE),
409
+ saveData: connection?.saveData,
410
+ effectiveType: connection?.effectiveType,
411
+ })
412
+ ) {
413
+ return;
414
+ }
415
+ win.clearTimeout(intent);
416
+ const delay = event.type === 'pointerover' ? NAVIGATION_PREFETCH_DELAY_MS : 0;
417
+ intent = win.setTimeout(() => prefetch(verdict.url), delay);
418
+ };
419
+ const onLeave = (): void => win.clearTimeout(intent);
420
+
421
+ /**
422
+ * Back/forward. The router's own entries name the document they show; any other entry (an app's
423
+ * own `pushState`) shows the document of its PATH. Either way, when that is not the document on
424
+ * screen it is fetched; when it is, only the scroll moves — the app keeps its own history.
425
+ */
426
+ const onPop = (event: PopStateEvent): void => {
427
+ // A save still pending belongs to the entry just left.
428
+ win.clearTimeout(settle);
429
+ const url = win.location.href;
430
+ const entry = entryOf(event.state);
431
+ const wanted = entry?.doc ?? withoutFragment(url);
432
+ const same =
433
+ entry !== undefined
434
+ ? wanted === rendered
435
+ : new URL(url).pathname === new URL(rendered).pathname;
436
+ if (!same) {
437
+ void navigate(url, { history: 'none' });
438
+ return;
439
+ }
440
+ if (entry !== undefined) {
441
+ win.scrollTo({
442
+ left: entry.scroll[0],
443
+ top: entry.scroll[1],
444
+ behavior: 'instant' as ScrollBehavior,
445
+ });
446
+ }
447
+ };
448
+
449
+ // `window`, bubble phase: after every handler on the page — Solid delegates to `document` — has
450
+ // had its chance to `preventDefault`, which the rules then honour.
451
+ win.addEventListener('click', onClick);
452
+ win.addEventListener('submit', onSubmit);
453
+ win.addEventListener('scroll', onScroll, { passive: true });
454
+ doc.addEventListener('pointerover', onIntent, { passive: true });
455
+ doc.addEventListener('pointerout', onLeave, { passive: true });
456
+ doc.addEventListener('focusin', onIntent);
457
+ doc.addEventListener('touchstart', onIntent, { passive: true });
458
+ win.addEventListener('popstate', onPop);
459
+
460
+ const router: NavigationRouter = {
461
+ navigate,
462
+ prefetch,
463
+ stop() {
464
+ win.removeEventListener('click', onClick);
465
+ win.removeEventListener('submit', onSubmit);
466
+ win.removeEventListener('scroll', onScroll);
467
+ doc.removeEventListener('pointerover', onIntent);
468
+ doc.removeEventListener('pointerout', onLeave);
469
+ doc.removeEventListener('focusin', onIntent);
470
+ doc.removeEventListener('touchstart', onIntent);
471
+ win.removeEventListener('popstate', onPop);
472
+ offWrite();
473
+ offRescope();
474
+ channel?.close();
475
+ delete win.__xNavigation;
476
+ },
477
+ };
478
+ win.__xNavigation = router;
479
+ return router;
480
+ }
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
- /** The input shape of `defineRoute` — exactly the contract's eleven keys, nothing else. */
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);