proteum 2.5.25 → 2.7.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.
Files changed (44) hide show
  1. package/README.md +4 -0
  2. package/agents/project/AGENTS.md +2 -0
  3. package/agents/project/client/pages/AGENTS.md +22 -1
  4. package/cli/compiler/client/index.ts +10 -5
  5. package/cli/compiler/client/outputNames.ts +28 -0
  6. package/cli/compiler/common/files/autres.ts +3 -1
  7. package/cli/scaffold/templates.ts +4 -0
  8. package/client/app/staleBuild.ts +80 -0
  9. package/client/services/router/components/Link.tsx +25 -0
  10. package/client/services/router/components/Page.tsx +19 -3
  11. package/client/services/router/components/router.tsx +165 -64
  12. package/client/services/router/index.tsx +132 -31
  13. package/client/services/router/navigation.ts +235 -0
  14. package/client/services/router/request/api.ts +123 -75
  15. package/client/services/router/response/index.tsx +5 -0
  16. package/client/services/router/response/page.ts +20 -4
  17. package/common/router/contracts.ts +2 -2
  18. package/common/router/definitions.ts +28 -10
  19. package/common/router/index.ts +2 -0
  20. package/common/router/pageData.ts +20 -2
  21. package/common/router/response/page.ts +73 -7
  22. package/docs/HISTORY.md +8 -0
  23. package/docs/client-assets.md +37 -0
  24. package/docs/client-navigation.md +128 -0
  25. package/docs/http-security-headers.md +114 -0
  26. package/package.json +2 -1
  27. package/server/services/router/http/cache.ts +4 -1
  28. package/server/services/router/http/index.ts +26 -25
  29. package/server/services/router/http/securityHeaders.ts +343 -0
  30. package/server/services/router/response/page/index.tsx +2 -2
  31. package/tests/app-shaped-typecheck.test.cjs +74 -0
  32. package/tests/client-api-fetch-sync.test.cjs +180 -0
  33. package/tests/client-output-names.test.cjs +114 -0
  34. package/tests/client-router-hooks.test.cjs +73 -0
  35. package/tests/client-router-navigation.test.cjs +857 -0
  36. package/tests/client-router-prefetch.test.cjs +197 -0
  37. package/tests/clientRouterHarness.cjs +70 -0
  38. package/tests/definition-contracts.test.cjs +45 -1
  39. package/tests/fakeDom.cjs +90 -0
  40. package/tests/http-server-security-headers.test.cjs +669 -0
  41. package/tests/router-cache-config.test.cjs +32 -0
  42. package/tests/stale-build-recovery.test.cjs +206 -0
  43. package/types/global/vendors.d.ts +9 -0
  44. package/types/vendors.d.ts +9 -0
@@ -31,17 +31,19 @@ import type { TRegisterPageArgs, TSsrUnresolvedRoute } from '@common/router/cont
31
31
  import { getLayout } from '@common/router/layouts';
32
32
  import { getRegisterPageArgs, buildRegex } from '@common/router/register';
33
33
  import { TFetcherList } from '@common/router/request/api';
34
- import type { TFrontRenderer, TPageDataProvider } from '@common/router/response/page';
34
+ import type { TFrontRenderer, TPageDataProvider, TPageRenderer } from '@common/router/response/page';
35
35
 
36
36
  import App from '@client/app/component';
37
37
  import type ClientApplication from '@client/app';
38
38
  import Service from '@client/app/service';
39
+ import { clearStaleBuildReloads, isChunkLoadError, reloadOnceForStaleBuild } from '@client/app/staleBuild';
39
40
 
40
41
  // Specific
41
42
  import ClientRequest, { isClientRequest } from './request';
42
43
  import { location, history } from './request/history';
43
44
  import ClientResponse, { type TRouterContext } from './response';
44
45
  import ClientPage from './response/page';
46
+ import type { TNavigationMode } from './navigation';
45
47
 
46
48
  type AppPropsContext = Parameters<typeof App>[0]['context'];
47
49
 
@@ -55,6 +57,13 @@ import appRoutes from '@generated/client/routes';
55
57
  const debug = false;
56
58
  const LogPrefix = '[router]';
