proteum 2.5.25 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/agents/project/AGENTS.md +2 -0
- package/agents/project/client/pages/AGENTS.md +22 -1
- package/client/services/router/components/Link.tsx +25 -0
- package/client/services/router/components/Page.tsx +19 -3
- package/client/services/router/components/router.tsx +165 -64
- package/client/services/router/index.tsx +122 -31
- package/client/services/router/navigation.ts +235 -0
- package/client/services/router/request/api.ts +123 -75
- package/client/services/router/response/index.tsx +5 -0
- package/client/services/router/response/page.ts +20 -4
- package/common/router/contracts.ts +2 -2
- package/common/router/definitions.ts +28 -10
- package/common/router/index.ts +2 -0
- package/common/router/pageData.ts +20 -2
- package/common/router/response/page.ts +73 -7
- package/docs/client-navigation.md +128 -0
- package/package.json +1 -1
- package/server/services/router/response/page/index.tsx +2 -2
- package/tests/client-api-fetch-sync.test.cjs +180 -0
- package/tests/client-router-hooks.test.cjs +73 -0
- package/tests/client-router-navigation.test.cjs +857 -0
- package/tests/client-router-prefetch.test.cjs +197 -0
- package/tests/clientRouterHarness.cjs +70 -0
- package/tests/definition-contracts.test.cjs +45 -1
- package/tests/fakeDom.cjs +90 -0
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) |
|
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.
|
|
@@ -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
|
----------------------------------*/
|