@ecopages/react-router 0.2.0-alpha.2 → 0.2.0-alpha.21

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 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
- - Updated `package.json` dependencies to align with the new core adapter and esbuild build adapter versions.
12
- - Internal peer dependency declarations updated for React 18+ and the new `@ecopages/core` API surface.
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 EcoPages React applications. Enables single-page application navigation while preserving full SSR benefits.
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
- Add the router adapter to your `eco.config.ts`:
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.dir)
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
- That's it! All pages now have SPA navigation enabled.
29
-
30
- ## Features
46
+ SPA navigation is now enabled for all React pages in your project.
31
47
 
32
- - **Opt-in via config** - Single line enables SPA for all pages
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
- Use `config.layout` for persistent UI across navigations:
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
- ### View Transitions
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
- #### Lifecycle
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
- When a navigation occurs with View Transitions enabled:
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 (Source)
112
- <img
113
- src={post.image}
114
- data-view-transition={`hero-${post.id}`}
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, the router applies a **clean morph** animation (disabling the default cross-fade ghosting). If you prefer the standard browser cross-fade, you can opt-out:
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 uses an **HTML-First** navigation strategy to ensure consistency with Server-Side Rendering (SSR).
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
- MIT
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.2",
3
+ "version": "0.2.0-alpha.21",
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/react": "0.2.0-alpha.2"
35
+ "@ecopages/core": "0.2.0-alpha.21",
36
+ "@ecopages/react": "0.2.0-alpha.21"
36
37
  },
37
38
  "dependencies": {
38
39
  "react": "^19",
@@ -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 a cleanup function when new stylesheets have loaded
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<() => void>;
19
+ export declare function morphHead(newDocument: Document): Promise<HeadMorphResult>;
@@ -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 src = el.src;
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
- const src = newEl.getAttribute("src");
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
- for (const el of elementsToRemove) {
88
- el.remove();
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
  };
@@ -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.__ECO_PAGE__ or fetched document.
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.__ECO_PAGE__.module (for current document)
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
- Component: ComponentType<any>;
57
- props: Record<string, any>;
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.__ECO_PAGE__?.module) {
20
- return window.__ECO_PAGE__.module;
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.__ECO_PAGE__?.props) {
33
- return window.__ECO_PAGE__.props;
37
+ if (doc === document && window.__ECO_PAGES__?.page?.props) {
38
+ return window.__ECO_PAGES__.page.props;
34
39
  }
35
- const propsScript = doc.getElementById("__ECO_PAGE_DATA__");
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("__ECO_PAGE__") && s.textContent.includes("hydrateRoot") && s.textContent.includes("import")
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?.includes("hydration.js") && s.src?.includes("ecopages-react"));
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
- const props = extractProps(doc);
82
- const componentUrl = await extractComponentUrl(doc);
83
- if (!componentUrl) {
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
  };
@@ -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.__ECO_PAGE__.
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