57
59
  const browserWindow = window as Window & { routes?: TSsrUnresolvedRoute[]; ssr?: TBasicSSrData };
60
+
61
+ // Where requestIdleCallback is missing (Safari, iOS), the prefetch waits this long after hydration instead, so the
62
+ // page's own requests, images and fonts go first rather than ten chunk downloads
63
+ const PREFETCH_IDLE_FALLBACK_MS = 2000;
64
+
65
+ // The Network Information API, Chromium only and missing from the DOM typings
66
+ type TNavigatorConnection = { connection?: { saveData?: boolean } };
58
67
  const withProfiler = <T,>(callback: (runtime: (typeof import('@client/dev/profiler/runtime'))['profilerRuntime']) => T) => {
59
68
  if (!__DEV__) return undefined as T | undefined;
60
69
  const profilerModule = require('@client/dev/profiler/runtime') as typeof import('@client/dev/profiler/runtime');
@@ -115,6 +124,8 @@ type TRouteLoader<
115
124
  TRouteModule<Route>
116
125
  >;
117
126
 
127
+ type TLoadedRoute<TRouter extends TAnyClientRouter> = TClientPageRoute<TRouter> | TClientPageErrorRoute<TRouter>;
128
+
118
129
  export type TRoutesLoaders = { [chunkId: string]: TRouteLoader<TClientPageRoute | TClientPageErrorRoute> };
119
130
 
120
131
  /*----------------------------------
@@ -123,10 +134,16 @@ export type TRoutesLoaders = { [chunkId: string]: TRouteLoader<TClientPageRoute
123
134
 
124
135
  export type THookCallback<TRouter extends TAnyClientRouter> = (request: ClientRequest<TRouter>) => void;
125
136
 
126
- type THookName = 'page.change' | 'page.changed' | 'page.rendered';
137
+ // page.ready: the page shows its data. After hydration on the first load, then after every navigation
138
+ // (with the swap on a blocking page, when the data arrives on a deferred one).
139
+ type THookName = 'page.change' | 'page.changed' | 'page.rendered' | 'page.ready';
127
140
 
128
141
  type Config = {
129
142
  preload: string[]; // List of globs
143
+ // Route paths whose chunks load at idle after the first render
144
+ prefetch?: string[];
145
+ // 'deferred' lets pages declaring navigation: 'deferred' swap in before their data. Default 'blocking'.
146
+ navigation?: { mode: TNavigationMode };
130
147
  context: (context: {}, router: TAnyClientRouter) => any;
131
148
  };
132
149
 
@@ -193,6 +210,11 @@ export default class ClientRouter<
193
210
  [code: number]: TClientPageErrorRoute<ClientRouter<TApplication, TConfig>> | TUnresolvedErrorRoute;
194
211
  } = {};
195
212
 
213
+ // One load per chunk: a navigation joins a prefetch already in flight
214
+ private routeLoads: {
215
+ [chunk: string]: Promise<TLoadedRoute<ClientRouter<TApplication, TConfig>>> | undefined;
216
+ } = {};
217
+
196
218
  public async registerRoutes() {
197
219
  const loaders = appRoutes as unknown as TRoutesLoaders;
198
220
  let currentRoute: TUnresolvedRoute | undefined;
@@ -265,7 +287,7 @@ export default class ClientRouter<
265
287
  path: string,
266
288
  options: Partial<TRouteOptions>,
267
289
  data: TPageDataProvider<TProvidedData> | null,
268
- renderer: TFrontRenderer<TProvidedData>,
290
+ renderer: TPageRenderer<TProvidedData>,
269
291
  ): TClientPageRoute<this>;
270
292
 
271
293
  protected page(...args: TRegisterPageArgs<any, TRouteOptions>): TClientPageRoute<this> {
@@ -316,7 +338,8 @@ export default class ClientRouter<
316
338
  /*----------------------------------
317
339
  - RESOLUTION
318
340
  ----------------------------------*/
319
- public async resolve(request: ClientRequest<this>): Promise<ClientPage<this>> {
341
+ // `isCurrent`: the caller's navigation is still the latest. Null when it is not anymore once the chunk loaded.
342
+ public async resolve(request: ClientRequest<this>, isCurrent?: () => boolean): Promise<ClientPage<this> | null> {
320
343
  debug && console.log(LogPrefix, 'Resolving request', request.path, Object.keys(request.data));
321
344
 
322
345
  for (let iRoute = 0; iRoute < this.routes.length; iRoute++) {
@@ -335,14 +358,14 @@ export default class ClientRouter<
335
358
  routeLabel: request.path,
336
359
  }),
337
360
  );
338
- const page = await this.createResponse(route, request);
361
+ const page = await this.createResponse(route, request, {}, isCurrent);
339
362
 
340
363
  return page;
341
364
  }
