@ecopages/react-router 0.2.0-alpha.2 → 0.2.0-alpha.20
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 +7 -2
- package/README.md +45 -119
- package/package.json +3 -2
- package/src/head-morpher.d.ts +6 -2
- package/src/head-morpher.js +104 -8
- package/src/navigation.d.ts +21 -8
- package/src/navigation.js +59 -33
- package/src/props-script.d.ts +1 -1
- package/src/router.d.ts +3 -1
- package/src/router.js +309 -59
- 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/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,12 @@ All notable changes to `@ecopages/react-router` are documented here.
|
|
|
6
6
|
|
|
7
7
|
## [UNRELEASED] — TBD
|
|
8
8
|
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- Fixed React-to-browser-router handoffs, queued-click replay, and stale-navigation races during mixed-router navigations.
|
|
12
|
+
- Standardized route payload reads, document-owner markers, rerun scripts, and current-page HMR refreshes for persisted React layouts.
|
|
13
|
+
|
|
9
14
|
### Refactoring
|
|
10
15
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
16
|
+
- Routed browser handoff and current-page reloads through the shared navigation coordinator.
|
|
17
|
+
- Updated package metadata for the current core, esbuild adapter, and React peer dependency surface.
|
package/README.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# @ecopages/react-router
|
|
2
2
|
|
|
3
|
-
Client-side SPA router for
|
|
3
|
+
Client-side SPA router for Ecopages React applications. Features single-page application navigation while preserving all the benefits of Server-Side Rendering (SSR).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **SSR preserved**: Initial loads are fully server-rendered.
|
|
8
|
+
- **Opt-in via config**: A single line in your config enables SPA navigation across all pages.
|
|
9
|
+
- **Layout persistence**: Shared layouts stay mounted while page content swaps.
|
|
10
|
+
- **Standard links**: Works with regular `<a>` tags.
|
|
11
|
+
- **Head sync**: Automatically updates document metadata `<head>` during navigation.
|
|
12
|
+
- **View Transitions**: Built-in support for the browser View Transitions API.
|
|
13
|
+
|
|
14
|
+
## Cross-Runtime Handoff
|
|
15
|
+
|
|
16
|
+
`@ecopages/react-router` only performs SPA updates for React-managed documents. When a navigation resolves to a non-React document, it will:
|
|
17
|
+
|
|
18
|
+
- hand the already-fetched HTML document to `@ecopages/browser-router` when browser-router is registered on the page
|
|
19
|
+
- fall back to a normal document navigation when browser-router is not present
|
|
20
|
+
|
|
21
|
+
This keeps React-router focused on React rendering while still allowing mixed React and non-React pages to transition without a second fetch when browser-router is active.
|
|
4
22
|
|
|
5
23
|
## Installation
|
|
6
24
|
|
|
@@ -10,37 +28,30 @@ bun add @ecopages/react-router
|
|
|
10
28
|
|
|
11
29
|
## Quick Start
|
|
12
30
|
|
|
13
|
-
|
|
31
|
+
Pass the router adapter to the React plugin in your `eco.config.ts`:
|
|
14
32
|
|
|
15
33
|
```typescript
|
|
16
|
-
import { ConfigBuilder } from '@ecopages/core';
|
|
34
|
+
import { ConfigBuilder } from '@ecopages/core/config-builder';
|
|
17
35
|
import { reactPlugin } from '@ecopages/react';
|
|
18
36
|
import { ecoRouter } from '@ecopages/react-router';
|
|
19
37
|
|
|
20
38
|
const config = await new ConfigBuilder()
|
|
21
|
-
.setRootDir(import.meta.
|
|
39
|
+
.setRootDir(import.meta.dirname)
|
|
22
40
|
.setIntegrations([reactPlugin({ router: ecoRouter() })])
|
|
23
41
|
.build();
|
|
24
42
|
|
|
25
43
|
export default config;
|
|
26
44
|
```
|
|
27
45
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
## Features
|
|
46
|
+
SPA navigation is now enabled for all React pages in your project.
|
|
31
47
|
|
|
32
|
-
-
|
|
33
|
-
- **SSR preserved** - Full server-side rendering on initial load
|
|
34
|
-
- **Layout persistence** - Layouts stay mounted, only page content swaps
|
|
35
|
-
- **Standard links** - Works with regular `<a>` tags
|
|
36
|
-
- **Head sync** - Automatically updates title, meta, and stylesheets
|
|
37
|
-
- **Pluggable** - Extensible adapter pattern
|
|
48
|
+
If your site mixes React pages with non-React pages, you can also run `@ecopages/browser-router` on the non-React shell. React-router will hand off non-React navigations to browser-router when it is available.
|
|
38
49
|
|
|
39
50
|
## Usage
|
|
40
51
|
|
|
41
52
|
### Layouts (Optional)
|
|
42
53
|
|
|
43
|
-
|
|
54
|
+
Configure your page with a layout to keep UI components (like headers/navs) mounted across navigations:
|
|
44
55
|
|
|
45
56
|
```tsx
|
|
46
57
|
// src/layouts/base-layout.tsx
|
|
@@ -65,6 +76,8 @@ export default HomePage;
|
|
|
65
76
|
|
|
66
77
|
### Links
|
|
67
78
|
|
|
79
|
+
Standard relative links are intercepted natively. To bypass the router and force a hard reload, use the `data-eco-reload` attribute.
|
|
80
|
+
|
|
68
81
|
```tsx
|
|
69
82
|
// SPA navigation (intercepted)
|
|
70
83
|
<a href="/about">About</a>
|
|
@@ -89,39 +102,21 @@ const MyComponent = () => {
|
|
|
89
102
|
};
|
|
90
103
|
```
|
|
91
104
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
The router automatically supports the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for smooth page transitions.
|
|
105
|
+
## View Transitions
|
|
95
106
|
|
|
96
|
-
|
|
107
|
+
The router automatically integrates with the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API).
|
|
97
108
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
1. **Snapshot**: The browser captures the current state (screenshot) of the page.
|
|
101
|
-
2. **Update**: React processes the state change and renders the new page.
|
|
102
|
-
3. **Animate**: The browser animates from the old snapshot to the new live state.
|
|
103
|
-
|
|
104
|
-
The router uses a deferred promise mechanism to ensure React has fully finished rendering the new content before telling the browser to start the animation phase.
|
|
105
|
-
|
|
106
|
-
#### Shared Element Transitions
|
|
107
|
-
|
|
108
|
-
To animate elements between pages (e.g., a thumbnail becoming a hero image), use the `data-view-transition` attribute. Ensure the value is unique to the specific element being transitioned and matches on both pages.
|
|
109
|
+
To animate elements between pages using Shared Element Transitions, mark them with a unique `data-view-transition` id that matches across both pages:
|
|
109
110
|
|
|
110
111
|
```tsx
|
|
111
|
-
// List Page
|
|
112
|
-
<img
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
/>
|
|
116
|
-
|
|
117
|
-
// Detail Page (Destination)
|
|
118
|
-
<img
|
|
119
|
-
src={post.image}
|
|
120
|
-
data-view-transition={`hero-${post.id}`}
|
|
121
|
-
/>
|
|
112
|
+
// List Page
|
|
113
|
+
<img src={post.image} data-view-transition={`hero-${post.id}`} />
|
|
114
|
+
|
|
115
|
+
// Detail Page
|
|
116
|
+
<img src={post.image} data-view-transition={`hero-${post.id}`} />
|
|
122
117
|
```
|
|
123
118
|
|
|
124
|
-
By default,
|
|
119
|
+
By default, we impose a "clean morph", disabling default cross-fade ghosting. To use standard crossfades on elements, opt-out:
|
|
125
120
|
|
|
126
121
|
```tsx
|
|
127
122
|
<div data-view-transition="my-hero" data-view-transition-animate="fade">
|
|
@@ -129,84 +124,15 @@ By default, the router applies a **clean morph** animation (disabling the defaul
|
|
|
129
124
|
</div>
|
|
130
125
|
```
|
|
131
126
|
|
|
132
|
-
#### Cross-Fade
|
|
133
|
-
|
|
134
|
-
By default, the router provides a smooth cross-fade for the root content. You can customize this by overriding the default view transition CSS:
|
|
135
|
-
|
|
136
|
-
```css
|
|
137
|
-
::view-transition-old(root),
|
|
138
|
-
::view-transition-new(root) {
|
|
139
|
-
animation-duration: 0.5s;
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
127
|
## How It Works
|
|
144
128
|
|
|
145
|
-
The router
|
|
146
|
-
|
|
147
|
-
1. **SSR**: Server renders full HTML for the initial page load.
|
|
148
|
-
2. **Hydration**: Client hydrates, router attaches to the document.
|
|
149
|
-
3. **Navigation**: On link click:
|
|
150
|
-
- **Fetch**: Requests the full HTML of the target page (just like a standard browser navigation).
|
|
151
|
-
- **Parse**: Extracts the page component URL and serialized props from the HTML.
|
|
152
|
-
- **Preload**: Dynamically imports the new page component.
|
|
153
|
-
- **Transition**:
|
|
154
|
-
- Calls `document.startViewTransition()`.
|
|
155
|
-
- Updates the document head (title, meta, styles).
|
|
156
|
-
- Updates the React state to render the new page component.
|
|
157
|
-
- Waits for React commit (useEffect).
|
|
158
|
-
- **Resolve**: View Transition finishes, browser plays the animation.
|
|
159
|
-
|
|
160
|
-
## API
|
|
161
|
-
|
|
162
|
-
### `ecoRouter()`
|
|
163
|
-
|
|
164
|
-
Creates a router adapter for the React plugin.
|
|
165
|
-
|
|
166
|
-
```typescript
|
|
167
|
-
reactPlugin({ router: ecoRouter() });
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### `useRouter()`
|
|
171
|
-
|
|
172
|
-
Hook for programmatic navigation.
|
|
173
|
-
|
|
174
|
-
```typescript
|
|
175
|
-
const { navigate, isPending } = useRouter();
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
### Link Behavior
|
|
179
|
-
|
|
180
|
-
Links are **not** intercepted when:
|
|
181
|
-
|
|
182
|
-
- Modifier keys held (Ctrl, Cmd, Shift, Alt)
|
|
183
|
-
- Has `target="_blank"` or `download` attribute
|
|
184
|
-
- Has `data-eco-reload` attribute
|
|
185
|
-
- Points to different origin
|
|
186
|
-
- Starts with `#` or `javascript:`
|
|
187
|
-
|
|
188
|
-
## Architecture
|
|
189
|
-
|
|
190
|
-
The router uses a pluggable adapter pattern:
|
|
191
|
-
|
|
192
|
-
```typescript
|
|
193
|
-
interface ReactRouterAdapter {
|
|
194
|
-
name: string;
|
|
195
|
-
bundle: { importPath; outputName; externals };
|
|
196
|
-
importMapKey: string;
|
|
197
|
-
components: { router; pageContent };
|
|
198
|
-
getRouterProps(page, props): string;
|
|
199
|
-
}
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
This allows custom router implementations while keeping integration simple.
|
|
203
|
-
|
|
204
|
-
## Compatibility
|
|
205
|
-
|
|
206
|
-
- React 18.x or 19.x
|
|
207
|
-
- Modern browsers with ES modules
|
|
208
|
-
- EcoPages with React integration
|
|
209
|
-
|
|
210
|
-
## License
|
|
129
|
+
The router relies on **HTML-First** navigation to sync perfectly with SSR:
|
|
211
130
|
|
|
212
|
-
|
|
131
|
+
1. **SSR**: Initial page arrives completely rendered.
|
|
132
|
+
2. **Hydration**: Client hydrates and the router attaches.
|
|
133
|
+
3. **Navigation**: On click, the router:
|
|
134
|
+
- Fetches the raw HTML of the next route.
|
|
135
|
+
- Extracts page-level serialized props and metadata.
|
|
136
|
+
- Preloads the next page component via dynamic import.
|
|
137
|
+
- Updates React state, syncs `<head>`, and triggers `startViewTransition`.
|
|
138
|
+
- The React graph reconciles and the animation plays.
|
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.20",
|
|
4
4
|
"description": "Client-side SPA router for EcoPages React applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"directory": "packages/react-router"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@ecopages/
|
|
35
|
+
"@ecopages/core": "0.2.0-alpha.20",
|
|
36
|
+
"@ecopages/react": "0.2.0-alpha.20"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"react": "^19",
|
package/src/head-morpher.d.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* Intelligently syncs head elements between pages using key-based diffing.
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
|
+
export type HeadMorphResult = {
|
|
7
|
+
cleanup: () => void;
|
|
8
|
+
flushRerunScripts: () => void;
|
|
9
|
+
};
|
|
6
10
|
/**
|
|
7
11
|
* Morphs the current document head to match the new document's head.
|
|
8
12
|
* Now splits the process into adding new elements and returning a cleanup function
|
|
@@ -10,6 +14,6 @@
|
|
|
10
14
|
* don't disappear before the "old" snapshot is taken.
|
|
11
15
|
*
|
|
12
16
|
* @param newDocument - The parsed document from the navigation target
|
|
13
|
-
* @returns Promise that resolves to
|
|
17
|
+
* @returns Promise that resolves to cleanup and rerun hooks when new stylesheets have loaded
|
|
14
18
|
*/
|
|
15
|
-
export declare function morphHead(newDocument: Document): Promise<
|
|
19
|
+
export declare function morphHead(newDocument: Document): Promise<HeadMorphResult>;
|
package/src/head-morpher.js
CHANGED
|
@@ -1,4 +1,45 @@
|
|
|
1
1
|
const PRESERVE_SELECTORS = ['script[type="importmap"]', "meta[charset]", "[data-eco-persist]"];
|
|
2
|
+
const RERUN_SRC_ATTR = "data-eco-rerun-src";
|
|
3
|
+
let rerunNonce = 0;
|
|
4
|
+
function isNonExecutableHeadScript(el) {
|
|
5
|
+
if (el.tagName !== "SCRIPT") {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const type = (el.getAttribute("type") ?? "").trim().toLowerCase();
|
|
9
|
+
if (!type) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return ![
|
|
13
|
+
"application/javascript",
|
|
14
|
+
"application/ecmascript",
|
|
15
|
+
"module",
|
|
16
|
+
"text/ecmascript",
|
|
17
|
+
"text/javascript"
|
|
18
|
+
].includes(type);
|
|
19
|
+
}
|
|
20
|
+
function shouldPersistExecutableInlineHeadScript(el) {
|
|
21
|
+
if (el.tagName !== "SCRIPT") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
|
|
25
|
+
if (!scriptId) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (el.hasAttribute("data-eco-rerun")) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (el.src) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return !isNonExecutableHeadScript(el);
|
|
35
|
+
}
|
|
36
|
+
function isRerunScript(el) {
|
|
37
|
+
return el.tagName === "SCRIPT" && el.hasAttribute("data-eco-rerun");
|
|
38
|
+
}
|
|
39
|
+
function isHydrationScript(el) {
|
|
40
|
+
const src = el.getAttribute("src");
|
|
41
|
+
return !!src && src.includes("hydration.js") && src.includes("ecopages-react");
|
|
42
|
+
}
|
|
2
43
|
function getHeadElementKey(el) {
|
|
3
44
|
const tag = el.tagName.toLowerCase();
|
|
4
45
|
switch (tag) {
|
|
@@ -18,7 +59,9 @@ function getHeadElementKey(el) {
|
|
|
18
59
|
}
|
|
19
60
|
case "script": {
|
|
20
61
|
if (el.getAttribute("type") === "importmap") return "importmap";
|
|
21
|
-
const
|
|
62
|
+
const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
|
|
63
|
+
if (scriptId) return `script-id:${scriptId}`;
|
|
64
|
+
const src = el.getAttribute(RERUN_SRC_ATTR) || el.src;
|
|
22
65
|
return src ? `script:${src}` : null;
|
|
23
66
|
}
|
|
24
67
|
case "style": {
|
|
@@ -36,6 +79,12 @@ async function morphHead(newDocument) {
|
|
|
36
79
|
const newElements = /* @__PURE__ */ new Map();
|
|
37
80
|
const stylesheetPromises = [];
|
|
38
81
|
const elementsToRemove = [];
|
|
82
|
+
const pendingRerunScripts = Array.from(newHead.querySelectorAll("script[data-eco-rerun]")).filter((script) => !isHydrationScript(script)).map((script) => ({
|
|
83
|
+
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
84
|
+
textContent: script.textContent ?? "",
|
|
85
|
+
scriptId: script.getAttribute("data-eco-script-id"),
|
|
86
|
+
src: script.getAttribute("src")
|
|
87
|
+
}));
|
|
39
88
|
for (const el of Array.from(currentHead.children)) {
|
|
40
89
|
const key = getHeadElementKey(el);
|
|
41
90
|
if (key) currentElements.set(key, el);
|
|
@@ -46,9 +95,11 @@ async function morphHead(newDocument) {
|
|
|
46
95
|
}
|
|
47
96
|
for (const [key, newEl] of newElements) {
|
|
48
97
|
const currentEl = currentElements.get(key);
|
|
98
|
+
if (isRerunScript(newEl)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
49
101
|
if (!currentEl) {
|
|
50
|
-
|
|
51
|
-
if (newEl.tagName === "SCRIPT" && src && src.includes("hydration.js") && src.includes("ecopages-react")) {
|
|
102
|
+
if (newEl.tagName === "SCRIPT" && isHydrationScript(newEl)) {
|
|
52
103
|
continue;
|
|
53
104
|
}
|
|
54
105
|
const cloned = newEl.cloneNode(true);
|
|
@@ -62,13 +113,15 @@ async function morphHead(newDocument) {
|
|
|
62
113
|
currentHead.appendChild(cloned);
|
|
63
114
|
} else if (key === "title" && currentEl.textContent !== newEl.textContent) {
|
|
64
115
|
currentEl.textContent = newEl.textContent;
|
|
116
|
+
} else if (isNonExecutableHeadScript(newEl) && currentEl.textContent !== newEl.textContent) {
|
|
117
|
+
currentEl.textContent = newEl.textContent;
|
|
65
118
|
} else if (key.startsWith("style:") && currentEl.textContent !== newEl.textContent) {
|
|
66
119
|
currentEl.textContent = newEl.textContent;
|
|
67
120
|
}
|
|
68
121
|
}
|
|
69
122
|
for (const newEl of Array.from(newHead.children)) {
|
|
70
123
|
const key = getHeadElementKey(newEl);
|
|
71
|
-
if (!key) {
|
|
124
|
+
if (!key && !isRerunScript(newEl)) {
|
|
72
125
|
currentHead.appendChild(newEl.cloneNode(true));
|
|
73
126
|
}
|
|
74
127
|
}
|
|
@@ -78,17 +131,60 @@ async function morphHead(newDocument) {
|
|
|
78
131
|
for (const [key, el] of currentElements) {
|
|
79
132
|
if (!newElements.has(key)) {
|
|
80
133
|
const shouldPreserve = PRESERVE_SELECTORS.some((sel) => el.matches(sel));
|
|
81
|
-
if (!shouldPreserve) {
|
|
134
|
+
if (!shouldPreserve && !shouldPersistExecutableInlineHeadScript(el)) {
|
|
82
135
|
elementsToRemove.push(el);
|
|
83
136
|
}
|
|
84
137
|
}
|
|
85
138
|
}
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
el
|
|
139
|
+
return {
|
|
140
|
+
cleanup: () => {
|
|
141
|
+
for (const el of elementsToRemove) {
|
|
142
|
+
el.remove();
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
flushRerunScripts: () => {
|
|
146
|
+
for (const script of pendingRerunScripts) {
|
|
147
|
+
const replacement = document.createElement("script");
|
|
148
|
+
const shouldBustModuleSrc = isExternalModuleRerunScript(script);
|
|
149
|
+
for (const [name, value] of script.attributes) {
|
|
150
|
+
if (name === "src" && shouldBustModuleSrc) {
|
|
151
|
+
replacement.setAttribute(RERUN_SRC_ATTR, value);
|
|
152
|
+
replacement.setAttribute("src", createRerunScriptUrl(value));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
replacement.setAttribute(name, value);
|
|
156
|
+
}
|
|
157
|
+
replacement.textContent = script.textContent;
|
|
158
|
+
const existingScript = findExistingRerunScript(script);
|
|
159
|
+
if (existingScript) {
|
|
160
|
+
existingScript.replaceWith(replacement);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
document.head.appendChild(replacement);
|
|
164
|
+
}
|
|
89
165
|
}
|
|
90
166
|
};
|
|
91
167
|
}
|
|
168
|
+
function findExistingRerunScript(script) {
|
|
169
|
+
const scripts = Array.from(document.head.querySelectorAll("script"));
|
|
170
|
+
if (script.scriptId) {
|
|
171
|
+
return scripts.find((candidate) => candidate.getAttribute("data-eco-script-id") === script.scriptId) ?? null;
|
|
172
|
+
}
|
|
173
|
+
return scripts.find(
|
|
174
|
+
(candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
|
|
175
|
+
) ?? null;
|
|
176
|
+
}
|
|
177
|
+
function isExternalModuleRerunScript(script) {
|
|
178
|
+
if (!script.src) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return script.attributes.some(([name, value]) => name === "type" && value === "module");
|
|
182
|
+
}
|
|
183
|
+
function createRerunScriptUrl(src) {
|
|
184
|
+
const url = new URL(src, document.baseURI);
|
|
185
|
+
url.searchParams.set("__eco_rerun", String(++rerunNonce));
|
|
186
|
+
return url.toString();
|
|
187
|
+
}
|
|
92
188
|
export {
|
|
93
189
|
morphHead
|
|
94
190
|
};
|
package/src/navigation.d.ts
CHANGED
|
@@ -8,6 +8,21 @@ export type PageState = {
|
|
|
8
8
|
Component: ComponentType<any>;
|
|
9
9
|
props: Record<string, any>;
|
|
10
10
|
};
|
|
11
|
+
export type LoadedPageModule = {
|
|
12
|
+
Component: ComponentType<any>;
|
|
13
|
+
props: Record<string, any>;
|
|
14
|
+
doc: Document;
|
|
15
|
+
finalPath: string;
|
|
16
|
+
moduleUrl: string;
|
|
17
|
+
};
|
|
18
|
+
export type FetchedPageDocument = {
|
|
19
|
+
doc: Document;
|
|
20
|
+
finalPath: string;
|
|
21
|
+
html: string;
|
|
22
|
+
};
|
|
23
|
+
type LoadPageModuleOptions = {
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
};
|
|
11
26
|
export type InterceptDecision = {
|
|
12
27
|
shouldIntercept: true;
|
|
13
28
|
} | {
|
|
@@ -26,7 +41,7 @@ export type InterceptDecision = {
|
|
|
26
41
|
*/
|
|
27
42
|
export declare function getInterceptDecision(event: MouseEvent, link: HTMLAnchorElement, options: Required<EcoRouterOptions>): InterceptDecision;
|
|
28
43
|
/**
|
|
29
|
-
* Extracts serialized page props from window.
|
|
44
|
+
* Extracts serialized page props from window.__ECO_PAGES__.page or fetched document.
|
|
30
45
|
* For current document, returns props set by hydration script.
|
|
31
46
|
* For fetched documents, parses the JSON script tag directly.
|
|
32
47
|
*/
|
|
@@ -34,7 +49,7 @@ export declare function extractProps(doc: Document): Record<string, any>;
|
|
|
34
49
|
/**
|
|
35
50
|
* Extracts component module URL using multi-tier strategy.
|
|
36
51
|
*
|
|
37
|
-
* 1. Read from window.
|
|
52
|
+
* 1. Read from window.__ECO_PAGES__.page.module (for current document)
|
|
38
53
|
* 2. Parse inline hydration script with regex (for fetched documents)
|
|
39
54
|
* 3. Fetch and parse external hydration script (final fallback)
|
|
40
55
|
*
|
|
@@ -52,14 +67,12 @@ export declare function extractComponentUrl(doc: Document): Promise<string | nul
|
|
|
52
67
|
* @param url - The URL to load
|
|
53
68
|
* @returns Object with Component, props, doc, and finalPath, or null on error
|
|
54
69
|
*/
|
|
55
|
-
export declare function loadPageModule(url: string): Promise<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
doc: Document;
|
|
59
|
-
finalPath: string;
|
|
60
|
-
} | null>;
|
|
70
|
+
export declare function loadPageModule(url: string, options?: LoadPageModuleOptions): Promise<LoadedPageModule | null>;
|
|
71
|
+
export declare function fetchPageDocument(url: string, options?: LoadPageModuleOptions): Promise<FetchedPageDocument | null>;
|
|
72
|
+
export declare function loadPageModuleFromDocument(doc: Document, finalPath: string): Promise<LoadedPageModule | null>;
|
|
61
73
|
/**
|
|
62
74
|
* Convenience wrapper around getInterceptDecision that returns a boolean.
|
|
63
75
|
* Use getInterceptDecision directly when you need the reason for debugging.
|
|
64
76
|
*/
|
|
65
77
|
export declare function shouldInterceptClick(event: MouseEvent, link: HTMLAnchorElement, options: Required<EcoRouterOptions>): boolean;
|
|
78
|
+
export {};
|
package/src/navigation.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { getEcoDocumentOwner } from "@ecopages/core/router/navigation-coordinator";
|
|
2
|
+
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
|
+
}
|
|
1
6
|
function getInterceptDecision(event, link, options) {
|
|
2
7
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
3
8
|
return { shouldIntercept: false, reason: "modified-click" };
|
|
@@ -16,8 +21,8 @@ function getInterceptDecision(event, link, options) {
|
|
|
16
21
|
return { shouldIntercept: true };
|
|
17
22
|
}
|
|
18
23
|
function extractComponentUrlFromMarker(doc) {
|
|
19
|
-
if (doc === document && window.
|
|
20
|
-
return window.
|
|
24
|
+
if (doc === document && window.__ECO_PAGES__?.page?.module) {
|
|
25
|
+
return window.__ECO_PAGES__.page.module;
|
|
21
26
|
}
|
|
22
27
|
return null;
|
|
23
28
|
}
|
|
@@ -29,10 +34,10 @@ function extractModulePathFromCode(code) {
|
|
|
29
34
|
return (defaultMatch || namespaceMatch)?.[2] ?? null;
|
|
30
35
|
}
|
|
31
36
|
function extractProps(doc) {
|
|
32
|
-
if (doc === document && window.
|
|
33
|
-
return window.
|
|
37
|
+
if (doc === document && window.__ECO_PAGES__?.page?.props) {
|
|
38
|
+
return window.__ECO_PAGES__.page.props;
|
|
34
39
|
}
|
|
35
|
-
const propsScript = doc.getElementById(
|
|
40
|
+
const propsScript = doc.getElementById(ROUTER_PROPS_SCRIPT_ID);
|
|
36
41
|
if (propsScript?.textContent) {
|
|
37
42
|
try {
|
|
38
43
|
return JSON.parse(propsScript.textContent);
|
|
@@ -43,6 +48,9 @@ function extractProps(doc) {
|
|
|
43
48
|
}
|
|
44
49
|
return {};
|
|
45
50
|
}
|
|
51
|
+
function isReactRouteDocument(doc) {
|
|
52
|
+
return getEcoDocumentOwner(doc) === "react-router";
|
|
53
|
+
}
|
|
46
54
|
function addCacheBuster(url) {
|
|
47
55
|
if (import.meta.env?.MODE === "production" || import.meta.env?.PROD) {
|
|
48
56
|
return url;
|
|
@@ -55,12 +63,12 @@ async function extractComponentUrl(doc) {
|
|
|
55
63
|
if (markerUrl) return markerUrl;
|
|
56
64
|
const scripts = Array.from(doc.querySelectorAll("script"));
|
|
57
65
|
const inlineHydrationScript = scripts.find(
|
|
58
|
-
(s) => !s.src && !!s.textContent && s.textContent.includes("
|
|
66
|
+
(s) => !s.src && !!s.textContent && s.textContent.includes("__ECO_PAGES__") && s.textContent.includes("hydrateRoot") && s.textContent.includes("import")
|
|
59
67
|
);
|
|
60
68
|
if (inlineHydrationScript?.textContent) {
|
|
61
69
|
return extractModulePathFromCode(inlineHydrationScript.textContent);
|
|
62
70
|
}
|
|
63
|
-
const hydrationScript = scripts.find((s) => s.src
|
|
71
|
+
const hydrationScript = scripts.find((s) => isReactPageHydrationAsset(s.src ?? ""));
|
|
64
72
|
if (!hydrationScript?.src) return null;
|
|
65
73
|
try {
|
|
66
74
|
const scriptUrl = addCacheBuster(hydrationScript.src);
|
|
@@ -71,50 +79,68 @@ async function extractComponentUrl(doc) {
|
|
|
71
79
|
return null;
|
|
72
80
|
}
|
|
73
81
|
}
|
|
74
|
-
async function loadPageModule(url) {
|
|
82
|
+
async function loadPageModule(url, options = {}) {
|
|
83
|
+
const fetchedPage = await fetchPageDocument(url, options);
|
|
84
|
+
if (!fetchedPage) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
|
|
88
|
+
}
|
|
89
|
+
async function fetchPageDocument(url, options = {}) {
|
|
75
90
|
try {
|
|
76
|
-
const res = await fetch(url
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
signal: options.signal,
|
|
93
|
+
headers: {
|
|
94
|
+
Accept: "text/html"
|
|
95
|
+
}
|
|
96
|
+
});
|
|
77
97
|
const html = await res.text();
|
|
78
98
|
const finalUrl = new URL(res.url || url, window.location.origin);
|
|
79
99
|
const finalPath = finalUrl.pathname + finalUrl.search;
|
|
80
100
|
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");
|
|
101
|
+
return { doc, finalPath, html };
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e instanceof DOMException && e.name === "AbortError") {
|
|
96
104
|
return null;
|
|
97
105
|
}
|
|
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
106
|
console.error("[EcoRouter] Navigation failed:", e);
|
|
108
107
|
return null;
|
|
109
108
|
}
|
|
110
109
|
}
|
|
110
|
+
async function loadPageModuleFromDocument(doc, finalPath) {
|
|
111
|
+
const props = extractProps(doc);
|
|
112
|
+
const componentUrl = await extractComponentUrl(doc);
|
|
113
|
+
if (!componentUrl) {
|
|
114
|
+
if (isReactRouteDocument(doc)) {
|
|
115
|
+
console.error("[EcoRouter] Could not find component URL");
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const moduleUrl = addCacheBuster(componentUrl);
|
|
120
|
+
const module = await import(
|
|
121
|
+
/* @vite-ignore */
|
|
122
|
+
moduleUrl
|
|
123
|
+
);
|
|
124
|
+
const rawComponent = module.Content || module.default?.Content || module.default;
|
|
125
|
+
const config = module.config || rawComponent?.config;
|
|
126
|
+
if (!rawComponent) {
|
|
127
|
+
console.error("[EcoRouter] No component found in module");
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (config && !rawComponent.config) {
|
|
131
|
+
rawComponent.config = config;
|
|
132
|
+
}
|
|
133
|
+
return { Component: rawComponent, props, doc, finalPath, moduleUrl: componentUrl };
|
|
134
|
+
}
|
|
111
135
|
function shouldInterceptClick(event, link, options) {
|
|
112
136
|
return getInterceptDecision(event, link, options).shouldIntercept;
|
|
113
137
|
}
|
|
114
138
|
export {
|
|
115
139
|
extractComponentUrl,
|
|
116
140
|
extractProps,
|
|
141
|
+
fetchPageDocument,
|
|
117
142
|
getInterceptDecision,
|
|
118
143
|
loadPageModule,
|
|
144
|
+
loadPageModuleFromDocument,
|
|
119
145
|
shouldInterceptClick
|
|
120
146
|
};
|
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,9 @@ 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.
|
|
33
35
|
*
|
|
34
36
|
* @example
|
|
35
37
|
* ```tsx
|