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.
@@ -2,6 +2,9 @@
2
2
  - DEPENDANCES
3
3
  ----------------------------------*/
4
4
 
5
+ // Npm
6
+ import safeStringify from 'fast-safe-stringify';
7
+
5
8
  // Core
6
9
  import type ClientApplication from '@client/app';
7
10
  import { buildConnectedProjectProxyPath } from '@common/connectedProjects';
@@ -44,6 +47,19 @@ const isFileValue = (value: unknown): value is Blob =>
44
47
  const isFileListValue = (value: unknown): value is FileList =>
45
48
  typeof FileList !== 'undefined' && typeof value === 'object' && value instanceof FileList;
46
49
 
50
+ // Same rule as the server: an api fetcher names the endpoint it calls
51
+ const isApiFetcher = (value: unknown): value is TFetcher =>
52
+ typeof value === 'object' && value !== null && 'method' in value && 'path' in value;
53
+
54
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
55
+ typeof value === 'object' && value !== null && typeof (value as PromiseLike<unknown>).then === 'function';
56
+
57
+ // Locally resolved data goes through JSON like SSR data and /api responses (Date to string, no undefined keys)
58
+ const toPageData = (value: unknown): { value: unknown } | undefined => {
59
+ const json = safeStringify(value);
60
+ return json === undefined ? undefined : { value: JSON.parse(json) };
61
+ };
62
+
47
63
  const containsFileValue = (value: unknown): boolean => {
48
64
  if (isFileValue(value) || isFileListValue(value)) return true;
49
65
  if (value instanceof Date) return false;
@@ -105,6 +121,9 @@ export default class ApiClient implements ApiClientService {
105
121
 
106
122
  debug && console.log('[api] Reload data', ids, params, page.fetchers);
107
123
 
124
+ // A deferred page reloads through its navigation: pending state, then the data step again
125
+ if (page.isDeferred()) return page.navigationRetry();
126
+
108
127
  page.fetchData()
109
128
  .then((data: TObjetDonnees) => {
110
129
  this.set(data);
@@ -199,86 +218,115 @@ export default class ApiClient implements ApiClientService {
199
218
  }
200
219
 
201
220
  public async fetchSync(fetchers: TFetcherList, alreadyLoadedData: {}): Promise<TObjetDonnees> {
202
- // Pick the fetchers where the data is needed
203
- const fetchersToRun: TFetcherList = {};
204
- let fetchersCount: number = 0;
221
+ // Only api fetchers go to the server, in one POST /api batch. Other promises resolve here,
222
+ // and plain values are page data as they are: sending them would only echo them back.
223
+ const batch: TFetcherList = {};
224
+ const local: TObjetDonnees = {};
225
+ const localPromises: Promise<void>[] = [];
226
+ const setLocal = (fetcherId: string, value: unknown) => {
227
+ const data = toPageData(value);
228
+ if (data) local[fetcherId] = data.value;
229
+ };
205
230
  // The fetcher can be undefined
206
- for (const fetcherId in fetchers)
207
- if (!(fetcherId in alreadyLoadedData) && fetchers[fetcherId]) {
208
- fetchersToRun[fetcherId] = fetchers[fetcherId];
209
- fetchersCount++;
231
+ for (const fetcherId in fetchers) {
232
+ const entry = fetchers[fetcherId];
233
+ if (fetcherId in alreadyLoadedData || !entry) continue;
234
+
235
+ if (isApiFetcher(entry)) batch[fetcherId] = entry;
236
+ else if (isThenable(entry))
237
+ localPromises.push(
238
+ Promise.resolve(entry).then(
239
+ (value) => setLocal(fetcherId, value),
240
+ (error: unknown) => {
241
+ // Surfaced like an /api failure
242
+ this.app.handleError(error);
243
+ throw error;
244
+ },
245
+ ),
246
+ );
247
+ else setLocal(fetcherId, entry);
248
+ }
249
+
250
+ // Errors will be catched in the caller
251
+ const [fetchedData] = await Promise.all([
252
+ Object.keys(batch).length === 0 ? ({} as TObjetDonnees) : this.fetchBatch(batch),
253
+ ...localPromises,
254
+ ]);
255
+
256
+ // Keep the order of the data provider keys
257
+ const data: TObjetDonnees = { ...alreadyLoadedData };
258
+ for (const fetcherId in fetchers) {
259
+ if (fetcherId in local) data[fetcherId] = local[fetcherId];
260
+ else if (fetcherId in fetchedData) data[fetcherId] = fetchedData[fetcherId];
261
+ }
262
+
263
+ return data;
264
+ }
265
+
266
+ // Fetch all the api data thanks to one http request
267
+ private async fetchBatch(fetchersToRun: TFetcherList): Promise<TObjetDonnees> {
268
+ const pendingTrace = withProfiler((runtime) =>
269
+ runtime.startTrace('navigation-data', {
270
+ fetcherIds: Object.keys(fetchersToRun),
271
+ label: 'Navigation data',
272
+ method: 'POST',
273
+ path: '/api',
274
+ }),
275
+ );
276
+
277
+ try {
278
+ const result = await this.executeDetailed<TObjetDonnees>(
279
+ 'client-navigation',
280
+ 'POST',
281
+ '/api',
282
+ ({ fetchers: fetchersToRun } as unknown) as TPostData,
283
+ );
284
+ const profilerModule = getProfilerModule();
285
+ const traceRequestId = profilerModule?.readProfilerTraceRequestId(result.response);
286
+
287
+ if (pendingTrace && traceRequestId) {
288
+ await profilerModule?.profilerRuntime.attachTraceByRequestId(
289
+ pendingTrace.sessionId,
290
+ pendingTrace.traceId,
291
+ traceRequestId,
292
+ );
293
+ } else if (pendingTrace) {
294
+ withProfiler((runtime) =>
295
+ runtime.completeTrace(pendingTrace.traceId, {
296
+ durationMs: result.durationMs,
297
+ status: 'completed',
298
+ }),
299
+ );
210
300
  }
211
301
 
212
- // Fetch all the api data thanks to one http request
213
- const fetchedData =
214
- fetchersCount === 0
215
- ? {}
216
- : await (async () => {
217
- const pendingTrace = withProfiler((runtime) =>
218
- runtime.startTrace('navigation-data', {
219
- fetcherIds: Object.keys(fetchersToRun),
220
- label: 'Navigation data',
221
- method: 'POST',
222
- path: '/api',
223
- }),
224
- );
225
-
226
- try {
227
- const result = await this.executeDetailed<TObjetDonnees>(
228
- 'client-navigation',
229
- 'POST',
230
- '/api',
231
- ({ fetchers: fetchersToRun } as unknown) as TPostData,
232
- );
233
- const profilerModule = getProfilerModule();
234
- const traceRequestId = profilerModule?.readProfilerTraceRequestId(result.response);
235
-
236
- if (pendingTrace && traceRequestId) {
237
- await profilerModule?.profilerRuntime.attachTraceByRequestId(
238
- pendingTrace.sessionId,
239
- pendingTrace.traceId,
240
- traceRequestId,
241
- );
242
- } else if (pendingTrace) {
243
- withProfiler((runtime) =>
244
- runtime.completeTrace(pendingTrace.traceId, {
245
- durationMs: result.durationMs,
246
- status: 'completed',
247
- }),
248
- );
249
- }
250
-
251
- const responseData: TObjetDonnees = {};
252
- for (const id in result.data) responseData[id] = result.data[id];
253
- return responseData;
254
- } catch (e) {
255
- const profilerModule = getProfilerModule();
256
- const errorResponse = (e as Error & { response?: Response }).response;
257
- const traceRequestId = errorResponse ? profilerModule?.readProfilerTraceRequestId(errorResponse) : undefined;
258
- if (pendingTrace && traceRequestId) {
259
- await profilerModule?.profilerRuntime.attachTraceByRequestId(
260
- pendingTrace.sessionId,
261
- pendingTrace.traceId,
262
- traceRequestId,
263
- );
264
- }
265
- withProfiler((runtime) =>
266
- runtime.completeTrace(pendingTrace?.traceId, {
267
- errorMessage: e instanceof Error ? e.message : String(e),
268
- status: 'error',
269
- }),
270
- );
271
-
272
- // API Error hook
273
- this.app.handleError(e);
274
-
275
- throw e;
276
- }
277
- })();
302
+ const responseData: TObjetDonnees = {};
303
+ for (const id in result.data) responseData[id] = result.data[id];
304
+ return responseData;
305
+ } catch (e) {
306
+ const profilerModule = getProfilerModule();
307
+ const errorResponse = (e as Error & { response?: Response }).response;
308
+ const traceRequestId = errorResponse
309
+ ? profilerModule?.readProfilerTraceRequestId(errorResponse)
310
+ : undefined;
311
+ if (pendingTrace && traceRequestId) {
312
+ await profilerModule?.profilerRuntime.attachTraceByRequestId(
313
+ pendingTrace.sessionId,
314
+ pendingTrace.traceId,
315
+ traceRequestId,
316
+ );
317
+ }
318
+ withProfiler((runtime) =>
319
+ runtime.completeTrace(pendingTrace?.traceId, {
320
+ errorMessage: e instanceof Error ? e.message : String(e),
321
+ status: 'error',
322
+ }),
323
+ );
278
324
 
279
- // Errors will be catched in the caller
325
+ // API Error hook
326
+ this.app.handleError(e);
280
327
 
281
- return { ...alreadyLoadedData, ...fetchedData };
328
+ throw e;
329
+ }
282
330
  }
283
331
 
284
332
  public configure = (...[method, path, data, options = {}]: TFetcherArgs) => {
@@ -112,7 +112,12 @@ export default class ClientPageResponse<
112
112
  if (existingContext === undefined) {
113
113
  this.request.router.context = newContext;
114
114
  } else {
115
+ // The page on screen stays the context's page until a navigation commits (the router's commit port
116
+ // sets it): this response may belong to a navigation that never does (superseded, failed), and
117
+ // api.set and api.reload read the page from the context.
118
+ const pageOnScreen = existingContext.page;
115
119
  Object.assign(existingContext, newContext);
120
+ existingContext.page = pageOnScreen;
116
121
  }
117
122
 
118
123
  return newContext;
@@ -7,8 +7,10 @@ import type { ComponentChild } from 'preact';
7
7
 
8
8
  // Core
9
9
  import type { Layout, TErrorRoute, TRoute } from '@common/router';
10
- import PageResponse, { TFrontRenderer } from '@common/router/response/page';
10
+ import type { TFetcherList } from '@common/router/request/api';
11
+ import PageResponse, { type TPageNavigation, type TPageRenderer } from '@common/router/response/page';
11
12
  import { isClientRequest } from '../request';
13
+ import { shouldDeferNavigation } from '../navigation';
12
14
 
13
15
  // Specific
14
16
  import type ClientRouter from '..';
@@ -35,7 +37,7 @@ export default class ClientPage<TRouter extends ClientRouter<any, any> = ClientR
35
37
 
36
38
  public constructor(
37
39
  public route: TClientPageRouteLike<TRouter>,
38
- public component: TFrontRenderer,
40
+ public component: TPageRenderer,
39
41
  public context: TRouterContext<TRouter, TRouter['app']>,
40
42
  public layout?: Layout,
41
43
  ) {
@@ -45,16 +47,22 @@ export default class ClientPage<TRouter extends ClientRouter<any, any> = ClientR
45
47
  this.scrollToId = isClientRequest(context.request) ? context.request.hash : undefined;
46
48
  }
47
49
 
48
- public async preRender(data?: TObjetDonnees) {
50
+ // `fetchers`: the result of an earlier prepareFetchers() call, so the data providers do not run twice
51
+ public async preRender(data?: TObjetDonnees, fetchers?: TFetcherList) {
49
52
  // Add the page to the context
50
53
  this.context.page = this;
51
54
 
52
55
  // Data succesfully loaded
53
- this.context.data = this.data = data || (await this.fetchData());
56
+ this.context.data = this.data = data || (await this.fetchData(fetchers));
54
57
 
55
58
  return this;
56
59
  }
57
60
 
61
+ // The page swaps in before its data: deferred router mode, deferred page option and a data loader
62
+ public isDeferred() {
63
+ return shouldDeferNavigation(this.context.Router.config.navigation?.mode, this.route);
64
+ }
65
+
58
66
  /*----------------------------------
59
67
  - ACTIONS
60
68
  ----------------------------------*/
@@ -68,6 +76,14 @@ export default class ClientPage<TRouter extends ClientRouter<any, any> = ClientR
68
76
  public setAllData(callback: (data: { [k: string]: any }) => void) {
69
77
  console.warn(`page.setAllData not yet attached to the page Reatc component.`);
70
78
  }
79
+ // Re-renders the page with this.navigation. Bound by the Page component, like setAllData
80
+ public setNavigation(navigation: TPageNavigation) {}
81
+
82
+ // Re-runs the data step of a deferred page (see createNavigationSequencer)
83
+ public navigationRetry() {
84
+ this.navigation.retry();
85
+ }
86
+
71
87
  public setData(key: string, value: ((value: any) => void) | any) {
72
88
  this.setAllData((old) => ({ ...old, [key]: typeof value === 'function' ? value(old[key]) : value }));
73
89
  }
@@ -3,7 +3,7 @@
3
3
  ----------------------------------*/
4
4
 
5
5
  // Core
6
- import type { TFrontRenderer, TPageDataProvider } from './response/page';
6
+ import type { TPageDataProvider, TPageRenderer } from './response/page';
7
7
  import type { TRouteOptions } from '.';
8
8
 
9
9
  /*----------------------------------
@@ -16,7 +16,7 @@ export type TRegisterPageArgs<TProvidedData extends {} = {}, TPageOptions extend
16
16
  path: string,
17
17
  options: Partial<TPageOptions>,
18
18
  data: TPageDataProvider<TProvidedData> | null,
19
- renderer: TFrontRenderer<TProvidedData>,
19
+ renderer: TPageRenderer<TProvidedData>,
20
20
  ];
21
21
 
22
22
  // Serialized SSR route description exchanged between build output and runtime.
@@ -7,7 +7,7 @@ import type { Request, Response, NextFunction } from 'express';
7
7
 
8
8
  // Core
9
9
  import type { TAnyRouter, TRouterContext, TRouteHttpMethod } from '@server/services/router';
10
- import type { TFrontRenderer, TPageDataProvider } from './response/page';
10
+ import type { TDeferredFrontRenderer, TFrontRenderer, TPageDataProvider, TPageRenderer } from './response/page';
11
11
  import type { TRouteOptions } from '.';
12
12
 
13
13
  /*----------------------------------
@@ -27,7 +27,7 @@ export type TPageRouteDefinition<TProvidedData extends {} = {}> = {
27
27
  path: string;
28
28
  options: Partial<TRouteOptions>;
29
29
  data: TPageDataProvider<TProvidedData> | null;
30
- render: TFrontRenderer<TProvidedData>;
30
+ render: TPageRenderer<TProvidedData>;
31
31
  };
32
32
 
33
33
  export type TErrorRouteDefinition = {
@@ -75,18 +75,36 @@ export type TRouteDefinitionRegistrar = {
75
75
  - HELPERS
76
76
  ----------------------------------*/
77
77
 
78
- export const definePageRoute = <TProvidedData extends {} = {}>({
79
- path,
80
- options,
81
- data,
82
- render,
83
- }: Omit<TPageRouteDefinition<TProvidedData>, 'kind'>): TPageRouteDefinition<TProvidedData> => ({
84
- kind: 'page',
78
+ // A page declaring `options.navigation: 'deferred'` renders with TDeferredFrontRenderer:
79
+ // every data key may be undefined, plus the `navigation` prop
80
+ export function definePageRoute<TProvidedData extends {} = {}>(definition: {
81
+ path: string;
82
+ options: Partial<TRouteOptions> & { navigation: 'deferred' };
83
+ data: TPageDataProvider<TProvidedData> | null;
84
+ render: TDeferredFrontRenderer<TProvidedData>;
85
+ }): TPageRouteDefinition<TProvidedData>;
86
+ // The blocking overload refuses `navigation: 'deferred'`: a deferred page whose render does not fit the deferred
87
+ // renderer (an annotated props type) would otherwise fall through to it and type its data as always defined
88
+ export function definePageRoute<TProvidedData extends {} = {}>(definition: {
89
+ path: string;
90
+ options: Omit<Partial<TRouteOptions>, 'navigation'> & { navigation?: 'blocking' };
91
+ data: TPageDataProvider<TProvidedData> | null;
92
+ render: TFrontRenderer<TProvidedData>;
93
+ }): TPageRouteDefinition<TProvidedData>;
94
+ export function definePageRoute<TProvidedData extends {} = {}>({
85
95
  path,
86
96
  options,
87
97
  data,
88
98
  render,
89
- });
99
+ }: Omit<TPageRouteDefinition<TProvidedData>, 'kind'>): TPageRouteDefinition<TProvidedData> {
100
+ return {
101
+ kind: 'page',
102
+ path,
103
+ options,
104
+ data,
105
+ render,
106
+ };
107
+ }
90
108
 
91
109
  export const defineErrorRoute = ({
92
110
  code,
@@ -115,6 +115,8 @@ export type TRouteOptions = {
115
115
  whenStatic?: boolean; // If true, the route is only executed even if the page is cached
116
116
  canonicalParams?: string[]; // For SEO + unique ID for static cache
117
117
  layout?: false | string; // The nale of the layout
118
+ // Client navigation: 'deferred' swaps the page in before its data when the router runs in deferred mode
119
+ navigation?: 'deferred' | 'blocking';
118
120
 
119
121
  // To cleanup
120
122
  TESTING?: boolean;
@@ -18,18 +18,23 @@ export const routeOptionKeys = [
18
18
  'whenStatic',
19
19
  'canonicalParams',
20
20
  'layout',
21
+ 'navigation',
21
22
  'TESTING',
22
23
  'logging',
23
24
  ] as const satisfies (keyof TRouteOptions)[];
24
25
 
25
26
  export const reservedRouteOptionKeys = ['id', 'filepath', 'sourceLocation', 'data'] as const;
26
27
 
28
+ // `navigation` stays a legal data key on blocking pages, where apps already return one.
29
+ // A deferred page reserves it for its navigation render prop (see validateDeferredDataKeys).
30
+ const dataReservedRouteOptionKeys = routeOptionKeys.filter((key) => key !== 'navigation');
31
+
27
32
  const routeOptionKeysSet = new Set<string>(routeOptionKeys);
28
33
  const reservedRouteOptionKeysSet = new Set<string>(reservedRouteOptionKeys);
29
34
  const reservedPageDataKeys = new Set<string>([
30
- ...routeOptionKeys,
35
+ ...dataReservedRouteOptionKeys,
31
36
  ...reservedRouteOptionKeys,
32
- ...routeOptionKeys.map((key) => `_${key}`),
37
+ ...dataReservedRouteOptionKeys.map((key) => `_${key}`),
33
38
  ...reservedRouteOptionKeys.map((key) => `_${key}`),
34
39
  ]);
35
40
 
@@ -51,6 +56,17 @@ export const getRouteOptionKey = (key: string) => {
51
56
  return routeOptionKeysSet.has(key) ? (key as keyof TRouteOptions) : null;
52
57
  };
53
58
 
59
+ // Page and layout data both reach the render props, where a deferred page reads `navigation`
60
+ export const validateDeferredDataKeys = (route: TAnyRoute, result: object, source: string) => {
61
+ if (route.options.navigation !== 'deferred' || !result || !('navigation' in result)) return;
62
+
63
+ throw new Error(
64
+ `${source} for ${formatRouteTarget(route)} in ${formatRouteSource(route)} ` +
65
+ `cannot return key "navigation": a page declaring navigation: 'deferred' receives its ` +
66
+ `navigation state under that render prop. Rename the data key.`,
67
+ );
68
+ };
69
+
54
70
  export const validatePageDataResult = (route: TAnyRoute, result: unknown) => {
55
71
  if (!result || typeof result !== 'object' || Array.isArray(result)) {
56
72
  throw new Error(
@@ -59,6 +75,8 @@ export const validatePageDataResult = (route: TAnyRoute, result: unknown) => {
59
75
  );
60
76
  }
61
77
 
78
+ validateDeferredDataKeys(route, result, 'definePageRoute data');
79
+
62
80
  for (const key of Object.keys(result)) {
63
81
  if (!reservedPageDataKeys.has(key)) continue;
64
82
 
@@ -10,7 +10,7 @@ import type { Thing } from 'schema-dts';
10
10
  import type { ClientContext } from '@/client/context';
11
11
  import { ClientOrServerRouter, TErrorRoute, TPageErrorRoute, TPageRoute, TRoute, TRouteOptions } from '@common/router';
12
12
  import type { TFetcher, TFetcherList } from '@common/router/request/api';
13
- import { validatePageDataResult } from '@common/router/pageData';
13
+ import { validateDeferredDataKeys, validatePageDataResult } from '@common/router/pageData';
14
14
 
15
15
  /*----------------------------------
16
16
  - TYPES
@@ -61,6 +61,65 @@ export type TFrontRenderer<
61
61
  TAdditionnalData & { context: TPageRenderContext; data: { [key: string]: PrimitiveValue } },
62
62
  ) => VNode<any> | null;
63
63
 
64
+ // Navigation state of a page that declares navigation: 'deferred'.
65
+ // `since` is the Date.now() timestamp of the last status change; `stale` means the previous data is still on screen.
66
+ // `reloading` means the data step runs again for a navigation whose data already reached the screen (api.reload,
67
+ // or retry() after ready): the stale data is this page's own, never a previous page's under the new URL.
68
+ type TPageNavigationBase = { since: number; retry: () => void };
69
+
70
+ export type TPageNavigation =
71
+ | (TPageNavigationBase & { status: 'ready'; pending: false; stale: false; reloading: false; error: null })
72
+ | (TPageNavigationBase & { status: 'pending'; pending: true; stale: boolean; reloading: boolean; error: null })
73
+ | (TPageNavigationBase & { status: 'error'; pending: false; stale: boolean; reloading: boolean; error: Error });
74
+
75
+ export type TPageNavigationState =
76
+ | { status: 'ready' }
77
+ | { status: 'pending'; stale: boolean; reloading?: boolean }
78
+ | { status: 'error'; stale: boolean; reloading?: boolean; error: Error };
79
+
80
+ export const createPageNavigation = (state: TPageNavigationState, retry: () => void = () => {}): TPageNavigation => {
81
+ const base = { since: Date.now(), retry };
82
+
83
+ if (state.status === 'ready')
84
+ return { ...base, status: 'ready', pending: false, stale: false, reloading: false, error: null };
85
+ if (state.status === 'pending')
86
+ return {
87
+ ...base,
88
+ status: 'pending',
89
+ pending: true,
90
+ stale: state.stale,
91
+ reloading: state.reloading === true,
92
+ error: null,
93
+ };
94
+ return {
95
+ ...base,
96
+ status: 'error',
97
+ pending: false,
98
+ stale: state.stale,
99
+ reloading: state.reloading === true,
100
+ error: state.error,
101
+ };
102
+ };
103
+
104
+ // Page data as a deferred page sees it: every key is undefined until the navigation is ready
105
+ export type TDeferredPageData<TProvidedData extends {} = {}> = {
106
+ [Property in keyof TResolvedPageData<TProvidedData>]: TResolvedPageData<TProvidedData>[Property] | undefined;
107
+ };
108
+
109
+ // The renderer of a page that declares navigation: 'deferred'
110
+ export type TDeferredFrontRenderer<TProvidedData extends {} = {}> = (
111
+ context: TPageRenderContext &
112
+ TDeferredPageData<TProvidedData> & {
113
+ context: TPageRenderContext;
114
+ data: { [key: string]: PrimitiveValue };
115
+ navigation: TPageNavigation;
116
+ },
117
+ ) => VNode<any> | null;
118
+
119
+ export type TPageRenderer<TProvidedData extends {} = {}> =
120
+ | TFrontRenderer<TProvidedData>
121
+ | TDeferredFrontRenderer<TProvidedData>;
122
+
64
123
  // Script or CSS resource
65
124
  export type TPageResource = { id: string; attrs?: TObjetDonnees } & (
66
125
  | { inline: string }
@@ -100,10 +159,11 @@ export default abstract class PageResponse<
100
159
  // Data
101
160
  public fetchers: TFetcherList = {};
102
161
  public data: TObjetDonnees = {};
162
+ public navigation: TPageNavigation = createPageNavigation({ status: 'ready' });
103
163
 
104
164
  public constructor(
105
165
  public route: TRouteLike,
106
- public renderer: TFrontRenderer,
166
+ public renderer: TPageRenderer,
107
167
  public context: TContext,
108
168
  ) {
109
169
  this.chunkId = context.route.options.id;
@@ -129,23 +189,29 @@ export default abstract class PageResponse<
129
189
  return data as TFetcherList;
130
190
  }
131
191
 
132
- public async fetchData() {
192
+ // Runs the page and layout data providers without fetching anything
193
+ public prepareFetchers() {
133
194
  this.fetchers = this.createFetchers();
134
195
  this.bodyId = this.route.options.bodyId;
135
196
 
136
- // Fetch layout data
197
+ // Layout data
137
198
  if (this.layout?.data) {
138
199
  const layoutContext = {
139
200
  ...this.context,
140
201
  data: this.context.request.data,
141
202
  } as unknown as Parameters<typeof this.layout.data>[0];
142
203
  const fetchers = this.layout.data(layoutContext);
204
+ validateDeferredDataKeys(this.route, fetchers, 'Layout data');
143
205
  this.fetchers = { ...this.fetchers, ...fetchers };
144
206
  }
145
207
 
146
- // Fetch page data
147
- debug && console.log(`[router][page] Fetching api data:` + Object.keys(this.fetchers));
148
- this.data = await this.context.request.api.fetchSync(this.fetchers, this.data);
208
+ return this.fetchers;
209
+ }
210
+
211
+ // Pass the fetchers of an earlier prepareFetchers() call to skip running the providers again
212
+ public async fetchData(fetchers: TFetcherList = this.prepareFetchers()) {
213
+ debug && console.log(`[router][page] Fetching api data:` + Object.keys(fetchers));
214
+ this.data = await this.context.request.api.fetchSync(fetchers, this.data);
149
215
 
150
216
  return this.data;
151
217
  }