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.
Files changed (44) hide show
  1. package/README.md +4 -0
  2. package/agents/project/AGENTS.md +2 -0
  3. package/agents/project/client/pages/AGENTS.md +22 -1
  4. package/cli/compiler/client/index.ts +10 -5
  5. package/cli/compiler/client/outputNames.ts +28 -0
  6. package/cli/compiler/common/files/autres.ts +3 -1
  7. package/cli/scaffold/templates.ts +4 -0
  8. package/client/app/staleBuild.ts +80 -0
  9. package/client/services/router/components/Link.tsx +25 -0
  10. package/client/services/router/components/Page.tsx +19 -3
  11. package/client/services/router/components/router.tsx +165 -64
  12. package/client/services/router/index.tsx +132 -31
  13. package/client/services/router/navigation.ts +235 -0
  14. package/client/services/router/request/api.ts +123 -75
  15. package/client/services/router/response/index.tsx +5 -0
  16. package/client/services/router/response/page.ts +20 -4
  17. package/common/router/contracts.ts +2 -2
  18. package/common/router/definitions.ts +28 -10
  19. package/common/router/index.ts +2 -0
  20. package/common/router/pageData.ts +20 -2
  21. package/common/router/response/page.ts +73 -7
  22. package/docs/HISTORY.md +8 -0
  23. package/docs/client-assets.md +37 -0
  24. package/docs/client-navigation.md +128 -0
  25. package/docs/http-security-headers.md +114 -0
  26. package/package.json +2 -1
  27. package/server/services/router/http/cache.ts +4 -1
  28. package/server/services/router/http/index.ts +26 -25
  29. package/server/services/router/http/securityHeaders.ts +343 -0
  30. package/server/services/router/response/page/index.tsx +2 -2
  31. package/tests/app-shaped-typecheck.test.cjs +74 -0
  32. package/tests/client-api-fetch-sync.test.cjs +180 -0
  33. package/tests/client-output-names.test.cjs +114 -0
  34. package/tests/client-router-hooks.test.cjs +73 -0
  35. package/tests/client-router-navigation.test.cjs +857 -0
  36. package/tests/client-router-prefetch.test.cjs +197 -0
  37. package/tests/clientRouterHarness.cjs +70 -0
  38. package/tests/definition-contracts.test.cjs +45 -1
  39. package/tests/fakeDom.cjs +90 -0
  40. package/tests/http-server-security-headers.test.cjs +669 -0
  41. package/tests/router-cache-config.test.cjs +32 -0
  42. package/tests/stale-build-recovery.test.cjs +206 -0
  43. package/types/global/vendors.d.ts +9 -0
  44. 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
 
@@ -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: '[name].js', // Output client.js
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: '[name].css',
189
- cssChunkFilename: '[name].css',
190
- chunkFilename: dev ? '[name].js' : '[id].[contenthash:8].js',
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
- new rspack.CssExtractRspackPlugin({}),
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
- generator: { filename: 'fonts/[name].[ext]' },
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
- const rendererProps = { ...context, ...fullData } as Parameters<NonNullable<typeof page.renderer>>[0];
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 page.renderer ? <page.renderer {...rendererProps} /> : <>Renderer missing</>;
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
- const scrollToElement = (selector: string) =>
39
- document.querySelector(selector)?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
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
- scrollToElement(request.hash);
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
- // Unable to load (no connection, server error, ....)
92
- if (newpage === null) return;
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
- async function changePage(newpage: Page, data?: {}, request?: ClientRequest, sessionId?: string) {
98
- // Fetch API data to hydrate the page
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
- currentPage?.scrollToId && scrollToElement(currentPage.scrollToId.substring(1));
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
- const routeLabel =
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
  ----------------------------------*/