proteum 2.5.25 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/agents/project/AGENTS.md +2 -0
- package/agents/project/client/pages/AGENTS.md +22 -1
- package/cli/compiler/client/index.ts +10 -5
- package/cli/compiler/client/outputNames.ts +28 -0
- package/cli/compiler/common/files/autres.ts +3 -1
- package/cli/scaffold/templates.ts +4 -0
- package/client/app/staleBuild.ts +80 -0
- package/client/services/router/components/Link.tsx +25 -0
- package/client/services/router/components/Page.tsx +19 -3
- package/client/services/router/components/router.tsx +165 -64
- package/client/services/router/index.tsx +132 -31
- package/client/services/router/navigation.ts +235 -0
- package/client/services/router/request/api.ts +123 -75
- package/client/services/router/response/index.tsx +5 -0
- package/client/services/router/response/page.ts +20 -4
- package/common/router/contracts.ts +2 -2
- package/common/router/definitions.ts +28 -10
- package/common/router/index.ts +2 -0
- package/common/router/pageData.ts +20 -2
- package/common/router/response/page.ts +73 -7
- package/docs/HISTORY.md +8 -0
- package/docs/client-assets.md +37 -0
- package/docs/client-navigation.md +128 -0
- package/docs/http-security-headers.md +114 -0
- package/package.json +2 -1
- package/server/services/router/http/cache.ts +4 -1
- package/server/services/router/http/index.ts +26 -25
- package/server/services/router/http/securityHeaders.ts +343 -0
- package/server/services/router/response/page/index.tsx +2 -2
- package/tests/app-shaped-typecheck.test.cjs +74 -0
- package/tests/client-api-fetch-sync.test.cjs +180 -0
- package/tests/client-output-names.test.cjs +114 -0
- package/tests/client-router-hooks.test.cjs +73 -0
- package/tests/client-router-navigation.test.cjs +857 -0
- package/tests/client-router-prefetch.test.cjs +197 -0
- package/tests/clientRouterHarness.cjs +70 -0
- package/tests/definition-contracts.test.cjs +45 -1
- package/tests/fakeDom.cjs +90 -0
- package/tests/http-server-security-headers.test.cjs +669 -0
- package/tests/router-cache-config.test.cjs +32 -0
- package/tests/stale-build-recovery.test.cjs +206 -0
- package/types/global/vendors.d.ts +9 -0
- package/types/vendors.d.ts +9 -0
package/README.md
CHANGED
|
@@ -275,13 +275,17 @@ my-app/
|
|
|
275
275
|
|
|
276
276
|
| Topic | Guide |
|
|
277
277
|
| --- | --- |
|
|
278
|
+
| Client navigation (blocking, deferred, prefetch) | [docs/client-navigation.md](docs/client-navigation.md) |
|
|
279
|
+
| Client assets (hashed names, cache headers, stale builds) | [docs/client-assets.md](docs/client-assets.md) |
|
|
278
280
|
| Diagnostics & explainability | [docs/diagnostics.md](docs/diagnostics.md) |
|
|
279
281
|
| Model Context Protocol (MCP) | [docs/mcp.md](docs/mcp.md) |
|
|
280
282
|
| Request tracing & perf | [docs/request-tracing.md](docs/request-tracing.md) |
|
|
283
|
+
| HTTP security headers & CSP | [docs/http-security-headers.md](docs/http-security-headers.md) |
|
|
281
284
|
| Agent routing & token efficiency | [docs/agent-routing.md](docs/agent-routing.md) |
|
|
282
285
|
| Dev commands | [docs/dev-commands.md](docs/dev-commands.md) |
|
|
283
286
|
| Dev sessions | [docs/dev-sessions.md](docs/dev-sessions.md) |
|
|
284
287
|
| Migrating to 2.5 | [docs/migration-2.5.md](docs/migration-2.5.md) |
|
|
288
|
+
| Release history | [docs/HISTORY.md](docs/HISTORY.md) |
|
|
285
289
|
|
|
286
290
|
## 🧭 Philosophy
|
|
287
291
|
|
package/agents/project/AGENTS.md
CHANGED
|
@@ -252,6 +252,8 @@ export default defineController({
|
|
|
252
252
|
- Controller fetchers and promises returned from `data` resolve before render.
|
|
253
253
|
- `render` consumes resolved page data and uses generated controller methods from render args or `@/client/context`.
|
|
254
254
|
- Use `api.reload(...)` or `api.set(...)` only when intentionally mutating active page data state.
|
|
255
|
+
- Client navigation is blocking by default: data first, then the swap. A page swaps in before its data only when the router config sets `navigation: { mode: 'deferred' }`, the page sets `options.navigation: 'deferred'`, and it has a `data` loader. Its render then receives every data key as possibly `undefined` plus a `navigation` prop (`status` `ready`, `pending` or `error`, `stale`, `reloading`, `error`, `since`, `retry()`), must render the pending and error states, and cannot return a data key named `navigation`. Contract: `node_modules/proteum/docs/client-navigation.md`.
|
|
256
|
+
- Run page-view analytics and anything that needs the page data on screen on the `page.ready` router hook, not `page.changed`. It fires once per navigation, when the data is first on screen (on a deferred page that includes a `retry()` recovering a failed first data step), never for later retries or reloads. Read the URL from the request it passes, not `window.location`. The router resets `document.body` classes before both hooks: an app re-adds its own in both.
|
|
255
257
|
- Error pages use `defineErrorRoute({ code, options, render })` in `client/pages/_messages/**`.
|
|
256
258
|
- Prefer `proteum create page ...` for new page boilerplate, then review the explicit route path, options object, and data payload.
|
|
257
259
|
|
|
@@ -36,9 +36,30 @@ export default definePageRoute({
|
|
|
36
36
|
- Route behavior belongs in the explicit `options` object, not in page data.
|
|
37
37
|
- `data` returns one flat object, or the route definition sets `data: null` when no page data is needed.
|
|
38
38
|
- Returning route-option keys such as `auth`, `layout`, `static`, `redirectLogged`, or their `_`-prefixed variants from `data` is a contract error.
|
|
39
|
-
- Controller fetchers and promises returned from `data` resolve before render.
|
|
39
|
+
- Controller fetchers and promises returned from `data` resolve before render, except on a deferred page (below). Only controller fetchers reach the server; other promises resolve in the browser and plain values skip the round trip. Both go through JSON, as SSR data does. A loader without any controller fetcher sends no request on a client navigation.
|
|
40
40
|
- If a page needs route data, return it from `data` and read it in `render`.
|
|
41
41
|
|
|
42
|
+
## Blocking And Deferred Navigation
|
|
43
|
+
|
|
44
|
+
- Blocking is the default: a client navigation fetches the page data, then swaps the page in. Render reads resolved data only.
|
|
45
|
+
- `options: { navigation: 'deferred' }` swaps the page in before its data, but only when the router config sets `navigation: { mode: 'deferred' }` and the page has a `data` loader. Otherwise the page stays blocking and its `navigation` prop is always `ready`.
|
|
46
|
+
- A deferred render receives every data key as `T | undefined` and a `navigation` prop: `status` (`ready`, `pending`, `error`), `pending`, `stale` (previous data still on screen), `reloading` (that data is this page's own: `api.reload`, or `retry()` after ready), `error`, `since`, `retry()`. Render the skeleton while data is missing, the previous data while `stale`, and an error with `navigation.retry()` on `error`. A render that reads the URL beside its data keeps stale data only while `reloading`.
|
|
47
|
+
- A deferred page cannot return a data key named `navigation`. Blocking pages may.
|
|
48
|
+
- Keep pages blocking when they redirect or throw on their data, and for public full-load pages.
|
|
49
|
+
- `api.reload(...)` on a deferred page re-runs only its data step through `navigation.retry()` and merges the result into the page state, keeping `api.set` values.
|
|
50
|
+
- `page.ready` fires once per navigation, when its data is first on screen, including after a `retry()` that recovers a failed first data step; never for later retries or reloads.
|
|
51
|
+
- Full contract: `node_modules/proteum/docs/client-navigation.md`.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
export default definePageRoute({
|
|
55
|
+
path: '/radars',
|
|
56
|
+
options: { auth: true, navigation: 'deferred' },
|
|
57
|
+
data: ({ Radars }) => ({ radars: Radars.list() }),
|
|
58
|
+
render: ({ radars, navigation }) =>
|
|
59
|
+
radars === undefined ? <RadarsSkeleton navigation={navigation} /> : <RadarsPage radars={radars} />,
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
42
63
|
## Page Rules
|
|
43
64
|
|
|
44
65
|
- Prefer generated page args or the app client context. Do not import `.proteum` implementation files directly.
|
|
@@ -15,6 +15,7 @@ import CssMinimizerPlugin from '../common/cssMinimizer';
|
|
|
15
15
|
import { toRspackAliases } from '../common/rspackAliases';
|
|
16
16
|
import { resolveUiSingletonAliases } from '../common/uiSingletons';
|
|
17
17
|
import identityAssets from './identite';
|
|
18
|
+
import { getClientOutputNames } from './outputNames';
|
|
18
19
|
import cli from '../..';
|
|
19
20
|
import { logVerbose } from '../../runtime/verbose';
|
|
20
21
|
|
|
@@ -126,6 +127,8 @@ export default function createCompiler(
|
|
|
126
127
|
const transpileModuleDirectories = app.transpileModuleDirectories;
|
|
127
128
|
const lightningCssTargets = resolveLightningCssTargets(app);
|
|
128
129
|
const hmrClientEntry = path.join(frameworkSourceRoot, 'client', 'dev', 'hmr.ts');
|
|
130
|
+
// Content-hashed in production, entries included: see outputNames.ts.
|
|
131
|
+
const outputNames = getClientOutputNames(dev);
|
|
129
132
|
|
|
130
133
|
const commonConfig = createCommonConfig(app, 'client', mode, outputTarget);
|
|
131
134
|
|
|
@@ -172,7 +175,7 @@ export default function createCompiler(
|
|
|
172
175
|
output: {
|
|
173
176
|
pathinfo: dev,
|
|
174
177
|
path: outputPath + '/public',
|
|
175
|
-
filename:
|
|
178
|
+
filename: outputNames.entry,
|
|
176
179
|
assetModuleFilename: '[hash][ext]',
|
|
177
180
|
environment: {
|
|
178
181
|
arrowFunction: true,
|
|
@@ -185,9 +188,9 @@ export default function createCompiler(
|
|
|
185
188
|
optionalChaining: true,
|
|
186
189
|
templateLiteral: true,
|
|
187
190
|
},
|
|
188
|
-
cssFilename:
|
|
189
|
-
cssChunkFilename:
|
|
190
|
-
chunkFilename:
|
|
191
|
+
cssFilename: outputNames.css,
|
|
192
|
+
cssChunkFilename: outputNames.css,
|
|
193
|
+
chunkFilename: outputNames.chunk,
|
|
191
194
|
},
|
|
192
195
|
|
|
193
196
|
resolve: {
|
|
@@ -290,7 +293,9 @@ export default function createCompiler(
|
|
|
290
293
|
]),
|
|
291
294
|
|
|
292
295
|
// Extract CSS in dev too so SSR emits the same stylesheet links as production.
|
|
293
|
-
|
|
296
|
+
// The extract plugin names its files itself (its default is `[name].css`); `output.cssFilename`
|
|
297
|
+
// only applies to native CSS, so the hashed name is passed here.
|
|
298
|
+
new rspack.CssExtractRspackPlugin({ filename: outputNames.css, chunkFilename: outputNames.css }),
|
|
294
299
|
|
|
295
300
|
...createClientBundleAnalysisPlugins(app, outputTarget),
|
|
296
301
|
],
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*----------------------------------
|
|
2
|
+
- CLIENT OUTPUT NAMES
|
|
3
|
+
----------------------------------*/
|
|
4
|
+
|
|
5
|
+
export type TClientOutputNames = {
|
|
6
|
+
// Entry files: `runtime`, `framework`, `vendors`, `client`
|
|
7
|
+
entry: string;
|
|
8
|
+
// Async JS chunks
|
|
9
|
+
chunk: string;
|
|
10
|
+
// Entry and page CSS
|
|
11
|
+
css: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Production files carry a content hash, entries included. An unhashed `runtime.js` that a
|
|
16
|
+
* browser or CDN keeps after a deploy still maps chunk ids to files that no longer exist
|
|
17
|
+
* (21 Oct 2026: a 4 h CDN browser TTL served the old runtime, its chunks 404ed). The same holds
|
|
18
|
+
* for CSS: a page chunk keeps its name across builds, so new HTML could pair with a stylesheet
|
|
19
|
+
* from the previous build. The HTML reads every name from `client-manifest.json`, so nothing
|
|
20
|
+
* names these files, and the router's public cache classifies `name.<hash>.ext` as versioned.
|
|
21
|
+
*
|
|
22
|
+
* Dev keeps stable names: HMR and the dev middleware rely on them, and dev serves `no-store`.
|
|
23
|
+
* `[name]` stays before the hash so the network panel and bundle analysis stay readable.
|
|
24
|
+
*/
|
|
25
|
+
export const getClientOutputNames = (dev: boolean): TClientOutputNames =>
|
|
26
|
+
dev
|
|
27
|
+
? { entry: '[name].js', chunk: '[name].js', css: '[name].css' }
|
|
28
|
+
: { entry: '[name].[contenthash:8].js', chunk: '[id].[contenthash:8].js', css: '[name].[contenthash:8].css' };
|
|
@@ -23,6 +23,8 @@ module.exports = (app: App, dev: boolean, client: boolean) => [
|
|
|
23
23
|
test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
|
|
24
24
|
//loader: 'file-loader',
|
|
25
25
|
type: 'asset/resource',
|
|
26
|
-
|
|
26
|
+
// Hashed so the router serves fonts immutable. `[ext]` carries its own dot: the former
|
|
27
|
+
// `[name].[ext]` emitted `figtree-latin..woff2`, unhashed and revalidated on every load.
|
|
28
|
+
generator: { filename: 'fonts/[name].[contenthash:8][ext]' },
|
|
27
29
|
},
|
|
28
30
|
];
|
|
@@ -218,6 +218,10 @@ export const routerBaseConfig = {
|
|
|
218
218
|
upload: {
|
|
219
219
|
maxSize: '10mb',
|
|
220
220
|
},
|
|
221
|
+
// Content-Security-Policy for router answers (HTML only under /api): see Proteum's docs/http-security-headers.md.
|
|
222
|
+
csp: {
|
|
223
|
+
scripts: [],
|
|
224
|
+
},
|
|
221
225
|
},
|
|
222
226
|
context: () => ({}),
|
|
223
227
|
} satisfies RouterBaseConfig;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/*----------------------------------
|
|
2
|
+
- STALE BUILD RECOVERY
|
|
3
|
+
----------------------------------*/
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* A page running an older build asks for chunk files that a deploy removed. The only cure is a
|
|
7
|
+
* fresh document: the new HTML names the new, content-hashed entry files, so a reload loads the
|
|
8
|
+
* new build. One reload per build id, kept in sessionStorage, so a chunk that is missing in the
|
|
9
|
+
* new build too (a deploy race, a CDN that kept a 404) ends on the error instead of a loop.
|
|
10
|
+
*
|
|
11
|
+
* - Every build id that reloaded is kept, not only the last one: during a rollout two builds can
|
|
12
|
+
* answer at once, and a single slot let the tab bounce between them for as long as it lasted.
|
|
13
|
+
* - A route chunk that loads clears the list, so a reload spent on a network blip does not take
|
|
14
|
+
* the recovery away from the real deploy that comes later in the same tab.
|
|
15
|
+
* - Offline, a chunk fails because there is no network, not because the build is gone: no reload,
|
|
16
|
+
* which would only swap the page for the browser's offline screen.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const STALE_BUILD_RELOAD_KEY = 'proteum:stale-build-reload';
|
|
20
|
+
|
|
21
|
+
// A rollout overlaps two builds at most; the cap only bounds what a long-lived tab can store
|
|
22
|
+
const MAX_RELOADED_BUILDS = 5;
|
|
23
|
+
|
|
24
|
+
type TReloadStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
25
|
+
|
|
26
|
+
export type TStaleBuildReloadOptions = {
|
|
27
|
+
storage?: () => TReloadStorage;
|
|
28
|
+
reload?: () => void;
|
|
29
|
+
isOnline?: () => boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const defaultStorage = () => window.sessionStorage;
|
|
33
|
+
|
|
34
|
+
const readReloadedBuilds = (store: TReloadStorage) => (store.getItem(STALE_BUILD_RELOAD_KEY) || '').split(',').filter(Boolean);
|
|
35
|
+
|
|
36
|
+
// The shapes rspack's runtime rejects with: `ChunkLoadError` for a JS chunk, the code for a CSS chunk.
|
|
37
|
+
export const isChunkLoadError = (error: Error) =>
|
|
38
|
+
error.name === 'ChunkLoadError' || ('code' in error && error.code === 'CSS_CHUNK_LOAD_FAILED');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reloads the page once for `buildId` and returns true, or returns false when this build already
|
|
42
|
+
* reloaded in this tab, when the browser is offline, or when sessionStorage is unavailable (no
|
|
43
|
+
* guard means no reload).
|
|
44
|
+
*/
|
|
45
|
+
export const reloadOnceForStaleBuild = (
|
|
46
|
+
buildId: string,
|
|
47
|
+
{
|
|
48
|
+
storage = defaultStorage,
|
|
49
|
+
reload = () => window.location.reload(),
|
|
50
|
+
isOnline = () => window.navigator.onLine !== false,
|
|
51
|
+
}: TStaleBuildReloadOptions = {},
|
|
52
|
+
): boolean => {
|
|
53
|
+
if (!isOnline()) return false;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const store = storage();
|
|
57
|
+
const reloaded = readReloadedBuilds(store);
|
|
58
|
+
if (reloaded.includes(buildId)) return false;
|
|
59
|
+
|
|
60
|
+
store.setItem(STALE_BUILD_RELOAD_KEY, [...reloaded, buildId].slice(-MAX_RELOADED_BUILDS).join(','));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Storage blocked (private mode, sandboxed frame, quota): without the guard a reload could loop
|
|
63
|
+
console.warn('Stale build recovery skipped: sessionStorage is unavailable.', error);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
reload();
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A route chunk loaded: the running build is whole, so a later missing chunk gets its reload again. */
|
|
72
|
+
export const clearStaleBuildReloads = (storage: () => TReloadStorage = defaultStorage) => {
|
|
73
|
+
try {
|
|
74
|
+
const store = storage();
|
|
75
|
+
if (store.getItem(STALE_BUILD_RELOAD_KEY) !== null) store.removeItem(STALE_BUILD_RELOAD_KEY);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Nothing to clear when storage is blocked: the reload was never granted either
|
|
78
|
+
console.warn('Stale build recovery could not reset its guard.', error);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// Npm
|
|
6
6
|
import React from 'react';
|
|
7
7
|
import type { ComponentChild } from 'preact';
|
|
8
|
+
|
|
9
|
+
// Core
|
|
10
|
+
import { ReactClientContext } from '@/client/context';
|
|
11
|
+
|
|
12
|
+
// Specific
|
|
8
13
|
import { history } from '../request/history';
|
|
9
14
|
|
|
10
15
|
export const shouldOpenNewTab = (url: string, target?: string) =>
|
|
@@ -21,16 +26,35 @@ export const Link = ({
|
|
|
21
26
|
className,
|
|
22
27
|
onClick,
|
|
23
28
|
target,
|
|
29
|
+
prefetch,
|
|
24
30
|
...props
|
|
25
31
|
}: {
|
|
26
32
|
to: string;
|
|
27
33
|
children?: ComponentChild;
|
|
28
34
|
class?: string;
|
|
29
35
|
className?: string;
|
|
36
|
+
// Loads the route chunk of `to` on hover or focus
|
|
37
|
+
prefetch?: boolean;
|
|
30
38
|
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
|
39
|
+
const context = React.useContext(ReactClientContext);
|
|
31
40
|
const openNewTab = shouldOpenNewTab(to, typeof target === 'string' ? target : undefined);
|
|
32
41
|
const resolvedTarget = openNewTab ? '_blank' : target;
|
|
33
42
|
|
|
43
|
+
const { onMouseEnter, onFocus } = props;
|
|
44
|
+
const prefetchHandlers =
|
|
45
|
+
prefetch && !openNewTab
|
|
46
|
+
? {
|
|
47
|
+
onMouseEnter: (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
48
|
+
void context?.Router.prefetch(to);
|
|
49
|
+
onMouseEnter?.(e);
|
|
50
|
+
},
|
|
51
|
+
onFocus: (e: React.FocusEvent<HTMLAnchorElement>) => {
|
|
52
|
+
void context?.Router.prefetch(to);
|
|
53
|
+
onFocus?.(e);
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
: {};
|
|
57
|
+
|
|
34
58
|
const handleClick: React.MouseEventHandler<HTMLAnchorElement> | undefined = openNewTab
|
|
35
59
|
? onClick
|
|
36
60
|
: (e) => {
|
|
@@ -42,6 +66,7 @@ export const Link = ({
|
|
|
42
66
|
return (
|
|
43
67
|
<a
|
|
44
68
|
{...props}
|
|
69
|
+
{...prefetchHandlers}
|
|
45
70
|
href={to}
|
|
46
71
|
target={resolvedTarget}
|
|
47
72
|
onClick={handleClick}
|
|
@@ -6,6 +6,7 @@ import React from 'react';
|
|
|
6
6
|
|
|
7
7
|
// Core
|
|
8
8
|
import useContext from '@/client/context';
|
|
9
|
+
import type { TFrontRenderer } from '@common/router/response/page';
|
|
9
10
|
|
|
10
11
|
// Specific
|
|
11
12
|
import type Page from '../response/page';
|
|
@@ -25,6 +26,12 @@ export default ({ page }: { page: Page }) => {
|
|
|
25
26
|
page.setAllData = setApiData;
|
|
26
27
|
const fullData = { ...context.data, ...apiData };
|
|
27
28
|
|
|
29
|
+
// Bind navigation the same way: the page instance holds the state, the setter re-renders
|
|
30
|
+
// A same-chunk navigation keeps this component, so the new page takes over both setters
|
|
31
|
+
const [, setNavigation] = React.useState(page.navigation);
|
|
32
|
+
page.setNavigation = setNavigation;
|
|
33
|
+
const { navigation } = page;
|
|
34
|
+
|
|
28
35
|
// Temporary fix: context.page may not be updated at this stage
|
|
29
36
|
// Seems to be the case when we change page, but still same page component with different data
|
|
30
37
|
// TODO: ensure these updated are made every tume we change page / context
|
|
@@ -33,15 +40,24 @@ export default ({ page }: { page: Page }) => {
|
|
|
33
40
|
context.context = context;
|
|
34
41
|
|
|
35
42
|
// Page component has not changed, but data were updated (ex: url parameters change)
|
|
43
|
+
// A deferred page gets its data from the navigation sequencer instead: it keeps the previous data
|
|
44
|
+
// on screen while stale, and merges a reload into what api.set wrote
|
|
36
45
|
React.useEffect(() => {
|
|
37
|
-
setApiData(page.data);
|
|
46
|
+
if (!page.isDeferred()) setApiData(page.data);
|
|
38
47
|
}, [page.data]);
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
// Only a page declaring navigation: 'deferred' receives the prop: blocking pages may use the key for data
|
|
50
|
+
const rendererProps = {
|
|
51
|
+
...context,
|
|
52
|
+
...fullData,
|
|
53
|
+
...(page.route.options.navigation === 'deferred' ? { navigation } : {}),
|
|
54
|
+
} as Parameters<TFrontRenderer>[0];
|
|
55
|
+
// A deferred renderer reads the same props, plus `navigation`
|
|
56
|
+
const Renderer = page.renderer as TFrontRenderer | undefined;
|
|
41
57
|
|
|
42
58
|
/*----------------------------------
|
|
43
59
|
- RENDER
|
|
44
60
|
----------------------------------*/
|
|
45
61
|
// Make request parameters and api data accessible from the page component
|
|
46
|
-
return
|
|
62
|
+
return Renderer ? <Renderer {...rendererProps} /> : <>Renderer missing</>;
|
|
47
63
|
};
|
|
@@ -15,6 +15,7 @@ import ClientRequest from '../request';
|
|
|
15
15
|
import { history, location, Update } from '../request/history';
|
|
16
16
|
//import initTooltips from '@client/components/Donnees/Tooltip';
|
|
17
17
|
import type Page from '../response/page';
|
|
18
|
+
import { createNavigationSequencer, type TNavigationSequencer } from '../navigation';
|
|
18
19
|
|
|
19
20
|
/*----------------------------------
|
|
20
21
|
- TYPES
|
|
@@ -35,8 +36,108 @@ const withProfiler = <T,>(callback: (runtime: (typeof import('@client/dev/profil
|
|
|
35
36
|
return callback(profilerModule.profilerRuntime);
|
|
36
37
|
};
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
// The hash arrives percent-encoded (`#caf%C3%A9`): the id is matched decoded, like the browser's own fragment scroll
|
|
40
|
+
const readHashId = (hash: string) => {
|
|
41
|
+
const id = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
42
|
+
try {
|
|
43
|
+
return decodeURIComponent(id);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// A malformed escape cannot name a decoded id: look it up as written
|
|
46
|
+
if (error instanceof URIError) return id;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Exported for the router tests. A hash names an element id, so look the id up: as a selector, `#prices` minus
|
|
52
|
+
// its `#` matches a <prices> tag, and an id such as `2026-prices` throws.
|
|
53
|
+
export const scrollToHash = (hash: string) => {
|
|
54
|
+
const id = readHashId(hash);
|
|
55
|
+
if (id) document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/*----------------------------------
|
|
59
|
+
- NAVIGATION
|
|
60
|
+
----------------------------------*/
|
|
61
|
+
|
|
62
|
+
type TRouterView = {
|
|
63
|
+
setCurrentPage: React.Dispatch<React.SetStateAction<Page | undefined>>;
|
|
64
|
+
setReadyCount: React.Dispatch<React.SetStateAction<number>>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type TRouterNavigation = { sequencer: TNavigationSequencer<ClientRequest, Page>; view: TRouterView };
|
|
68
|
+
|
|
69
|
+
const navigations = new WeakMap<ClientRouter, TRouterNavigation>();
|
|
70
|
+
|
|
71
|
+
// Exported for the router tests
|
|
72
|
+
export const createRouterNavigation = (
|
|
73
|
+
clientRouter: ClientRouter,
|
|
74
|
+
context: ReturnType<typeof useContext>,
|
|
75
|
+
view: TRouterView,
|
|
76
|
+
initialPage: Page | undefined,
|
|
77
|
+
): TRouterNavigation => {
|
|
78
|
+
const navigation: TRouterNavigation = { view, sequencer: undefined! };
|
|
79
|
+
|
|
80
|
+
navigation.sequencer = createNavigationSequencer<ClientRequest, Page>(
|
|
81
|
+
{
|
|
82
|
+
resolve: (request, isCurrent) => clientRouter.resolve(request, isCurrent),
|
|
83
|
+
defers: (page) => page.isDeferred(),
|
|
84
|
+
prepare: (page) => {
|
|
85
|
+
page.prepareFetchers();
|
|
86
|
+
},
|
|
87
|
+
// Fetch API data to hydrate the page
|
|
88
|
+
fetch: (page, prepared) => page.preRender(undefined, prepared ? page.fetchers : undefined),
|
|
89
|
+
commit: (newpage) => {
|
|
90
|
+
// The context names the page on screen: set at the swap, never at resolution, so a navigation
|
|
91
|
+
// that never commits (superseded, failed) leaves api.set and api.reload on the page still shown
|
|
92
|
+
context.page = newpage as typeof context.page;
|
|
93
|
+
|
|
94
|
+
// Add page container
|
|
95
|
+
navigation.view.setCurrentPage((page) => {
|
|
96
|
+
// WARN: Don't cancel navigation if same page as before, as we already instanciated the new page
|
|
97
|
+
// and bound the context with it. Otherwise it would cause reference issues
|
|
98
|
+
// (ex: page.setAllData makes ref to the new context)
|
|
99
|
+
|
|
100
|
+
// If if the layout changed
|
|
101
|
+
const curLayout = page?.layout;
|
|
102
|
+
const newLayout = newpage?.layout;
|
|
103
|
+
if (newLayout && curLayout && newLayout.path !== curLayout.path) {
|
|
104
|
+
// TEMPORARY FIX: reload everything when we change layout
|
|
105
|
+
// Because layout can have a different CSS theme
|
|
106
|
+
// But when we call setLayout, the style of the previous layout are still oaded and applied
|
|
107
|
+
// Find a way to unload the previous layout / page resources before to load the new one
|
|
108
|
+
console.log(LogPrefix, `Changing layout. Before:`, curLayout, 'New layout:', newLayout);
|
|
109
|
+
const app = context.app as { setLayout?: (layout: NonNullable<typeof newLayout>) => void };
|
|
110
|
+
app.setLayout?.(newLayout);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return newpage;
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
// Data and navigation state change in one batch, so no render sees ready without the data
|
|
117
|
+
render: (page, data) => {
|
|
118
|
+
if (data === 'replace') page.setAllData(() => page.data);
|
|
119
|
+
else if (data === 'merge') page.setAllData((current) => ({ ...current, ...page.data }));
|
|
120
|
+
page.setNavigation(page.navigation);
|
|
121
|
+
},
|
|
122
|
+
ready: () => navigation.view.setReadyCount((count: number) => count + 1),
|
|
123
|
+
setLoading: (loading) => clientRouter.setLoading(loading),
|
|
124
|
+
fail: (step, error, sessionId) => {
|
|
125
|
+
const message = step === 'route' ? 'Unable to load the page:' : 'Unable to fetch data:';
|
|
126
|
+
console.error(LogPrefix, message, error);
|
|
127
|
+
// A chunk that fails to load means a new version: the app error handler tells the user
|
|
128
|
+
if (step === 'route') clientRouter.app.handleError(error);
|
|
129
|
+
if (step === 'retry') return;
|
|
130
|
+
withProfiler((runtime) =>
|
|
131
|
+
runtime.failNavigation(error instanceof Error ? error.message : String(error), sessionId),
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
startRender: (sessionId) => withProfiler((runtime) => runtime.startRenderStep(sessionId)),
|
|
135
|
+
},
|
|
136
|
+
initialPage,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return navigation;
|
|
140
|
+
};
|
|
40
141
|
|
|
41
142
|
/*----------------------------------
|
|
42
143
|
- COMPONENT
|
|
@@ -48,6 +149,18 @@ export default ({ service: clientRouter, loaderComponent }: TProps) => {
|
|
|
48
149
|
|
|
49
150
|
const context = useContext();
|
|
50
151
|
const [currentPage, setCurrentPage] = React.useState<undefined | Page>(context.page as Page | undefined);
|
|
152
|
+
// Bumped when a deferred page settles ready for the first time, so page.ready runs after that render
|
|
153
|
+
const [readyCount, setReadyCount] = React.useState(0);
|
|
154
|
+
const readyPage = React.useRef<Page>();
|
|
155
|
+
|
|
156
|
+
// The sequencer outlives this component, which remounts when the layout changes: ports reach the mounted one
|
|
157
|
+
const view: TRouterView = { setCurrentPage, setReadyCount };
|
|
158
|
+
let navigation = clientRouter && navigations.get(clientRouter);
|
|
159
|
+
if (navigation) navigation.view = view;
|
|
160
|
+
else if (clientRouter) {
|
|
161
|
+
navigation = createRouterNavigation(clientRouter, context, view, currentPage);
|
|
162
|
+
navigations.set(clientRouter, navigation);
|
|
163
|
+
}
|
|
51
164
|
|
|
52
165
|
// Bind context object to client router
|
|
53
166
|
if (clientRouter !== undefined) {
|
|
@@ -72,7 +185,7 @@ export default ({ service: clientRouter, loaderComponent }: TProps) => {
|
|
|
72
185
|
request.hash !== currentRequest.hash &&
|
|
73
186
|
request.hash !== undefined
|
|
74
187
|
) {
|
|
75
|
-
|
|
188
|
+
scrollToHash(request.hash);
|
|
76
189
|
return;
|
|
77
190
|
}
|
|
78
191
|
|
|
@@ -85,62 +198,51 @@ export default ({ service: clientRouter, loaderComponent }: TProps) => {
|
|
|
85
198
|
);
|
|
86
199
|
clientRouter.runHook('page.change', request);
|
|
87
200
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
88
|
-
clientRouter.setLoading(true);
|
|
89
|
-
const newpage = (context.page = await clientRouter.resolve(request));
|
|
90
201
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return await changePage(newpage, data, request, sessionId);
|
|
202
|
+
// Loader, route, then data and swap in the order of the page navigation mode
|
|
203
|
+
return await navigation?.sequencer.navigate(request, data, sessionId);
|
|
95
204
|
};
|
|
96
205
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
await newpage.preRender();
|
|
101
|
-
} catch (error) {
|
|
102
|
-
console.error(LogPrefix, 'Unable to fetch data:', error);
|
|
103
|
-
withProfiler((runtime) =>
|
|
104
|
-
runtime.failNavigation(error instanceof Error ? error.message : String(error), sessionId),
|
|
105
|
-
);
|
|
106
|
-
clientRouter?.setLoading(false);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Add additional data
|
|
111
|
-
if (data) newpage.data = { ...newpage.data, ...data };
|
|
112
|
-
withProfiler((runtime) => runtime.startRenderStep(sessionId));
|
|
113
|
-
|
|
114
|
-
// Add page container
|
|
115
|
-
setCurrentPage((page) => {
|
|
116
|
-
// WARN: Don't cancel navigation if same page as before, as we already instanciated the new page and bound the context with it
|
|
117
|
-
// Otherwise it would cause reference issues (ex: page.setAllData makes ref to the new context)
|
|
118
|
-
|
|
119
|
-
// If if the layout changed
|
|
120
|
-
const curLayout = currentPage?.layout;
|
|
121
|
-
const newLayout = newpage?.layout;
|
|
122
|
-
if (newLayout && curLayout && newLayout.path !== curLayout.path) {
|
|
123
|
-
// TEMPORARY FIX: reload everything when we change layout
|
|
124
|
-
// Because layout can have a different CSS theme
|
|
125
|
-
// But when we call setLayout, the style of the previous layout are still oaded and applied
|
|
126
|
-
// Find a way to unload the previous layout / page resources before to load the new one
|
|
127
|
-
console.log(LogPrefix, `Changing layout. Before:`, curLayout, 'New layout:', newLayout);
|
|
128
|
-
/*window.location.replace( request ? request.url : window.location.href );
|
|
129
|
-
return page; // Don't spread since it's an instance*/
|
|
130
|
-
|
|
131
|
-
(context.app as { setLayout?: (layout: NonNullable<typeof newLayout>) => void }).setLayout?.(newLayout);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return newpage;
|
|
135
|
-
});
|
|
206
|
+
function changePage(newpage: Page, data?: {}) {
|
|
207
|
+
return navigation?.sequencer.show(newpage, data);
|
|
136
208
|
}
|
|
137
209
|
|
|
138
210
|
/*----------------------------------
|
|
139
211
|
- HOOKS
|
|
140
212
|
----------------------------------*/
|
|
141
213
|
|
|
142
|
-
const restoreScroll = (currentPage?: Page) =>
|
|
143
|
-
|
|
214
|
+
const restoreScroll = (currentPage?: Page) => currentPage?.scrollToId && scrollToHash(currentPage.scrollToId);
|
|
215
|
+
|
|
216
|
+
const finishNavigation = (currentPage?: Page) => {
|
|
217
|
+
const routeLabel =
|
|
218
|
+
currentPage && 'path' in currentPage.route && currentPage.route.path
|
|
219
|
+
? currentPage.route.path
|
|
220
|
+
: currentPage && 'code' in currentPage.route
|
|
221
|
+
? String(currentPage.route.code)
|
|
222
|
+
: undefined;
|
|
223
|
+
withProfiler((runtime) =>
|
|
224
|
+
runtime.finishNavigation({
|
|
225
|
+
chunkId: currentPage?.chunkId,
|
|
226
|
+
routeLabel,
|
|
227
|
+
title: currentPage?.title,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// page.ready: once per page, after the render that shows its data
|
|
233
|
+
const markReady = (page: Page | undefined, afterData: boolean) => {
|
|
234
|
+
if (!clientRouter || !page || readyPage.current === page || page.navigation.status !== 'ready') return;
|
|
235
|
+
readyPage.current = page;
|
|
236
|
+
|
|
237
|
+
if (afterData) {
|
|
238
|
+
// Title, body classes and hash target come from the render with data
|
|
239
|
+
page.updateClient();
|
|
240
|
+
restoreScroll(page);
|
|
241
|
+
finishNavigation(page);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
clientRouter.runHook('page.ready', (page.context.request || context.request) as ClientRequest);
|
|
245
|
+
};
|
|
144
246
|
|
|
145
247
|
// First render
|
|
146
248
|
React.useEffect(() => {
|
|
@@ -155,12 +257,17 @@ export default ({ service: clientRouter, loaderComponent }: TProps) => {
|
|
|
155
257
|
});
|
|
156
258
|
}, []);
|
|
157
259
|
|
|
260
|
+
// A deferred page keeps the loader until its data settles. Read here, at render, for the effect below: data
|
|
261
|
+
// landing between this render and that effect (one frame) would read as ready there, fire page.ready before
|
|
262
|
+
// the data render, and leave the refresh after it (title, hash scroll, profiler) unrun.
|
|
263
|
+
const pending = currentPage !== undefined && currentPage.navigation.status !== 'ready';
|
|
264
|
+
|
|
158
265
|
// On every page change
|
|
159
266
|
React.useEffect(() => {
|
|
160
267
|
if (!clientRouter) return;
|
|
161
268
|
|
|
162
269
|
// Page loaded
|
|
163
|
-
clientRouter.setLoading(false);
|
|
270
|
+
if (!pending) clientRouter.setLoading(false);
|
|
164
271
|
|
|
165
272
|
// Reset scroll
|
|
166
273
|
window.scrollTo(0, 0);
|
|
@@ -168,24 +275,18 @@ export default ({ service: clientRouter, loaderComponent }: TProps) => {
|
|
|
168
275
|
currentPage?.updateClient();
|
|
169
276
|
// Scroll to the selected content via url hash
|
|
170
277
|
restoreScroll(currentPage);
|
|
171
|
-
|
|
172
|
-
currentPage && 'path' in currentPage.route && currentPage.route.path
|
|
173
|
-
? currentPage.route.path
|
|
174
|
-
: currentPage && 'code' in currentPage.route
|
|
175
|
-
? String(currentPage.route.code)
|
|
176
|
-
: undefined;
|
|
177
|
-
withProfiler((runtime) =>
|
|
178
|
-
runtime.finishNavigation({
|
|
179
|
-
chunkId: currentPage?.chunkId,
|
|
180
|
-
routeLabel,
|
|
181
|
-
title: currentPage?.title,
|
|
182
|
-
}),
|
|
183
|
-
);
|
|
278
|
+
if (!pending) finishNavigation(currentPage);
|
|
184
279
|
|
|
185
280
|
// Hooks
|
|
186
281
|
clientRouter.runHook('page.changed', (currentPage?.context.request || context.request) as ClientRequest);
|
|
282
|
+
if (!pending) markReady(currentPage, false);
|
|
187
283
|
}, [currentPage]);
|
|
188
284
|
|
|
285
|
+
// A deferred page settled ready
|
|
286
|
+
React.useEffect(() => {
|
|
287
|
+
if (readyCount > 0) markReady(currentPage, true);
|
|
288
|
+
}, [readyCount]);
|
|
289
|
+
|
|
189
290
|
/*----------------------------------
|
|
190
291
|
- RENDER
|
|
191
292
|
----------------------------------*/
|