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.
@@ -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
- type THookName = 'page.change' | 'page.changed' | 'page.rendered';
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: TFrontRenderer<TProvidedData>,
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
- public async resolve(request: ClientRequest<this>): Promise<ClientPage<this>> {
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 loaded = await route.load();
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
- if ('code' in route) return fetched as TClientPageErrorRoute<this>;
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
- ): Promise<ClientPage<this>> {
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
- ): Promise<ClientPage<this>> {
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) route = this.errors[code] = await this.load(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]?: (THookCallback<this> | null)[] } = {};
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
- 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
- }
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} (index ${cbIndex})`);
494
- this.hooks[hookName] = this.hooks[hookName]?.filter((_, index) => index !== cbIndex);
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
+ };