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.
- package/README.md +4 -0
- package/agents/project/AGENTS.md +2 -0
- package/agents/project/client/pages/AGENTS.md +22 -1
- package/cli/compiler/client/index.ts +10 -5
- package/cli/compiler/client/outputNames.ts +28 -0
- package/cli/compiler/common/files/autres.ts +3 -1
- package/cli/scaffold/templates.ts +4 -0
- package/client/app/staleBuild.ts +80 -0
- package/client/services/router/components/Link.tsx +25 -0
- package/client/services/router/components/Page.tsx +19 -3
- package/client/services/router/components/router.tsx +165 -64
- package/client/services/router/index.tsx +132 -31
- package/client/services/router/navigation.ts +235 -0
- package/client/services/router/request/api.ts +123 -75
- package/client/services/router/response/index.tsx +5 -0
- package/client/services/router/response/page.ts +20 -4
- package/common/router/contracts.ts +2 -2
- package/common/router/definitions.ts +28 -10
- package/common/router/index.ts +2 -0
- package/common/router/pageData.ts +20 -2
- package/common/router/response/page.ts +73 -7
- package/docs/HISTORY.md +8 -0
- package/docs/client-assets.md +37 -0
- package/docs/client-navigation.md +128 -0
- package/docs/http-security-headers.md +114 -0
- package/package.json +2 -1
- package/server/services/router/http/cache.ts +4 -1
- package/server/services/router/http/index.ts +26 -25
- package/server/services/router/http/securityHeaders.ts +343 -0
- package/server/services/router/response/page/index.tsx +2 -2
- package/tests/app-shaped-typecheck.test.cjs +74 -0
- package/tests/client-api-fetch-sync.test.cjs +180 -0
- package/tests/client-output-names.test.cjs +114 -0
- package/tests/client-router-hooks.test.cjs +73 -0
- package/tests/client-router-navigation.test.cjs +857 -0
- package/tests/client-router-prefetch.test.cjs +197 -0
- package/tests/clientRouterHarness.cjs +70 -0
- package/tests/definition-contracts.test.cjs +45 -1
- package/tests/fakeDom.cjs +90 -0
- package/tests/http-server-security-headers.test.cjs +669 -0
- package/tests/router-cache-config.test.cjs +32 -0
- package/tests/stale-build-recovery.test.cjs +206 -0
- package/types/global/vendors.d.ts +9 -0
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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]?:
|
|
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
|
-
|
|
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}
|
|
494
|
-
this.hooks[hookName] = this.hooks[hookName]?.filter((
|
|
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
|
+
};
|