@ecopages/react-router 0.2.0-alpha.3 → 0.2.0-alpha.30
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 +11 -2
- package/README.md +45 -119
- package/package.json +3 -2
- package/src/adapter.js +1 -2
- package/src/head-morpher.d.ts +6 -2
- package/src/head-morpher.js +110 -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 +93 -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/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,16 @@ All notable changes to `@ecopages/react-router` are documented here.
|
|
|
6
6
|
|
|
7
7
|
## [UNRELEASED] — TBD
|
|
8
8
|
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- Fixed same-page hash links and Shadow DOM TOC clicks to bypass React Router interception so anchor navigation preserves the URL fragment without a document fetch.
|
|
12
|
+
- Extended page-module extraction to honor explicit hydration markers and self-owned React page entry bundles during navigation.
|
|
13
|
+
- Fixed current-page reloads to accept HMR module overrides so persisted-layout refreshes import the rebuilt active page entry.
|
|
14
|
+
- Fixed React-to-browser-router handoffs, queued-click replay, and stale-navigation races during mixed-router navigations.
|
|
15
|
+
- Standardized route payload reads, document-owner markers, rerun scripts, and current-page HMR refreshes for persisted React layouts.
|
|
16
|
+
|
|
9
17
|
### Refactoring
|
|
10
18
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
19
|
+
- Routed browser handoff and current-page reloads through the shared navigation coordinator.
|
|
20
|
+
- Removed the React router adapter `importMapKey` field so the adapter now exposes only the browser bundle import path used by both development and production hydration.
|
|
21
|
+
- 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.30",
|
|
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.30",
|
|
36
|
+
"@ecopages/react": "0.2.0-alpha.30"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"react": "^19",
|
package/src/adapter.js
CHANGED
|
@@ -2,11 +2,10 @@ function ecoRouter(options) {
|
|
|
2
2
|
return {
|
|
3
3
|
name: "eco-router",
|
|
4
4
|
bundle: {
|
|
5
|
-
importPath: "@ecopages/react-router/browser
|
|
5
|
+
importPath: "@ecopages/react-router/browser",
|
|
6
6
|
outputName: "react-router-esm",
|
|
7
7
|
externals: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"]
|
|
8
8
|
},
|
|
9
|
-
importMapKey: "@ecopages/react-router",
|
|
10
9
|
components: {
|
|
11
10
|
router: "EcoRouter",
|
|
12
11
|
pageContent: "PageContent"
|
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,50 @@
|
|
|
1
|
-
|
|
1
|
+
import { isReactRouterPageBootstrapAssetSrc } from "./hydration-assets.js";
|
|
2
|
+
const PRESERVE_SELECTORS = ["meta[charset]", "[data-eco-persist]"];
|
|
3
|
+
const RERUN_SRC_ATTR = "data-eco-rerun-src";
|
|
4
|
+
let rerunNonce = 0;
|
|
5
|
+
function isNonExecutableHeadScript(el) {
|
|
6
|
+
if (el.tagName !== "SCRIPT") {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const type = (el.getAttribute("type") ?? "").trim().toLowerCase();
|
|
10
|
+
if (!type) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return ![
|
|
14
|
+
"application/javascript",
|
|
15
|
+
"application/ecmascript",
|
|
16
|
+
"module",
|
|
17
|
+
"text/ecmascript",
|
|
18
|
+
"text/javascript"
|
|
19
|
+
].includes(type);
|
|
20
|
+
}
|
|
21
|
+
function shouldPersistExecutableInlineHeadScript(el) {
|
|
22
|
+
if (el.tagName !== "SCRIPT") {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
|
|
26
|
+
if (!scriptId) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (el.hasAttribute("data-eco-rerun")) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (el.src) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return !isNonExecutableHeadScript(el);
|
|
36
|
+
}
|
|
37
|
+
function isRerunScript(el) {
|
|
38
|
+
return el.tagName === "SCRIPT" && el.hasAttribute("data-eco-rerun");
|
|
39
|
+
}
|
|
40
|
+
function isReactRouterPageBootstrapScriptId(scriptId) {
|
|
41
|
+
return !!scriptId && scriptId.startsWith("ecopages-react-") && !scriptId.startsWith("ecopages-react-island-");
|
|
42
|
+
}
|
|
43
|
+
function isHydrationScript(el) {
|
|
44
|
+
const src = el.getAttribute("src");
|
|
45
|
+
const scriptId = el.getAttribute("data-eco-script-id");
|
|
46
|
+
return isReactRouterPageBootstrapScriptId(scriptId) || !!src && isReactRouterPageBootstrapAssetSrc(src);
|
|
47
|
+
}
|
|
2
48
|
function getHeadElementKey(el) {
|
|
3
49
|
const tag = el.tagName.toLowerCase();
|
|
4
50
|
switch (tag) {
|
|
@@ -17,8 +63,9 @@ function getHeadElementKey(el) {
|
|
|
17
63
|
return href ? `link:${href}` : null;
|
|
18
64
|
}
|
|
19
65
|
case "script": {
|
|
20
|
-
|
|
21
|
-
|
|
66
|
+
const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
|
|
67
|
+
if (scriptId) return `script-id:${scriptId}`;
|
|
68
|
+
const src = el.getAttribute(RERUN_SRC_ATTR) || el.src;
|
|
22
69
|
return src ? `script:${src}` : null;
|
|
23
70
|
}
|
|
24
71
|
case "style": {
|
|
@@ -36,6 +83,12 @@ async function morphHead(newDocument) {
|
|
|
36
83
|
const newElements = /* @__PURE__ */ new Map();
|
|
37
84
|
const stylesheetPromises = [];
|
|
38
85
|
const elementsToRemove = [];
|
|
86
|
+
const pendingRerunScripts = Array.from(newHead.querySelectorAll("script[data-eco-rerun]")).filter((script) => !isHydrationScript(script)).map((script) => ({
|
|
87
|
+
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
88
|
+
textContent: script.textContent ?? "",
|
|
89
|
+
scriptId: script.getAttribute("data-eco-script-id"),
|
|
90
|
+
src: script.getAttribute("src")
|
|
91
|
+
}));
|
|
39
92
|
for (const el of Array.from(currentHead.children)) {
|
|
40
93
|
const key = getHeadElementKey(el);
|
|
41
94
|
if (key) currentElements.set(key, el);
|
|
@@ -46,9 +99,11 @@ async function morphHead(newDocument) {
|
|
|
46
99
|
}
|
|
47
100
|
for (const [key, newEl] of newElements) {
|
|
48
101
|
const currentEl = currentElements.get(key);
|
|
102
|
+
if (isRerunScript(newEl)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
49
105
|
if (!currentEl) {
|
|
50
|
-
|
|
51
|
-
if (newEl.tagName === "SCRIPT" && src && src.includes("hydration.js") && src.includes("ecopages-react")) {
|
|
106
|
+
if (newEl.tagName === "SCRIPT" && isHydrationScript(newEl)) {
|
|
52
107
|
continue;
|
|
53
108
|
}
|
|
54
109
|
const cloned = newEl.cloneNode(true);
|
|
@@ -62,13 +117,15 @@ async function morphHead(newDocument) {
|
|
|
62
117
|
currentHead.appendChild(cloned);
|
|
63
118
|
} else if (key === "title" && currentEl.textContent !== newEl.textContent) {
|
|
64
119
|
currentEl.textContent = newEl.textContent;
|
|
120
|
+
} else if (isNonExecutableHeadScript(newEl) && currentEl.textContent !== newEl.textContent) {
|
|
121
|
+
currentEl.textContent = newEl.textContent;
|
|
65
122
|
} else if (key.startsWith("style:") && currentEl.textContent !== newEl.textContent) {
|
|
66
123
|
currentEl.textContent = newEl.textContent;
|
|
67
124
|
}
|
|
68
125
|
}
|
|
69
126
|
for (const newEl of Array.from(newHead.children)) {
|
|
70
127
|
const key = getHeadElementKey(newEl);
|
|
71
|
-
if (!key) {
|
|
128
|
+
if (!key && !isRerunScript(newEl)) {
|
|
72
129
|
currentHead.appendChild(newEl.cloneNode(true));
|
|
73
130
|
}
|
|
74
131
|
}
|
|
@@ -78,17 +135,60 @@ async function morphHead(newDocument) {
|
|
|
78
135
|
for (const [key, el] of currentElements) {
|
|
79
136
|
if (!newElements.has(key)) {
|
|
80
137
|
const shouldPreserve = PRESERVE_SELECTORS.some((sel) => el.matches(sel));
|
|
81
|
-
if (!shouldPreserve) {
|
|
138
|
+
if (!shouldPreserve && !shouldPersistExecutableInlineHeadScript(el)) {
|
|
82
139
|
elementsToRemove.push(el);
|
|
83
140
|
}
|
|
84
141
|
}
|
|
85
142
|
}
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
el
|
|
143
|
+
return {
|
|
144
|
+
cleanup: () => {
|
|
145
|
+
for (const el of elementsToRemove) {
|
|
146
|
+
el.remove();
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
flushRerunScripts: () => {
|
|
150
|
+
for (const script of pendingRerunScripts) {
|
|
151
|
+
const replacement = document.createElement("script");
|
|
152
|
+
const shouldBustModuleSrc = isExternalModuleRerunScript(script);
|
|
153
|
+
for (const [name, value] of script.attributes) {
|
|
154
|
+
if (name === "src" && shouldBustModuleSrc) {
|
|
155
|
+
replacement.setAttribute(RERUN_SRC_ATTR, value);
|
|
156
|
+
replacement.setAttribute("src", createRerunScriptUrl(value));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
replacement.setAttribute(name, value);
|
|
160
|
+
}
|
|
161
|
+
replacement.textContent = script.textContent;
|
|
162
|
+
const existingScript = findExistingRerunScript(script);
|
|
163
|
+
if (existingScript) {
|
|
164
|
+
existingScript.replaceWith(replacement);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
document.head.appendChild(replacement);
|
|
168
|
+
}
|
|
89
169
|
}
|
|
90
170
|
};
|
|
91
171
|
}
|
|
172
|
+
function findExistingRerunScript(script) {
|
|
173
|
+
const scripts = Array.from(document.head.querySelectorAll("script"));
|
|
174
|
+
if (script.scriptId) {
|
|
175
|
+
return scripts.find((candidate) => candidate.getAttribute("data-eco-script-id") === script.scriptId) ?? null;
|
|
176
|
+
}
|
|
177
|
+
return scripts.find(
|
|
178
|
+
(candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
|
|
179
|
+
) ?? null;
|
|
180
|
+
}
|
|
181
|
+
function isExternalModuleRerunScript(script) {
|
|
182
|
+
if (!script.src) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return script.attributes.some(([name, value]) => name === "type" && value === "module");
|
|
186
|
+
}
|
|
187
|
+
function createRerunScriptUrl(src) {
|
|
188
|
+
const url = new URL(src, document.baseURI);
|
|
189
|
+
url.searchParams.set("__eco_rerun", String(++rerunNonce));
|
|
190
|
+
return url.toString();
|
|
191
|
+
}
|
|
92
192
|
export {
|
|
93
193
|
morphHead
|
|
94
194
|
};
|
|
@@ -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
|
@@ -8,12 +8,38 @@ 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
|
+
};
|
|
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
|
+
};
|
|
11
36
|
export type InterceptDecision = {
|
|
12
37
|
shouldIntercept: true;
|
|
13
38
|
} | {
|
|
14
39
|
shouldIntercept: false;
|
|
15
|
-
reason: 'modified-click' | 'non-left-click' | 'external-target' | 'explicit-reload' | 'download' | 'invalid-href' | 'cross-origin';
|
|
40
|
+
reason: 'modified-click' | 'non-left-click' | 'external-target' | 'explicit-reload' | 'download' | 'invalid-href' | 'cross-origin' | 'same-page-hash';
|
|
16
41
|
};
|
|
42
|
+
export declare function isSamePageHashNavigationHref(href: string): boolean;
|
|
17
43
|
/**
|
|
18
44
|
* Determines whether a link click should be intercepted for client-side navigation.
|
|
19
45
|
*
|
|
@@ -26,7 +52,7 @@ export type InterceptDecision = {
|
|
|
26
52
|
*/
|
|
27
53
|
export declare function getInterceptDecision(event: MouseEvent, link: HTMLAnchorElement, options: Required<EcoRouterOptions>): InterceptDecision;
|
|
28
54
|
/**
|
|
29
|
-
* Extracts serialized page props from window.
|
|
55
|
+
* Extracts serialized page props from window.__ECO_PAGES__.page or fetched document.
|
|
30
56
|
* For current document, returns props set by hydration script.
|
|
31
57
|
* For fetched documents, parses the JSON script tag directly.
|
|
32
58
|
*/
|
|
@@ -34,7 +60,7 @@ export declare function extractProps(doc: Document): Record<string, any>;
|
|
|
34
60
|
/**
|
|
35
61
|
* Extracts component module URL using multi-tier strategy.
|
|
36
62
|
*
|
|
37
|
-
* 1. Read from window.
|
|
63
|
+
* 1. Read from window.__ECO_PAGES__.page.module (for current document)
|
|
38
64
|
* 2. Parse inline hydration script with regex (for fetched documents)
|
|
39
65
|
* 3. Fetch and parse external hydration script (final fallback)
|
|
40
66
|
*
|
|
@@ -52,14 +78,26 @@ export declare function extractComponentUrl(doc: Document): Promise<string | nul
|
|
|
52
78
|
* @param url - The URL to load
|
|
53
79
|
* @returns Object with Component, props, doc, and finalPath, or null on error
|
|
54
80
|
*/
|
|
55
|
-
export declare function loadPageModule(url: string): Promise<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
export declare function loadPageModule(url: string, options?: LoadPageModuleOptions): Promise<LoadedPageModule | null>;
|
|
82
|
+
export declare function fetchPageDocument(url: string, options?: LoadPageModuleOptions): Promise<FetchedPageDocument | null>;
|
|
83
|
+
/**
|
|
84
|
+
* Loads the page module for a fetched or current document.
|
|
85
|
+
*
|
|
86
|
+
* The router normally extracts the page module URL from the document's
|
|
87
|
+
* hydration assets. Callers can provide `options.moduleUrlOverride` when the
|
|
88
|
+
* document is stale with respect to the active runtime module identity, such as
|
|
89
|
+
* during HMR-driven current-page reloads.
|
|
90
|
+
*
|
|
91
|
+
* @param doc - Parsed destination document.
|
|
92
|
+
* @param finalPath - Final route path after redirects.
|
|
93
|
+
* @param options - Module loading overrides.
|
|
94
|
+
* @returns Loaded page module payload or `null` when the document is not a
|
|
95
|
+
* React-router page or no page component can be resolved.
|
|
96
|
+
*/
|
|
97
|
+
export declare function loadPageModuleFromDocument(doc: Document, finalPath: string, options?: LoadPageModuleFromDocumentOptions): Promise<LoadedPageModule | null>;
|
|
61
98
|
/**
|
|
62
99
|
* Convenience wrapper around getInterceptDecision that returns a boolean.
|
|
63
100
|
* Use getInterceptDecision directly when you need the reason for debugging.
|
|
64
101
|
*/
|
|
65
102
|
export declare function shouldInterceptClick(event: MouseEvent, link: HTMLAnchorElement, options: Required<EcoRouterOptions>): boolean;
|
|
103
|
+
export {};
|