342
365
 
343
366
  const notFoundRoute = this.errors[404];
344
367
  withProfiler((runtime) => runtime.completeResolveStep({ routeLabel: '404' }));
345
- return await this.createResponse(notFoundRoute, request, { error: new Error('Page not found') });
368
+ return await this.createResponse(notFoundRoute, request, { error: new Error('Page not found') }, isCurrent);
346
369
  }
347
370
 
348
371
  private async load(route: TUnresolvedNormalRoute): Promise<TClientPageRoute<this>>;
@@ -355,20 +378,26 @@ export default class ClientRouter<
355
378
  debug && console.log(`Fetching route ${route.chunk} ...`, route);
356
379
  const stepId = withProfiler((runtime) => runtime.startChunkStep(route.chunk));
357
380
  try {
358
- const loaded = await route.load();
359
- const fetched = loaded.__register(this.app);
381
+ const fetched = await this.loadRoute(route);
360
382
 
361
383
  debug && console.log(`Route fetched: ${route.chunk}`, fetched);
362
384
  withProfiler((runtime) => runtime.finishStep(stepId));
385
+ // The running build is whole: a later missing chunk (the next deploy) gets its reload again
386
+ clearStaleBuildReloads();
363
387
 
364
- if ('code' in route) return fetched as TClientPageErrorRoute<this>;
365
-
366
- return { ...(fetched as TClientPageRoute<this>), regex: route.regex, keys: route.keys };
388
+ return fetched;
367
389
  } catch (e) {
368
390
  withProfiler((runtime) =>
369
391
  runtime.finishStep(stepId, 'error', e instanceof Error ? e.message : String(e)),
370
392
  );
371
393
  console.error(`Failed to fetch the route ${route.chunk}`, e);
394
+
395
+ // A missing chunk means this page runs a build that a deploy replaced: one reload loads
396
+ // the new one. Settling would render the error page for a frame before the reload, so the
397
+ // navigation waits for the document to be replaced. See client/app/staleBuild.ts.
398
+ if (e instanceof Error && isChunkLoadError(e) && reloadOnceForStaleBuild(String(BUILD_ID)))
399
+ return new Promise<never>(() => {});
400
+
372
401
  try {
373
402
  this.app.handleUpdate();
374
403
  } catch (error) {}
@@ -376,6 +405,67 @@ export default class ClientRouter<
376
405
  }
377
406
  }
378
407
 
