@ecopages/react-router 0.2.0-alpha.23 → 0.2.0-alpha.25
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/CHANGELOG.md +2 -0
- package/package.json +3 -3
- package/src/head-morpher.js +2 -1
- package/src/hydration-assets.d.ts +12 -0
- package/src/hydration-assets.js +17 -0
- package/src/navigation.d.ts +25 -1
- package/src/navigation.js +28 -8
- package/src/router.d.ts +3 -1
- package/src/router.js +22 -16
- package/src/scroll-persist.js +15 -7
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ All notable changes to `@ecopages/react-router` are documented here.
|
|
|
8
8
|
|
|
9
9
|
### Bug Fixes
|
|
10
10
|
|
|
11
|
+
- Extended page-module extraction to honor explicit hydration markers and self-owned React page entry bundles during navigation.
|
|
12
|
+
- Fixed current-page reloads to accept HMR module overrides so persisted-layout refreshes import the rebuilt active page entry.
|
|
11
13
|
- Fixed React-to-browser-router handoffs, queued-click replay, and stale-navigation races during mixed-router navigations.
|
|
12
14
|
- Standardized route payload reads, document-owner markers, rerun scripts, and current-page HMR refreshes for persisted React layouts.
|
|
13
15
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/react-router",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.25",
|
|
4
4
|
"description": "Client-side SPA router for EcoPages React applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"directory": "packages/react-router"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@ecopages/core": "0.2.0-alpha.
|
|
36
|
-
"@ecopages/react": "0.2.0-alpha.
|
|
35
|
+
"@ecopages/core": "0.2.0-alpha.25",
|
|
36
|
+
"@ecopages/react": "0.2.0-alpha.25"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"react": "^19",
|
package/src/head-morpher.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isReactRouterPageBootstrapAssetSrc } from "./hydration-assets.js";
|
|
1
2
|
const PRESERVE_SELECTORS = ['script[type="importmap"]', "meta[charset]", "[data-eco-persist]"];
|
|
2
3
|
const RERUN_SRC_ATTR = "data-eco-rerun-src";
|
|
3
4
|
let rerunNonce = 0;
|
|
@@ -38,7 +39,7 @@ function isRerunScript(el) {
|
|
|
38
39
|
}
|
|
39
40
|
function isHydrationScript(el) {
|
|
40
41
|
const src = el.getAttribute("src");
|
|
41
|
-
return !!src &&
|
|
42
|
+
return !!src && isReactRouterPageBootstrapAssetSrc(src);
|
|
42
43
|
}
|
|
43
44
|
function getHeadElementKey(el) {
|
|
44
45
|
const tag = el.tagName.toLowerCase();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns whether a script URL belongs to a router-managed React page bootstrap asset.
|
|
3
|
+
*/
|
|
4
|
+
export declare function isReactRouterPageBootstrapAssetSrc(src: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Returns whether a script URL follows the legacy React hydration asset naming pattern.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isReactHydrationAssetSrc(src: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Returns whether a script URL should be treated as the page module source during router navigation.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isReactPageHydrationAssetSrc(src: string): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function isReactPageAssetName(src) {
|
|
2
|
+
return src.includes("ecopages-react-") && src.endsWith(".js");
|
|
3
|
+
}
|
|
4
|
+
function isReactRouterPageBootstrapAssetSrc(src) {
|
|
5
|
+
return isReactPageAssetName(src) && !src.includes("ecopages-react-island-");
|
|
6
|
+
}
|
|
7
|
+
function isReactHydrationAssetSrc(src) {
|
|
8
|
+
return isReactPageAssetName(src) && src.includes("hydration.js");
|
|
9
|
+
}
|
|
10
|
+
function isReactPageHydrationAssetSrc(src) {
|
|
11
|
+
return isReactRouterPageBootstrapAssetSrc(src);
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
isReactHydrationAssetSrc,
|
|
15
|
+
isReactPageHydrationAssetSrc,
|
|
16
|
+
isReactRouterPageBootstrapAssetSrc
|
|
17
|
+
};
|
package/src/navigation.d.ts
CHANGED
|
@@ -23,6 +23,16 @@ export type FetchedPageDocument = {
|
|
|
23
23
|
type LoadPageModuleOptions = {
|
|
24
24
|
signal?: AbortSignal;
|
|
25
25
|
};
|
|
26
|
+
type LoadPageModuleFromDocumentOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* Explicit page module URL to import instead of extracting one from the
|
|
29
|
+
* document's hydration assets.
|
|
30
|
+
*
|
|
31
|
+
* React Router uses this during HMR-driven reloads so the active hot module
|
|
32
|
+
* entry wins over any static bootstrap asset references embedded in the HTML.
|
|
33
|
+
*/
|
|
34
|
+
moduleUrlOverride?: string;
|
|
35
|
+
};
|
|
26
36
|
export type InterceptDecision = {
|
|
27
37
|
shouldIntercept: true;
|
|
28
38
|
} | {
|
|
@@ -69,7 +79,21 @@ export declare function extractComponentUrl(doc: Document): Promise<string | nul
|
|
|
69
79
|
*/
|
|
70
80
|
export declare function loadPageModule(url: string, options?: LoadPageModuleOptions): Promise<LoadedPageModule | null>;
|
|
71
81
|
export declare function fetchPageDocument(url: string, options?: LoadPageModuleOptions): Promise<FetchedPageDocument | null>;
|
|
72
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Loads the page module for a fetched or current document.
|
|
84
|
+
*
|
|
85
|
+
* The router normally extracts the page module URL from the document's
|
|
86
|
+
* hydration assets. Callers can provide `options.moduleUrlOverride` when the
|
|
87
|
+
* document is stale with respect to the active runtime module identity, such as
|
|
88
|
+
* during HMR-driven current-page reloads.
|
|
89
|
+
*
|
|
90
|
+
* @param doc - Parsed destination document.
|
|
91
|
+
* @param finalPath - Final route path after redirects.
|
|
92
|
+
* @param options - Module loading overrides.
|
|
93
|
+
* @returns Loaded page module payload or `null` when the document is not a
|
|
94
|
+
* React-router page or no page component can be resolved.
|
|
95
|
+
*/
|
|
96
|
+
export declare function loadPageModuleFromDocument(doc: Document, finalPath: string, options?: LoadPageModuleFromDocumentOptions): Promise<LoadedPageModule | null>;
|
|
73
97
|
/**
|
|
74
98
|
* Convenience wrapper around getInterceptDecision that returns a boolean.
|
|
75
99
|
* Use getInterceptDecision directly when you need the reason for debugging.
|
package/src/navigation.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { getEcoDocumentOwner } from "@ecopages/core/router/navigation-coordinator";
|
|
2
|
+
import { isReactPageHydrationAssetSrc } from "./hydration-assets.js";
|
|
2
3
|
const ROUTER_PROPS_SCRIPT_ID = "__ECO_PAGE_DATA__";
|
|
3
|
-
function isReactPageHydrationAsset(src) {
|
|
4
|
-
return src.includes("ecopages-react-") && src.includes("hydration.js") && !src.includes("ecopages-react-island-");
|
|
5
|
-
}
|
|
6
4
|
function getInterceptDecision(event, link, options) {
|
|
7
5
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
8
6
|
return { shouldIntercept: false, reason: "modified-click" };
|
|
@@ -28,7 +26,29 @@ function extractComponentUrlFromMarker(doc) {
|
|
|
28
26
|
}
|
|
29
27
|
const DEFAULT_IMPORT_REGEX = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/;
|
|
30
28
|
const NAMESPACE_IMPORT_REGEX = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]([^'"]+)['"]/;
|
|
31
|
-
|
|
29
|
+
const PAGE_MODULE_MARKER_REGEX = /module\s*:\s*['"]([^'"]+)['"]/;
|
|
30
|
+
const PAGE_MODULE_IDENTIFIER_REGEX = /module\s*:\s*([A-Za-z_$][\w$]*)\s*,/;
|
|
31
|
+
function extractModulePathFromCode(code, fallbackUrl) {
|
|
32
|
+
const markerMatch = code.match(PAGE_MODULE_MARKER_REGEX);
|
|
33
|
+
if (markerMatch) {
|
|
34
|
+
return markerMatch[1] ?? null;
|
|
35
|
+
}
|
|
36
|
+
const moduleIdentifier = code.match(PAGE_MODULE_IDENTIFIER_REGEX)?.[1];
|
|
37
|
+
if (moduleIdentifier) {
|
|
38
|
+
const assignmentRegex = new RegExp(
|
|
39
|
+
`(?:const|let|var)[^;]*\\b${moduleIdentifier}\\s*=\\s*(?:['"]([^'"]+)['"]|(import\\.meta\\.url))`
|
|
40
|
+
);
|
|
41
|
+
const assignmentMatch = code.match(assignmentRegex);
|
|
42
|
+
if (assignmentMatch?.[1]) {
|
|
43
|
+
return assignmentMatch[1];
|
|
44
|
+
}
|
|
45
|
+
if (fallbackUrl && assignmentMatch?.[2]) {
|
|
46
|
+
return fallbackUrl;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (fallbackUrl && code.includes("module:import.meta.url")) {
|
|
50
|
+
return fallbackUrl;
|
|
51
|
+
}
|
|
32
52
|
const defaultMatch = code.match(DEFAULT_IMPORT_REGEX);
|
|
33
53
|
const namespaceMatch = code.match(NAMESPACE_IMPORT_REGEX);
|
|
34
54
|
return (defaultMatch || namespaceMatch)?.[2] ?? null;
|
|
@@ -68,13 +88,13 @@ async function extractComponentUrl(doc) {
|
|
|
68
88
|
if (inlineHydrationScript?.textContent) {
|
|
69
89
|
return extractModulePathFromCode(inlineHydrationScript.textContent);
|
|
70
90
|
}
|
|
71
|
-
const hydrationScript = scripts.find((s) =>
|
|
91
|
+
const hydrationScript = scripts.find((s) => isReactPageHydrationAssetSrc(s.src ?? ""));
|
|
72
92
|
if (!hydrationScript?.src) return null;
|
|
73
93
|
try {
|
|
74
94
|
const scriptUrl = addCacheBuster(hydrationScript.src);
|
|
75
95
|
const res = await fetch(scriptUrl);
|
|
76
96
|
const code = await res.text();
|
|
77
|
-
return extractModulePathFromCode(code);
|
|
97
|
+
return extractModulePathFromCode(code, hydrationScript.src);
|
|
78
98
|
} catch {
|
|
79
99
|
return null;
|
|
80
100
|
}
|
|
@@ -107,9 +127,9 @@ async function fetchPageDocument(url, options = {}) {
|
|
|
107
127
|
return null;
|
|
108
128
|
}
|
|
109
129
|
}
|
|
110
|
-
async function loadPageModuleFromDocument(doc, finalPath) {
|
|
130
|
+
async function loadPageModuleFromDocument(doc, finalPath, options = {}) {
|
|
111
131
|
const props = extractProps(doc);
|
|
112
|
-
const componentUrl = await extractComponentUrl(doc);
|
|
132
|
+
const componentUrl = options.moduleUrlOverride ?? await extractComponentUrl(doc);
|
|
113
133
|
if (!componentUrl) {
|
|
114
134
|
if (isReactRouteDocument(doc)) {
|
|
115
135
|
console.error("[EcoRouter] Could not find component URL");
|
package/src/router.d.ts
CHANGED
|
@@ -31,7 +31,9 @@ export declare function clearLayoutCache(): void;
|
|
|
31
31
|
* Must be a child of {@link EcoRouter}. When `persistLayouts` is enabled,
|
|
32
32
|
* shared layouts remain mounted across navigations. When the server serialized
|
|
33
33
|
* request `locals` for hydration, the same `locals` object is passed to the
|
|
34
|
-
* layout on the client so the hydrated tree matches SSR.
|
|
34
|
+
* layout on the client so the hydrated tree matches SSR. Refresh-style updates
|
|
35
|
+
* may still replace the cached layout implementation when the router marks the
|
|
36
|
+
* page state with `refreshPersistedLayout`.
|
|
35
37
|
*
|
|
36
38
|
* @example
|
|
37
39
|
* ```tsx
|
package/src/router.js
CHANGED
|
@@ -64,7 +64,7 @@ const PageContent = () => {
|
|
|
64
64
|
}
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
|
-
const { Component: Page, props } = pageContext;
|
|
67
|
+
const { Component: Page, props, refreshPersistedLayout } = pageContext;
|
|
68
68
|
const Layout = getLayoutFromPage(Page);
|
|
69
69
|
const pageElement = createElement(Page, props);
|
|
70
70
|
const layoutProps = props?.locals ? { locals: props.locals } : null;
|
|
@@ -76,7 +76,7 @@ const PageContent = () => {
|
|
|
76
76
|
const layoutConfig = Layout.config;
|
|
77
77
|
const layoutKeyRaw = layoutConfig?.__eco?.id || Layout.displayName || Layout.name || "layout";
|
|
78
78
|
const layoutKey = normalizeLayoutKey(layoutKeyRaw);
|
|
79
|
-
if (!layoutCache.has(layoutKey)) {
|
|
79
|
+
if (!layoutCache.has(layoutKey) || refreshPersistedLayout && layoutCache.get(layoutKey) !== Layout) {
|
|
80
80
|
layoutCache.set(layoutKey, Layout);
|
|
81
81
|
}
|
|
82
82
|
const CachedLayout = layoutCache.get(layoutKey);
|
|
@@ -114,7 +114,7 @@ function useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef
|
|
|
114
114
|
clearLayoutCache();
|
|
115
115
|
}
|
|
116
116
|
const currentUrl = window.location.pathname + window.location.search;
|
|
117
|
-
await navigate(currentUrl);
|
|
117
|
+
await navigate(currentUrl, { moduleUrlOverride: request?.moduleUrl });
|
|
118
118
|
},
|
|
119
119
|
cleanupBeforeHandoff: async () => {
|
|
120
120
|
runtimeActiveRef.current = false;
|
|
@@ -136,7 +136,11 @@ function useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef
|
|
|
136
136
|
}
|
|
137
137
|
const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
138
138
|
const options = useMemo(() => ({ ...DEFAULT_OPTIONS, ...userOptions }), [userOptions]);
|
|
139
|
-
const [currentPage, setCurrentPage] = useState({
|
|
139
|
+
const [currentPage, setCurrentPage] = useState({
|
|
140
|
+
Component: page,
|
|
141
|
+
props: pageProps,
|
|
142
|
+
refreshPersistedLayout: false
|
|
143
|
+
});
|
|
140
144
|
const [isNavigating, setIsNavigating] = useState(false);
|
|
141
145
|
const pendingRenderRef = useRef(null);
|
|
142
146
|
const activeNavigationRef = useRef(null);
|
|
@@ -145,13 +149,12 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
|
145
149
|
const pendingPointerNavigationRef = useRef(null);
|
|
146
150
|
const pendingHoverNavigationRef = useRef(null);
|
|
147
151
|
const queuedNavigationHrefRef = useRef(null);
|
|
148
|
-
const pendingScrollRestoreRef = useRef(null);
|
|
149
152
|
const previousUrlRef = useRef(typeof window !== "undefined" ? window.location.href : "");
|
|
150
153
|
useEffect(() => {
|
|
151
154
|
isNavigatingRef.current = isNavigating;
|
|
152
155
|
}, [isNavigating]);
|
|
153
156
|
useEffect(() => {
|
|
154
|
-
setCurrentPage({ Component: page, props: pageProps });
|
|
157
|
+
setCurrentPage({ Component: page, props: pageProps, refreshPersistedLayout: true });
|
|
155
158
|
}, [page, pageProps]);
|
|
156
159
|
useEffect(() => {
|
|
157
160
|
applyViewTransitionNames();
|
|
@@ -182,15 +185,15 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
|
182
185
|
});
|
|
183
186
|
previousUrlRef.current = url.href;
|
|
184
187
|
}
|
|
185
|
-
if (pendingScrollRestoreRef.current) {
|
|
186
|
-
const { url: targetUrl, isPopState } = pendingScrollRestoreRef.current;
|
|
187
|
-
restoreScrollPositions(targetUrl, isPopState);
|
|
188
|
-
pendingScrollRestoreRef.current = null;
|
|
189
|
-
}
|
|
190
188
|
}, [currentPage, options.scrollBehavior, options.smoothScroll]);
|
|
191
189
|
const navigate = useCallback(
|
|
192
190
|
async (url, navigationOptions = {}) => {
|
|
193
|
-
const {
|
|
191
|
+
const {
|
|
192
|
+
isPopState = false,
|
|
193
|
+
pushHistory = false,
|
|
194
|
+
skipViewTransition = false,
|
|
195
|
+
moduleUrlOverride
|
|
196
|
+
} = navigationOptions;
|
|
194
197
|
const navigationRuntime = getEcoNavigationRuntime(window);
|
|
195
198
|
const navigation = navigationRuntime.beginNavigationTransaction();
|
|
196
199
|
activeNavigationRef.current = navigation;
|
|
@@ -212,24 +215,25 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
|
212
215
|
window.location.href = url;
|
|
213
216
|
return;
|
|
214
217
|
}
|
|
215
|
-
const result = await loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath
|
|
218
|
+
const result = await loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath, {
|
|
219
|
+
moduleUrlOverride
|
|
220
|
+
});
|
|
216
221
|
if (isStale()) return;
|
|
217
222
|
if (result) {
|
|
218
223
|
const { Component, props, doc, finalPath, moduleUrl } = result;
|
|
219
|
-
const nextPage = { Component, props };
|
|
224
|
+
const nextPage = { Component, props, refreshPersistedLayout: Boolean(moduleUrlOverride) };
|
|
220
225
|
const { cleanup: cleanupHead, flushRerunScripts } = await morphHead(doc);
|
|
221
226
|
if (isStale()) {
|
|
222
227
|
cleanupHead();
|
|
223
228
|
return;
|
|
224
229
|
}
|
|
225
230
|
applyViewTransitionNames();
|
|
231
|
+
saveScrollPositions();
|
|
226
232
|
if (pushHistory) {
|
|
227
233
|
window.history.pushState(null, "", finalPath);
|
|
228
234
|
} else if (finalPath !== url) {
|
|
229
235
|
window.history.replaceState(null, "", finalPath);
|
|
230
236
|
}
|
|
231
|
-
saveScrollPositions();
|
|
232
|
-
pendingScrollRestoreRef.current = { url, isPopState };
|
|
233
237
|
if (!skipViewTransition && options.viewTransitions && document.startViewTransition) {
|
|
234
238
|
pendingRenderRef.current?.resolve();
|
|
235
239
|
const renderDfd = createDeferred();
|
|
@@ -260,6 +264,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
|
260
264
|
flushRerunScripts();
|
|
261
265
|
cleanupHead();
|
|
262
266
|
applyViewTransitionNames();
|
|
267
|
+
restoreScrollPositions(finalPath, isPopState);
|
|
263
268
|
} finally {
|
|
264
269
|
resolve();
|
|
265
270
|
}
|
|
@@ -284,6 +289,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
|
|
|
284
289
|
flushRerunScripts();
|
|
285
290
|
cleanupHead();
|
|
286
291
|
applyViewTransitionNames();
|
|
292
|
+
restoreScrollPositions(finalPath, isPopState);
|
|
287
293
|
}
|
|
288
294
|
} else {
|
|
289
295
|
if (isStale()) return;
|
package/src/scroll-persist.js
CHANGED
|
@@ -28,19 +28,27 @@ function restoreScrollPositions(targetUrl, isPopState) {
|
|
|
28
28
|
currentScrollSnapshot = null;
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
const restore = (remainingAttempts) => {
|
|
32
32
|
requestAnimationFrame(() => {
|
|
33
|
+
const restoredKeys = /* @__PURE__ */ new Set();
|
|
33
34
|
document.querySelectorAll(PERSIST_SELECTOR).forEach((el) => {
|
|
34
35
|
const key = getElementKey(el);
|
|
35
|
-
if (key
|
|
36
|
-
|
|
37
|
-
el.scrollTop = pos.top;
|
|
38
|
-
el.scrollLeft = pos.left;
|
|
36
|
+
if (!key || !positions.has(key)) {
|
|
37
|
+
return;
|
|
39
38
|
}
|
|
39
|
+
const pos = positions.get(key);
|
|
40
|
+
el.scrollTop = pos.top;
|
|
41
|
+
el.scrollLeft = pos.left;
|
|
42
|
+
restoredKeys.add(key);
|
|
40
43
|
});
|
|
41
|
-
|
|
44
|
+
if (restoredKeys.size === positions.size || remainingAttempts <= 1) {
|
|
45
|
+
currentScrollSnapshot = null;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
setTimeout(() => restore(remainingAttempts - 1), 50);
|
|
42
49
|
});
|
|
43
|
-
}
|
|
50
|
+
};
|
|
51
|
+
restore(20);
|
|
44
52
|
}
|
|
45
53
|
function getScrollPositions(url) {
|
|
46
54
|
return urlScrollStore.get(url);
|