proteum 2.5.24 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -275,6 +275,7 @@ my-app/
275
275
 
276
276
  | Topic | Guide |
277
277
  | --- | --- |
278
+ | Client navigation (blocking, deferred, prefetch) | [docs/client-navigation.md](docs/client-navigation.md) |
278
279
  | Diagnostics & explainability | [docs/diagnostics.md](docs/diagnostics.md) |
279
280
  | Model Context Protocol (MCP) | [docs/mcp.md](docs/mcp.md) |
280
281
  | Request tracing & perf | [docs/request-tracing.md](docs/request-tracing.md) |
@@ -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.
@@ -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
  ----------------------------------*/