proteum 2.5.24 → 2.6.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 +1 -0
- package/agents/project/AGENTS.md +2 -0
- package/agents/project/client/pages/AGENTS.md +22 -1
- 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 +122 -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/client-navigation.md +128 -0
- package/package.json +1 -1
- package/server/services/router/http/index.ts +24 -1
- package/server/services/router/response/page/index.tsx +2 -2
- package/tests/client-api-fetch-sync.test.cjs +180 -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-cleanup.test.cjs +115 -0
|
@@ -31,7 +31,7 @@ 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';
|
|
@@ -42,6 +42,7 @@ import ClientRequest, { isClientRequest } from './request';
|
|
|
42
42
|
import { location, history } from './request/history';
|
|
43
43
|
import ClientResponse, { type TRouterContext } from './response';
|
|
44
44
|
import ClientPage from './response/page';
|
|
45
|
+
import type { TNavigationMode } from './navigation';
|
|
45
46
|
|
|
46
47
|
type AppPropsContext = Parameters<typeof App>[0]['context'];
|
|
47
48
|
|
|
@@ -55,6 +56,13 @@ import appRoutes from '@generated/client/routes';
|
|
|
55
56
|
const debug = false;
|
|
56
57
|
const LogPrefix = '[router]';
|
|
57
58
|
const browserWindow = window as Window & { routes?: TSsrUnresolvedRoute[]; ssr?: TBasicSSrData };
|
|
59
|
+
|
|
60
|
+
// Where requestIdleCallback is missing (Safari, iOS), the prefetch waits this long after hydration instead, so the
|
|
61
|
+
// page's own requests, images and fonts go first rather than ten chunk downloads
|
|
62
|
+
const PREFETCH_IDLE_FALLBACK_MS = 2000;
|
|
63
|
+
|
|
64
|
+
// The Network Information API, Chromium only and missing from the DOM typings
|
|
65
|
+
type TNavigatorConnection = { connection?: { saveData?: boolean } };
|
|
58
66
|
const withProfiler = <T,>(callback: (runtime: (typeof import('@client/dev/profiler/runtime'))['profilerRuntime']) => T) => {
|
|
59
67
|
if (!__DEV__) return undefined as T | undefined;
|
|
60
68
|
const profilerModule = require('@client/dev/profiler/runtime') as typeof import('@client/dev/profiler/runtime');
|
|
@@ -115,6 +123,8 @@ type TRouteLoader<
|
|
|
115
123
|
TRouteModule<Route>
|
|
116
124
|
>;
|
|
117
125
|
|
|
126
|
+
type TLoadedRoute<TRouter extends TAnyClientRouter> = TClientPageRoute<TRouter> | TClientPageErrorRoute<TRouter>;
|
|
127
|
+
|
|
118
128
|
export type TRoutesLoaders = { [chunkId: string]: TRouteLoader<TClientPageRoute | TClientPageErrorRoute> };
|
|
119
129
|
|
|
120
130
|
/*----------------------------------
|
|
@@ -123,10 +133,16 @@ export type TRoutesLoaders = { [chunkId: string]: TRouteLoader<TClientPageRoute
|
|
|
123
133
|
|
|
124
134
|
export type THookCallback<TRouter extends TAnyClientRouter> = (request: ClientRequest<TRouter>) => void;
|
|
125
135
|
|
|
126
|
-
|
|
136
|
+
// page.ready: the page shows its data. After hydration on the first load, then after every navigation
|
|
137
|
+
// (with the swap on a blocking page, when the data arrives on a deferred one).
|
|
138
|
+
type THookName = 'page.change' | 'page.changed' | 'page.rendered' | 'page.ready';
|
|
127
139
|
|
|
128
140
|
type Config = {
|
|
129
141
|
preload: string[]; // List of globs
|
|
142
|
+
// Route paths whose chunks load at idle after the first render
|
|
143
|
+
prefetch?: string[];
|
|
144
|
+
// 'deferred' lets pages declaring navigation: 'deferred' swap in before their data. Default 'blocking'.
|
|
145
|
+
navigation?: { mode: TNavigationMode };
|
|
130
146
|
context: (context: {}, router: TAnyClientRouter) => any;
|
|
131
147
|
};
|
|
132
148
|
|
|
@@ -193,6 +209,11 @@ export default class ClientRouter<
|
|
|
193
209
|
[code: number]: TClientPageErrorRoute<ClientRouter<TApplication, TConfig>> | TUnresolvedErrorRoute;
|
|
194
210
|
} = {};
|
|
195
211
|
|
|
212
|
+
// One load per chunk: a navigation joins a prefetch already in flight
|
|
213
|
+
private routeLoads: {
|
|
214
|
+
[chunk: string]: Promise<TLoadedRoute<ClientRouter<TApplication, TConfig>>> | undefined;
|
|
215
|
+
} = {};
|
|
216
|
+
|
|
196
217
|
public async registerRoutes() {
|
|
197
218
|
const loaders = appRoutes as unknown as TRoutesLoaders;
|
|
198
219
|
let currentRoute: TUnresolvedRoute | undefined;
|
|
@@ -265,7 +286,7 @@ export default class ClientRouter<
|
|
|
265
286
|
path: string,
|
|
266
287
|
options: Partial<TRouteOptions>,
|
|
267
288
|
data: TPageDataProvider<TProvidedData> | null,
|
|
268
|
-
renderer:
|
|
289
|
+
renderer: TPageRenderer<TProvidedData>,
|
|
269
290
|
): TClientPageRoute<this>;
|
|
270
291
|
|
|
271
292
|
protected page(...args: TRegisterPageArgs<any, TRouteOptions>): TClientPageRoute<this> {
|
|
@@ -316,7 +337,8 @@ export default class ClientRouter<
|
|
|
316
337
|
/*----------------------------------
|
|
317
338
|
- RESOLUTION
|
|
318
339
|
----------------------------------*/
|
|
319
|
-
|
|
340
|
+
// `isCurrent`: the caller's navigation is still the latest. Null when it is not anymore once the chunk loaded.
|
|
341
|
+
public async resolve(request: ClientRequest<this>, isCurrent?: () => boolean): Promise<ClientPage<this> | null> {
|
|
320
342
|
debug && console.log(LogPrefix, 'Resolving request', request.path, Object.keys(request.data));
|
|
321
343
|
|
|
322
344
|
for (let iRoute = 0; iRoute < this.routes.length; iRoute++) {
|
|
@@ -335,14 +357,14 @@ export default class ClientRouter<
|
|
|
335
357
|
routeLabel: request.path,
|
|
336
358
|
}),
|
|
337
359
|
);
|
|
338
|
-
const page = await this.createResponse(route, request);
|
|
360
|
+
const page = await this.createResponse(route, request, {}, isCurrent);
|
|
339
361
|
|
|
340
362
|
return page;
|
|
341
363
|
}
|
|
342
364
|
|
|
343
365
|
const notFoundRoute = this.errors[404];
|
|
344
366
|
withProfiler((runtime) => runtime.completeResolveStep({ routeLabel: '404' }));
|
|
345
|
-
return await this.createResponse(notFoundRoute, request, { error: new Error('Page not found') });
|
|
367
|
+
return await this.createResponse(notFoundRoute, request, { error: new Error('Page not found') }, isCurrent);
|
|
346
368
|
}
|
|
347
369
|
|
|
348
370
|
private async load(route: TUnresolvedNormalRoute): Promise<TClientPageRoute<this>>;
|
|
@@ -355,15 +377,12 @@ export default class ClientRouter<
|
|
|
355
377
|
debug && console.log(`Fetching route ${route.chunk} ...`, route);
|
|
356
378
|
const stepId = withProfiler((runtime) => runtime.startChunkStep(route.chunk));
|
|
357
379
|
try {
|
|
358
|
-
const
|
|
359
|
-
const fetched = loaded.__register(this.app);
|
|
380
|
+
const fetched = await this.loadRoute(route);
|
|
360
381
|
|
|
361
382
|
debug && console.log(`Route fetched: ${route.chunk}`, fetched);
|
|
362
383
|
withProfiler((runtime) => runtime.finishStep(stepId));
|
|
363
384
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
return { ...(fetched as TClientPageRoute<this>), regex: route.regex, keys: route.keys };
|
|
385
|
+
return fetched;
|
|
367
386
|
} catch (e) {
|
|
368
387
|
withProfiler((runtime) =>
|
|
369
388
|
runtime.finishStep(stepId, 'error', e instanceof Error ? e.message : String(e)),
|
|
@@ -376,6 +395,67 @@ export default class ClientRouter<
|
|
|
376
395
|
}
|
|
377
396
|
}
|
|
378
397
|
|
|
398
|
+
private loadRoute(route: TUnresolvedNormalRoute | TUnresolvedErrorRoute): Promise<TLoadedRoute<this>> {
|
|
399
|
+
const pending = this.routeLoads[route.chunk];
|
|
400
|
+
if (pending) return pending;
|
|
401
|
+
|
|
402
|
+
const load = route
|
|
403
|
+
.load()
|
|
404
|
+
.then((loaded) => {
|
|
405
|
+
const fetched = loaded.__register(this.app);
|
|
406
|
+
if ('code' in route) return fetched as TClientPageErrorRoute<this>;
|
|
407
|
+
|
|
408
|
+
return { ...(fetched as TClientPageRoute<this>), regex: route.regex, keys: route.keys };
|
|
409
|
+
})
|
|
410
|
+
.finally(() => {
|
|
411
|
+
delete this.routeLoads[route.chunk];
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
this.routeLoads[route.chunk] = load;
|
|
415
|
+
return load;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/*----------------------------------
|
|
419
|
+
- PREFETCH
|
|
420
|
+
----------------------------------*/
|
|
421
|
+
|
|
422
|
+
// Loads the route chunk of a path ahead of the navigation. Best effort: a failure is left to the navigation.
|
|
423
|
+
public async prefetch(path: string) {
|
|
424
|
+
const pathname = path.split(/[?#]/)[0];
|
|
425
|
+
const route = this.routes.find(
|
|
426
|
+
(candidate) =>
|
|
427
|
+
candidate !== undefined &&
|
|
428
|
+
'regex' in candidate &&
|
|
429
|
+
candidate.regex instanceof RegExp &&
|
|
430
|
+
candidate.regex.test(pathname),
|
|
431
|
+
);
|
|
432
|
+
if (route === undefined || !('load' in route)) return;
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const loaded = await this.loadRoute(route);
|
|
436
|
+
if ('load' in this.routes[route.index]) this.routes[route.index] = loaded as TClientPageRoute<this>;
|
|
437
|
+
} catch (error) {
|
|
438
|
+
debug && console.warn(LogPrefix, `Unable to prefetch ${path}`, error);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private prefetchAtIdle() {
|
|
443
|
+
const paths = this.config.prefetch || [];
|
|
444
|
+
if (paths.length === 0) return;
|
|
445
|
+
|
|
446
|
+
// Data saver: the visitor asked for fewer bytes, and a prefetched chunk is bytes they may never use
|
|
447
|
+
const { connection } = window.navigator as Navigator & TNavigatorConnection;
|
|
448
|
+
if (connection?.saveData === true) return;
|
|
449
|
+
|
|
450
|
+
const whenIdle =
|
|
451
|
+
window.requestIdleCallback ||
|
|
452
|
+
((callback: () => void) => window.setTimeout(callback, PREFETCH_IDLE_FALLBACK_MS));
|
|
453
|
+
whenIdle(() => {
|
|
454
|
+
// One chunk at a time, so prefetching never competes with itself
|
|
455
|
+
void paths.reduce<Promise<void>>((chain, path) => chain.then(() => this.prefetch(path)), Promise.resolve());
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
379
459
|
public set(data: TObjetDonnees) {
|
|
380
460
|
throw new Error(`router.set was not attached to the router component.`);
|
|
381
461
|
}
|
|
@@ -415,14 +495,27 @@ export default class ClientRouter<
|
|
|
415
495
|
withProfiler((runtime) => runtime.markInitialHydrated({ chunkId: response.chunkId, title: response.title }));
|
|
416
496
|
|
|
417
497
|
this.runHook('page.rendered', request);
|
|
498
|
+
this.prefetchAtIdle();
|
|
418
499
|
});
|
|
419
500
|
}
|
|
420
501
|
|
|
502
|
+
private createResponse(
|
|
503
|
+
route: TUnresolvedRoute | TClientPageErrorRoute<this> | TClientPageRoute<this>,
|
|
504
|
+
request: ClientRequest<this>,
|
|
505
|
+
pageData?: {},
|
|
506
|
+
): Promise<ClientPage<this>>;
|
|
507
|
+
private createResponse(
|
|
508
|
+
route: TUnresolvedRoute | TClientPageErrorRoute<this> | TClientPageRoute<this>,
|
|
509
|
+
request: ClientRequest<this>,
|
|
510
|
+
pageData: {},
|
|
511
|
+
isCurrent: (() => boolean) | undefined,
|
|
512
|
+
): Promise<ClientPage<this> | null>;
|
|
421
513
|
private async createResponse(
|
|
422
514
|
route: TUnresolvedRoute | TClientPageErrorRoute<this> | TClientPageRoute<this>,
|
|
423
515
|
request: ClientRequest<this>,
|
|
424
516
|
pageData: {} = {},
|
|
425
|
-
|
|
517
|
+
isCurrent?: () => boolean,
|
|
518
|
+
): Promise<ClientPage<this> | null> {
|
|
426
519
|
// Load the route if not done before
|
|
427
520
|
if ('load' in route) {
|
|
428
521
|
if ('code' in route) {
|
|
@@ -434,6 +527,9 @@ export default class ClientRouter<
|
|
|
434
527
|
this.routes[route.index] = loadedRoute;
|
|
435
528
|
route = loadedRoute;
|
|
436
529
|
}
|
|
530
|
+
|
|
531
|
+
// A newer navigation started during the load: building the response would overwrite its context
|
|
532
|
+
if (isCurrent && !isCurrent()) return null;
|
|
437
533
|
}
|
|
438
534
|
|
|
439
535
|
// Run controller
|
|
@@ -442,7 +538,7 @@ export default class ClientRouter<
|
|
|
442
538
|
const response = new ClientResponse<this, ClientPage<this>>(request, route);
|
|
443
539
|
return await response.runController(pageData);
|
|
444
540
|
} catch (error) {
|
|
445
|
-
return await this.createErrorResponse(error, request);
|
|
541
|
+
return await this.createErrorResponse(error, request, {}, isCurrent);
|
|
446
542
|
}
|
|
447
543
|
}
|
|
448
544
|
|
|
@@ -450,7 +546,8 @@ export default class ClientRouter<
|
|
|
450
546
|
e: any,
|
|
451
547
|
request: ClientRequest<this>,
|
|
452
548
|
pageData: {} = {},
|
|
453
|
-
|
|
549
|
+
isCurrent?: () => boolean,
|
|
550
|
+
): Promise<ClientPage<this> | null> {
|
|
454
551
|
const code = 'http' in e ? e.http : 500;
|
|
455
552
|
console.log(`Loading error page ` + code);
|
|
456
553
|
let route = this.errors[code];
|
|
@@ -464,7 +561,11 @@ export default class ClientRouter<
|
|
|
464
561
|
}
|
|
465
562
|
|
|
466
563
|
// Load if not done before
|
|
467
|
-
if ('load' in route)
|
|
564
|
+
if ('load' in route) {
|
|
565
|
+
route = this.errors[code] = await this.load(route);
|
|
566
|
+
// Same guard as createResponse: a newer navigation owns the router context now
|
|
567
|
+
if (isCurrent && !isCurrent()) return null;
|
|
568
|
+
}
|
|
468
569
|
|
|
469
570
|
const response = new ClientResponse<this, ClientPage<this>>(request, route);
|
|
470
571
|
return await response.runController(pageData);
|
|
@@ -473,32 +574,22 @@ export default class ClientRouter<
|
|
|
473
574
|
/*----------------------------------
|
|
474
575
|
- HOOKS
|
|
475
576
|
----------------------------------*/
|
|
476
|
-
private hooks: { [hookname in THookName]?:
|
|
577
|
+
private hooks: { [hookname in THookName]?: THookCallback<this>[] } = {};
|
|
477
578
|
|
|
478
579
|
public on(hookName: THookName, callback: THookCallback<this>) {
|
|
479
580
|
debug && console.info(LogPrefix, `Register hook ${hookName}`);
|
|
480
581
|
|
|
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
|
-
}
|
|
582
|
+
this.hooks[hookName] = [...(this.hooks[hookName] || []), callback];
|
|
490
583
|
|
|
491
|
-
// Listener remover
|
|
584
|
+
// Listener remover: by reference, since an index shifts once an earlier listener is removed
|
|
492
585
|
return () => {
|
|
493
|
-
debug && console.info(LogPrefix, `De-register hook ${hookName}
|
|
494
|
-
this.hooks[hookName] = this.hooks[hookName]?.filter((
|
|
586
|
+
debug && console.info(LogPrefix, `De-register hook ${hookName}`);
|
|
587
|
+
this.hooks[hookName] = this.hooks[hookName]?.filter((registered) => registered !== callback);
|
|
495
588
|
};
|
|
496
589
|
}
|
|
497
590
|
|
|
498
591
|
public runHook(hookName: THookName, request: ClientRequest<this>) {
|
|
499
592
|
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);
|
|
593
|
+
if (callbacks) for (const callback of callbacks) callback(request);
|
|
503
594
|
}
|
|
504
595
|
}
|
|
@@ -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
|
+
};
|