408
+ private loadRoute(route: TUnresolvedNormalRoute | TUnresolvedErrorRoute): Promise<TLoadedRoute<this>> {
409
+ const pending = this.routeLoads[route.chunk];
410
+ if (pending) return pending;
411
+
412
+ const load = route
413
+ .load()
414
+ .then((loaded) => {
415
+ const fetched = loaded.__register(this.app);
416
+ if ('code' in route) return fetched as TClientPageErrorRoute<this>;
417
+
418
+ return { ...(fetched as TClientPageRoute<this>), regex: route.regex, keys: route.keys };
419
+ })
420
+ .finally(() => {
421
+ delete this.routeLoads[route.chunk];
422
+ });
423
+
424
+ this.routeLoads[route.chunk] = load;
425
+ return load;
426
+ }
427
+
428
+ /*----------------------------------
429
+ - PREFETCH
430
+ ----------------------------------*/
431
+
432
+ // Loads the route chunk of a path ahead of the navigation. Best effort: a failure is left to the navigation.
433
+ public async prefetch(path: string) {
434
+ const pathname = path.split(/[?#]/)[0];
435
+ const route = this.routes.find(
436
+ (candidate) =>
437
+ candidate !== undefined &&
438
+ 'regex' in candidate &&
439
+ candidate.regex instanceof RegExp &&
440
+ candidate.regex.test(pathname),
441
+ );
442
+ if (route === undefined || !('load' in route)) return;
443
+
444
+ try {
445
+ const loaded = await this.loadRoute(route);
446
+ if ('load' in this.routes[route.index]) this.routes[route.index] = loaded as TClientPageRoute<this>;
447
+ } catch (error) {
448
+ debug && console.warn(LogPrefix, `Unable to prefetch ${path}`, error);
449
+ }
450
+ }
451
+
452
+ private prefetchAtIdle() {
453
+ const paths = this.config.prefetch || [];
454
+ if (paths.length === 0) return;
455
+
456
+ // Data saver: the visitor asked for fewer bytes, and a prefetched chunk is bytes they may never use
457
+ const { connection } = window.navigator as Navigator & TNavigatorConnection;
458
+ if (connection?.saveData === true) return;
459
+
460
+ const whenIdle =
461
+ window.requestIdleCallback ||
462
+ ((callback: () => void) => window.setTimeout(callback, PREFETCH_IDLE_FALLBACK_MS));
463
+ whenIdle(() => {
464
+ // One chunk at a time, so prefetching never competes with itself
465
+ void paths.reduce<Promise<void>>((chain, path) => chain.then(() => this.prefetch(path)), Promise.resolve());
466
+ });
467
+ }
468
+
379
469
  public set(data: TObjetDonnees) {
380
470
  throw new Error(`router.set was not attached to the router component.`);
381
471
  }
@@ -415,14 +505,27 @@ export default class ClientRouter<
415
505
  withProfiler((runtime) => runtime.markInitialHydrated({ chunkId: response.chunkId, title: response.title }));
416
506
 
417
507
  this.runHook('page.rendered', request);
508
+ this.prefetchAtIdle();
418
509
  });
419
510
  }
420
511
 
512
+ private createResponse(
513
+ route: TUnresolvedRoute | TClientPageErrorRoute<this> | TClientPageRoute<this>,
514
+ request: ClientRequest<this>,
515
+ pageData?: {},
516
+ ): Promise<ClientPage<this>>;
517
+ private createResponse(
518
+ route: TUnresolvedRoute | TClientPageErrorRoute<this> | TClientPageRoute<this>,
519
+ request: ClientRequest<this>,
520
+ pageData: {},
521
+ isCurrent: (() => boolean) | undefined,
522
+ ): Promise<ClientPage<this> | null>;
421
523
  private async createResponse(
422
524
  route: TUnresolvedRoute | TClientPageErrorRoute<this> | TClientPageRoute<this>,
423
525
  request: ClientRequest<this>,
424
526
  pageData: {} = {},
425
- ): Promise<ClientPage<this>> {
527
+ isCurrent?: () => boolean,
528
+ ): Promise<ClientPage<this> | null> {
426
529
  // Load the route if not done before
427
530
  if ('load' in route) {
428
531
  if ('code' in route) {
@@ -434,6 +537,9 @@ export default class ClientRouter<
434
537
  this.routes[route.index] = loadedRoute;
435
538
  route = loadedRoute;
436
539
  }
540
+
541
+ // A newer navigation started during the load: building the response would overwrite its context
542
+ if (isCurrent && !isCurrent()) return null;
437
543
  }
438
544
 
439
545
  // Run controller
@@ -442,7 +548,7 @@ export default class ClientRouter<
442
548
  const response = new ClientResponse<this, ClientPage<this>>(request, route);
443
549
  return await response.runController(pageData);
444
550
  } catch (error) {
445
- return await this.createErrorResponse(error, request);
551
+ return await this.createErrorResponse(error, request, {}, isCurrent);
446
552
  }
447
553
  }
448
554
 
@@ -450,7 +556,8 @@ export default class ClientRouter<
450
556
  e: any,
451
557
  request: ClientRequest<this>,
452
558
  pageData: {} = {},
453
- ): Promise<ClientPage<this>> {
559
+ isCurrent?: () => boolean,
560
+ ): Promise<ClientPage<this> | null> {
454
561
  const code = 'http' in e ? e.http : 500;
455
562
  console.log(`Loading error page ` + code);
456
563
  let route = this.errors[code];
@@ -464,7 +571,11 @@ export default class ClientRouter<
464
571
  }
