proteum 2.5.25 → 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/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
|
@@ -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
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
325
|
+
// API Error hook
|
|
326
|
+
this.app.handleError(e);
|
|
280
327
|
|
|
281
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
package/common/router/index.ts
CHANGED
|
@@ -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
|
-
...
|
|
35
|
+
...dataReservedRouteOptionKeys,
|
|
31
36
|
...reservedRouteOptionKeys,
|
|
32
|
-
...
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
}
|