@ecopages/react-router 0.2.0-alpha.5 → 0.2.0-alpha.50
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 +45 -119
- package/package.json +5 -4
- package/src/adapter.js +1 -2
- package/src/head-morpher.d.ts +6 -2
- package/src/head-morpher.js +125 -10
- package/src/hydration-assets.d.ts +12 -0
- package/src/hydration-assets.js +17 -0
- package/src/navigation.d.ts +47 -9
- package/src/navigation.js +94 -35
- package/src/props-script.d.ts +1 -1
- package/src/router.d.ts +5 -1
- package/src/router.js +373 -90
- package/src/scroll-persist.js +15 -7
- package/CHANGELOG.md +0 -12
- package/browser.ts +0 -17
- package/src/adapter.ts +0 -48
- package/src/context.ts +0 -25
- package/src/head-morpher.ts +0 -170
- package/src/index.ts +0 -21
- package/src/manage-scroll.ts +0 -47
- package/src/navigation.ts +0 -247
- package/src/props-script.ts +0 -19
- package/src/router.ts +0 -348
- package/src/scroll-persist.ts +0 -96
- package/src/types.ts +0 -64
- package/src/view-transition-manager.ts +0 -30
- package/src/view-transition-utils.ts +0 -95
package/src/navigation.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { getEcoDocumentOwner } from "@ecopages/core/router/navigation-coordinator";
|
|
2
|
+
import { isReactPageHydrationAssetSrc } from "./hydration-assets.js";
|
|
3
|
+
const ROUTER_PROPS_SCRIPT_ID = "__ECO_PAGE_DATA__";
|
|
4
|
+
function isSamePageHashNavigationHref(href) {
|
|
5
|
+
if (!href) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const currentUrl = new URL(window.location.href);
|
|
9
|
+
const targetUrl = new URL(href, currentUrl);
|
|
10
|
+
return targetUrl.origin === currentUrl.origin && targetUrl.hash.length > 0 && targetUrl.pathname === currentUrl.pathname && targetUrl.search === currentUrl.search;
|
|
11
|
+
}
|
|
1
12
|
function getInterceptDecision(event, link, options) {
|
|
2
13
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
3
14
|
return { shouldIntercept: false, reason: "modified-click" };
|
|
@@ -13,26 +24,52 @@ function getInterceptDecision(event, link, options) {
|
|
|
13
24
|
}
|
|
14
25
|
const url = new URL(href, window.location.origin);
|
|
15
26
|
if (url.origin !== window.location.origin) return { shouldIntercept: false, reason: "cross-origin" };
|
|
27
|
+
if (isSamePageHashNavigationHref(href)) {
|
|
28
|
+
return { shouldIntercept: false, reason: "same-page-hash" };
|
|
29
|
+
}
|
|
16
30
|
return { shouldIntercept: true };
|
|
17
31
|
}
|
|
18
32
|
function extractComponentUrlFromMarker(doc) {
|
|
19
|
-
if (doc === document && window.
|
|
20
|
-
return window.
|
|
33
|
+
if (doc === document && window.__ECO_PAGES__?.page?.module) {
|
|
34
|
+
return window.__ECO_PAGES__.page.module;
|
|
21
35
|
}
|
|
22
36
|
return null;
|
|
23
37
|
}
|
|
24
38
|
const DEFAULT_IMPORT_REGEX = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/;
|
|
25
39
|
const NAMESPACE_IMPORT_REGEX = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]([^'"]+)['"]/;
|
|
26
|
-
|
|
40
|
+
const PAGE_MODULE_MARKER_REGEX = /__ECO_PAGES__\.page\s*=\s*\{\s*module\s*:\s*['"]([^'"]+)['"]/;
|
|
41
|
+
const PAGE_MODULE_IDENTIFIER_REGEX = /__ECO_PAGES__\.page\s*=\s*\{\s*module\s*:\s*([A-Za-z_$][\w$]*)\s*,/;
|
|
42
|
+
const PAGE_BOOTSTRAP_SELECTOR = 'script[data-eco-page-bootstrap="react-router"]';
|
|
43
|
+
function extractModulePathFromCode(code, fallbackUrl) {
|
|
44
|
+
const markerMatch = code.match(PAGE_MODULE_MARKER_REGEX);
|
|
45
|
+
if (markerMatch) {
|
|
46
|
+
return markerMatch[1] ?? null;
|
|
47
|
+
}
|
|
48
|
+
const moduleIdentifier = code.match(PAGE_MODULE_IDENTIFIER_REGEX)?.[1];
|
|
49
|
+
if (moduleIdentifier) {
|
|
50
|
+
const assignmentRegex = new RegExp(
|
|
51
|
+
`(?:const|let|var)[^;]*\\b${moduleIdentifier}\\s*=\\s*(?:['"]([^'"]+)['"]|(import\\.meta\\.url))`
|
|
52
|
+
);
|
|
53
|
+
const assignmentMatch = code.match(assignmentRegex);
|
|
54
|
+
if (assignmentMatch?.[1]) {
|
|
55
|
+
return assignmentMatch[1];
|
|
56
|
+
}
|
|
57
|
+
if (fallbackUrl && assignmentMatch?.[2]) {
|
|
58
|
+
return fallbackUrl;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (fallbackUrl && code.includes("module:import.meta.url")) {
|
|
62
|
+
return fallbackUrl;
|
|
63
|
+
}
|
|
27
64
|
const defaultMatch = code.match(DEFAULT_IMPORT_REGEX);
|
|
28
65
|
const namespaceMatch = code.match(NAMESPACE_IMPORT_REGEX);
|
|
29
66
|
return (defaultMatch || namespaceMatch)?.[2] ?? null;
|
|
30
67
|
}
|
|
31
68
|
function extractProps(doc) {
|
|
32
|
-
if (doc === document && window.
|
|
33
|
-
return window.
|
|
69
|
+
if (doc === document && window.__ECO_PAGES__?.page?.props) {
|
|
70
|
+
return window.__ECO_PAGES__.page.props;
|
|
34
71
|
}
|
|
35
|
-
const propsScript = doc.getElementById(
|
|
72
|
+
const propsScript = doc.getElementById(ROUTER_PROPS_SCRIPT_ID);
|
|
36
73
|
if (propsScript?.textContent) {
|
|
37
74
|
try {
|
|
38
75
|
return JSON.parse(propsScript.textContent);
|
|
@@ -43,6 +80,9 @@ function extractProps(doc) {
|
|
|
43
80
|
}
|
|
44
81
|
return {};
|
|
45
82
|
}
|
|
83
|
+
function isReactRouteDocument(doc) {
|
|
84
|
+
return getEcoDocumentOwner(doc) === "react-router";
|
|
85
|
+
}
|
|
46
86
|
function addCacheBuster(url) {
|
|
47
87
|
if (import.meta.env?.MODE === "production" || import.meta.env?.PROD) {
|
|
48
88
|
return url;
|
|
@@ -55,66 +95,85 @@ async function extractComponentUrl(doc) {
|
|
|
55
95
|
if (markerUrl) return markerUrl;
|
|
56
96
|
const scripts = Array.from(doc.querySelectorAll("script"));
|
|
57
97
|
const inlineHydrationScript = scripts.find(
|
|
58
|
-
(s) => !s.src && !!s.textContent && s.textContent.includes("
|
|
98
|
+
(s) => !s.src && !!s.textContent && s.textContent.includes("__ECO_PAGES__") && s.textContent.includes("hydrateRoot") && s.textContent.includes("import")
|
|
59
99
|
);
|
|
60
100
|
if (inlineHydrationScript?.textContent) {
|
|
61
101
|
return extractModulePathFromCode(inlineHydrationScript.textContent);
|
|
62
102
|
}
|
|
63
|
-
const hydrationScript = scripts.find((s) => s.src
|
|
103
|
+
const hydrationScript = doc.querySelector(PAGE_BOOTSTRAP_SELECTOR) ?? scripts.find((s) => isReactPageHydrationAssetSrc(s.src ?? ""));
|
|
64
104
|
if (!hydrationScript?.src) return null;
|
|
65
105
|
try {
|
|
66
106
|
const scriptUrl = addCacheBuster(hydrationScript.src);
|
|
67
107
|
const res = await fetch(scriptUrl);
|
|
68
108
|
const code = await res.text();
|
|
69
|
-
return extractModulePathFromCode(code);
|
|
109
|
+
return extractModulePathFromCode(code, hydrationScript.src);
|
|
70
110
|
} catch {
|
|
71
111
|
return null;
|
|
72
112
|
}
|
|
73
113
|
}
|
|
74
|
-
async function loadPageModule(url) {
|
|
114
|
+
async function loadPageModule(url, options = {}) {
|
|
115
|
+
const fetchedPage = await fetchPageDocument(url, options);
|
|
116
|
+
if (!fetchedPage) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
|
|
120
|
+
}
|
|
121
|
+
async function fetchPageDocument(url, options = {}) {
|
|
75
122
|
try {
|
|
76
|
-
const res = await fetch(url
|
|
123
|
+
const res = await fetch(url, {
|
|
124
|
+
signal: options.signal,
|
|
125
|
+
headers: {
|
|
126
|
+
Accept: "text/html"
|
|
127
|
+
}
|
|
128
|
+
});
|
|
77
129
|
const html = await res.text();
|
|
78
130
|
const finalUrl = new URL(res.url || url, window.location.origin);
|
|
79
131
|
const finalPath = finalUrl.pathname + finalUrl.search;
|
|
80
132
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
console.error("[EcoRouter] Could not find component URL");
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
const moduleUrl = addCacheBuster(componentUrl);
|
|
88
|
-
const module = await import(
|
|
89
|
-
/* @vite-ignore */
|
|
90
|
-
moduleUrl
|
|
91
|
-
);
|
|
92
|
-
const rawComponent = module.Content || module.default?.Content || module.default;
|
|
93
|
-
const config = module.config || rawComponent?.config;
|
|
94
|
-
if (!rawComponent) {
|
|
95
|
-
console.error("[EcoRouter] No component found in module");
|
|
133
|
+
return { doc, finalPath, html };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (e instanceof DOMException && e.name === "AbortError") {
|
|
96
136
|
return null;
|
|
97
137
|
}
|
|
98
|
-
if (config && !rawComponent.config) {
|
|
99
|
-
rawComponent.config = config;
|
|
100
|
-
}
|
|
101
|
-
window.__ECO_PAGE__ = {
|
|
102
|
-
module: componentUrl,
|
|
103
|
-
props
|
|
104
|
-
};
|
|
105
|
-
return { Component: rawComponent, props, doc, finalPath };
|
|
106
|
-
} catch (e) {
|
|
107
138
|
console.error("[EcoRouter] Navigation failed:", e);
|
|
108
139
|
return null;
|
|
109
140
|
}
|
|
110
141
|
}
|
|
142
|
+
async function loadPageModuleFromDocument(doc, finalPath, options = {}) {
|
|
143
|
+
const props = extractProps(doc);
|
|
144
|
+
const componentUrl = options.moduleUrlOverride ?? await extractComponentUrl(doc);
|
|
145
|
+
if (!componentUrl) {
|
|
146
|
+
if (isReactRouteDocument(doc)) {
|
|
147
|
+
console.error("[EcoRouter] Could not find component URL");
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const moduleUrl = addCacheBuster(componentUrl);
|
|
152
|
+
const module = await import(
|
|
153
|
+
/* @vite-ignore */
|
|
154
|
+
moduleUrl
|
|
155
|
+
);
|
|
156
|
+
const rawComponent = module.Content || module.default?.Content || module.default;
|
|
157
|
+
const config = module.config || rawComponent?.config;
|
|
158
|
+
if (!rawComponent) {
|
|
159
|
+
console.error("[EcoRouter] No component found in module");
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (config && !rawComponent.config) {
|
|
163
|
+
rawComponent.config = config;
|
|
164
|
+
}
|
|
165
|
+
return { Component: rawComponent, props, doc, finalPath, moduleUrl: componentUrl };
|
|
166
|
+
}
|
|
111
167
|
function shouldInterceptClick(event, link, options) {
|
|
112
168
|
return getInterceptDecision(event, link, options).shouldIntercept;
|
|
113
169
|
}
|
|
114
170
|
export {
|
|
115
171
|
extractComponentUrl,
|
|
116
172
|
extractProps,
|
|
173
|
+
fetchPageDocument,
|
|
117
174
|
getInterceptDecision,
|
|
175
|
+
isSamePageHashNavigationHref,
|
|
118
176
|
loadPageModule,
|
|
177
|
+
loadPageModuleFromDocument,
|
|
119
178
|
shouldInterceptClick
|
|
120
179
|
};
|
package/src/props-script.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface EcoPropsScriptProps {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* Serializes page props as JSON for SPA navigation.
|
|
8
|
-
* The hydration script reads this and sets window.
|
|
8
|
+
* The hydration script reads this and sets window.__ECO_PAGES__.page.
|
|
9
9
|
* Using application/json allows direct parsing without regex.
|
|
10
10
|
*/
|
|
11
11
|
export declare const EcoPropsScript: FC<EcoPropsScriptProps>;
|
package/src/router.d.ts
CHANGED
|
@@ -29,7 +29,11 @@ export declare function clearLayoutCache(): void;
|
|
|
29
29
|
* Renders the current page with its layout.
|
|
30
30
|
*
|
|
31
31
|
* Must be a child of {@link EcoRouter}. When `persistLayouts` is enabled,
|
|
32
|
-
* shared layouts remain mounted across navigations.
|
|
32
|
+
* shared layouts remain mounted across navigations. When the server serialized
|
|
33
|
+
* request `locals` for hydration, the same `locals` object is passed to the
|
|
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`.
|
|
33
37
|
*
|
|
34
38
|
* @example
|
|
35
39
|
* ```tsx
|