465
572
 
466
573
  // Load if not done before
467
- if ('load' in route) route = this.errors[code] = await this.load(route);
574
+ if ('load' in route) {
575
+ route = this.errors[code] = await this.load(route);
576
+ // Same guard as createResponse: a newer navigation owns the router context now
577
+ if (isCurrent && !isCurrent()) return null;
578
+ }
468
579
 
469
580
  const response = new ClientResponse<this, ClientPage<this>>(request, route);
470
581
  return await response.runController(pageData);
@@ -473,32 +584,22 @@ export default class ClientRouter<
473
584
  /*----------------------------------
474
585
  - HOOKS
475
586
  ----------------------------------*/
476
- private hooks: { [hookname in THookName]?: (THookCallback<this> | null)[] } = {};
587
+ private hooks: { [hookname in THookName]?: THookCallback<this>[] } = {};
477
588
 
478
589
  public on(hookName: THookName, callback: THookCallback<this>) {
479
590
  debug && console.info(LogPrefix, `Register hook ${hookName}`);
480
591
 
481
- let cbIndex: number;
482
- let callbacks = this.hooks[hookName];
483
- if (!callbacks) {
484
- cbIndex = 0;
485
- callbacks = this.hooks[hookName] = [callback];
486
- } else {
487
- cbIndex = callbacks.length;
488
- callbacks.push(callback);
489
- }
592
+ this.hooks[hookName] = [...(this.hooks[hookName] || []), callback];
490
593
 
491
- // Listener remover
594
+ // Listener remover: by reference, since an index shifts once an earlier listener is removed
492
595
  return () => {
493
- debug && console.info(LogPrefix, `De-register hook ${hookName} (index ${cbIndex})`);
494
- this.hooks[hookName] = this.hooks[hookName]?.filter((_, index) => index !== cbIndex);
596
+ debug && console.info(LogPrefix, `De-register hook ${hookName}`);
597
+ this.hooks[hookName] = this.hooks[hookName]?.filter((registered) => registered !== callback);
495
598
  };
496
599
  }
497
600
 
498
601
  public runHook(hookName: THookName, request: ClientRequest<this>) {
499
602
  const callbacks = this.hooks[hookName];
500
- if (callbacks)
501
- // callback can be null since we use delete to unregister
502
- for (const callback of callbacks) callback && callback(request);
603
+ if (callbacks) for (const callback of callbacks) callback(request);
503
604
  }
504
605
  }
@@ -0,0 +1,235 @@
1
+ /*----------------------------------
2
+ - DEPENDANCES
3
+ ----------------------------------*/
4
+
5
+ // Core
6
+ import {
7
+ createPageNavigation,
8
+ type TPageNavigation,
9
+ type TPageNavigationState,
10
+ } from '@common/router/response/page';
11
+
12
+ /*----------------------------------
13
+ - TYPES
14
+ ----------------------------------*/
15
+
16
+ export type TNavigationMode = 'blocking' | 'deferred';
17
+
18
+ // What the sequencer reads and writes on a page. ClientPage satisfies it.
19
+ export type TNavigationPage = {
20
+ chunkId?: string;
21
+ data: TObjetDonnees;
22
+ navigation: TPageNavigation;
23
+ };
24
+
25
+ export type TNavigationPorts<TRequest, TPage extends TNavigationPage> = {
26
+ // Loads the route chunk and builds the page without its data. Null when isCurrent() turned false during the load.
27
+ resolve: (request: TRequest, isCurrent: () => boolean) => Promise<TPage | null>;
28
+ // True when the page swaps in before its data
29
+ defers: (page: TPage) => boolean;
30
+ // Runs the data providers without fetching, so a throwing provider aborts before the swap
31
+ prepare: (page: TPage) => void;
32
+ // Fetches page.data. `prepared` reuses the fetchers built by prepare().
33
+ fetch: (page: TPage, prepared: boolean) => Promise<unknown>;
34
+ // Swaps the rendered page
35
+ commit: (page: TPage) => void;
36
+ // Re-renders a committed page with page.navigation, and with page.data when given:
37
+ // 'replace' for the first data of a navigation, 'merge' for a retry (keeps values written with api.set)
38
+ render: (page: TPage, data?: 'replace' | 'merge') => void;
39
+ // A deferred navigation is ready for the first time
40
+ ready: (page: TPage) => void;
41
+ setLoading: (loading: boolean) => void;
42
+ // Logs a failed step. 'retry' is a data step started by navigation.retry().
43
+ fail: (step: 'route' | 'data' | 'retry', error: unknown, sessionId?: string) => void;
44
+ // Profiler render step
45
+ startRender: (sessionId?: string) => void;
46
+ };
47
+
48
+ export type TNavigationSequencer<TRequest, TPage extends TNavigationPage> = {
49
+ // A client navigation, from the loader to the swap
50
+ navigate: (request: TRequest, data?: TObjetDonnees, sessionId?: string) => Promise<void>;
51
+ // Shows a page that is already resolved (error pages)
52
+ show: (page: TPage, data?: TObjetDonnees, sessionId?: string) => Promise<void>;
53
+ // Re-runs the data step of the current page when it defers
54
+ retry: () => Promise<void>;
55
+ current: () => TPage | undefined;
56
+ };
57
+
58
+ type TEntry<TPage> = { page: TPage; token: number; dataToken: number; ready: boolean };
59
+
60
+ /*----------------------------------
61
+ - HELPERS
62
+ ----------------------------------*/
63
+
64
+ // A page defers only when the router mode is deferred, the page declares it, and it has a data loader
65
+ export const shouldDeferNavigation = (
66
+ mode: TNavigationMode | undefined,
67
+ route: { options: { navigation?: TNavigationMode }; data?: unknown },
68
+ ) => mode === 'deferred' && route.options.navigation === 'deferred' && typeof route.data === 'function';
69
+
70
+ const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
71
+
72
+ /*----------------------------------
73
+ - SEQUENCER
74
+ ----------------------------------*/
75
+
76
+ /**
77
+ * Orders the steps of a client navigation.
78
+ * Blocking: loader, route, data, swap. Deferred: loader, route, providers, swap with a pending state, data.
79
+ * Every navigation takes a new token and gives up after an await that returns to an older one;
80
+ * a data step also gives up once its page left the screen or a newer data step started for it.
81
+ */
82
+ export const createNavigationSequencer = <TRequest, TPage extends TNavigationPage>(
83
+ ports: TNavigationPorts<TRequest, TPage>,
84
+ initialPage?: TPage,
85
+ ): TNavigationSequencer<TRequest, TPage> => {
86
+ let token = 0;
87
+ let current: TEntry<TPage> | undefined;
88
+
89
+ const setNavigation = (entry: TEntry<TPage>, state: TPageNavigationState) => {
90
+ entry.page.navigation = createPageNavigation(state, () => {
91
+ if (current === entry) void retry();
92
+ });
93
+ };
94
+
95
+ const runData = async (entry: TEntry<TPage>, retrying: boolean, data?: TObjetDonnees, sessionId?: string) => {
96
+ const dataToken = ++entry.dataToken;
97
+ const isCurrent = () => current === entry && entry.dataToken === dataToken;
98
+ // The loader belongs to the latest navigation
99
+ const releaseLoading = () => token === entry.token && ports.setLoading(false);
100
+
101
+ try {
102
+ await ports.fetch(entry.page, !retrying);
103
+ } catch (error) {
104
+ if (!isCurrent()) return;
105
+ const { stale, reloading } = entry.page.navigation;
106
+ setNavigation(entry, { status: 'error', stale, reloading, error: toError(error) });
107
+ ports.render(entry.page);
108
+ ports.fail(retrying ? 'retry' : 'data', error, sessionId);
109
+ releaseLoading();
110
+ return;
111
+ }
112
+ if (!isCurrent()) return;
113
+
114
+ // Add additional data
115
+ if (data) entry.page.data = { ...entry.page.data, ...data };
116
+ setNavigation(entry, { status: 'ready' });
117
+ // Merge only into data this navigation already showed (api.set values survive a reload). Until then the
118
+ // Page component may still hold the previous page's data (a same-chunk navigation keeps it), so the
119
+ // first data render replaces it, including a retry that recovers a failed first data step.
120
+ ports.render(entry.page, entry.ready ? 'merge' : 'replace');
121
+ releaseLoading();
122
+
123
+ if (!entry.ready) {
124
+ entry.ready = true;
125
+ ports.ready(entry.page);
126
+ }
127
+ };
128
+
129
+ const showPage = async (
130
+ page: TPage,
131
+ data: TObjetDonnees | undefined,
132
+ sessionId: string | undefined,
133
+ navigationToken: number,
134
+ ) => {
135
+ const isCurrent = () => token === navigationToken;
136
+
137
+ // Blocking: the data, then the swap
138
+ if (!ports.defers(page)) {
139
+ try {
140
+ await ports.fetch(page, false);
141
+ } catch (error) {
142
+ if (!isCurrent()) return;
143
+ ports.fail('data', error, sessionId);
144
+ ports.setLoading(false);
145
+ return;
146
+ }
147
+ if (!isCurrent()) return;
148
+
149
+ // Add additional data
150
+ if (data) page.data = { ...page.data, ...data };
151
+ ports.startRender(sessionId);
152
+ current = { page, token: navigationToken, dataToken: 0, ready: true };
153
+ ports.commit(page);
154
+ return;
155
+ }
156
+
157
+ // Deferred: a throwing provider aborts like a blocking data failure
158
+ try {
159
+ ports.prepare(page);
160
+ } catch (error) {
161
+ if (!isCurrent()) return;
162
+ ports.fail('data', error, sessionId);
163
+ ports.setLoading(false);
164
+ return;
165
+ }
166
+ // A provider can start a navigation itself (a redirect): the page is superseded before its swap
167
+ if (!isCurrent()) return;
168
+
169
+ // Same chunk keeps the rendered tree, so the previous data stays on screen while it has some
170
+ const previous = current?.page.navigation;
171
+ const stale =
172
+ current !== undefined &&
173
+ current.page.chunkId === page.chunkId &&
174
+ (previous?.status === 'ready' || previous?.stale === true);
175
+ const entry: TEntry<TPage> = { page, token: navigationToken, dataToken: 0, ready: false };
176
+ setNavigation(entry, { status: 'pending', stale });
177
+ ports.startRender(sessionId);
178
+ current = entry;
179
+ ports.commit(page);
180
+
181
+ await runData(entry, false, data, sessionId);
182
+ };
183
+
184
+ const navigate = async (request: TRequest, data: TObjetDonnees = {}, sessionId?: string) => {
185
+ const navigationToken = ++token;
186
+ const isCurrent = () => token === navigationToken;
187
+
188
+ ports.setLoading(true);
189
+
190
+ let page: TPage | null;
191
+ try {
192
+ page = await ports.resolve(request, isCurrent);
193
+ } catch (error) {
194
+ // Chunk load failure
195
+ if (!isCurrent()) return;
196
+ ports.fail('route', error, sessionId);
197
+ ports.setLoading(false);
198
+ return;
199
+ }
200
+
201
+ // Unable to load, or superseded during the load
202
+ if (page === null || !isCurrent()) return;
203
+
204
+ await showPage(page, data, sessionId, navigationToken);
205
+ };
206
+
207
+ const retry = async () => {
208
+ const entry = current;
209
+ if (!entry || !ports.defers(entry.page)) return;
210
+
211
+ const { navigation } = entry.page;
212
+ setNavigation(entry, {
213
+ status: 'pending',
214
+ stale: navigation.status === 'ready' || navigation.stale,
215
+ // A reload of data this navigation showed, not a first load that failed
216
+ reloading: entry.ready,
217
+ });
218
+ ports.render(entry.page);
219
+
220
+ await runData(entry, true);
221
+ };
222
+
223
+ // The SSR page is ready: bind its retry
224
+ if (initialPage) {
225
+ current = { page: initialPage, token, dataToken: 0, ready: true };
226
+ setNavigation(current, { status: 'ready' });
227
+ }
228
+
229
+ return {
230
+ navigate,
231
+ show: (page, data, sessionId) => showPage(page, data, sessionId, ++token),
232
+ retry,
233
+ current: () => current?.page,
234
+ };
235
+ };