@ecopages/react-router 0.2.0-alpha.5 → 0.2.0-alpha.7

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,14 @@ 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-non-React handoffs to replay queued clicks through the next active runtime and reuse prefetched HTML documents instead of forcing a second fetch.
12
+ - Fixed stale handoff cleanup and fallback races so older React-router or browser-router navigations cannot overwrite a newer navigation.
13
+ - Standardized React route payload reads on `window.__ECO_PAGES__.page` and explicit document owner markers so mixed-router page ownership stays stable.
14
+ - Restored current-page HMR refreshes with persist layouts enabled by targeting the active React-router owner.
15
+
9
16
  ### Refactoring
10
17
 
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.
18
+ - Routed browser handoff and current-page reloads through the shared navigation coordinator.
19
+ - Updated package metadata for the current core, esbuild adapter, and React peer dependency surface.
package/README.md CHANGED
@@ -1,46 +1,57 @@
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
 
7
25
  ```bash
8
- bun add @ecopages/react-router
26
+ bunx jsr add @ecopages/react-router
9
27
  ```
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.5",
3
+ "version": "0.2.0-alpha.7",
4
4
  "description": "Client-side SPA router for EcoPages React applications",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -32,9 +32,10 @@
32
32
  "directory": "packages/react-router"
33
33
  },
34
34
  "peerDependencies": {
35
- "@ecopages/react": "0.2.0-alpha.5"
35
+ "@ecopages/react": "0.2.0-alpha.7"
36
36
  },
37
37
  "dependencies": {
38
+ "@ecopages/core": "0.2.0-alpha.7",
38
39
  "react": "^19",
39
40
  "react-dom": "^19.2.4"
40
41
  },
@@ -1,4 +1,36 @@
1
1
  const PRESERVE_SELECTORS = ['script[type="importmap"]', "meta[charset]", "[data-eco-persist]"];
2
+ function isNonExecutableHeadScript(el) {
3
+ if (el.tagName !== "SCRIPT") {
4
+ return false;
5
+ }
6
+ const type = (el.getAttribute("type") ?? "").trim().toLowerCase();
7
+ if (!type) {
8
+ return false;
9
+ }
10
+ return ![
11
+ "application/javascript",
12
+ "application/ecmascript",
13
+ "module",
14
+ "text/ecmascript",
15
+ "text/javascript"
16
+ ].includes(type);
17
+ }
18
+ function shouldPersistExecutableInlineHeadScript(el) {
19
+ if (el.tagName !== "SCRIPT") {
20
+ return false;
21
+ }
22
+ const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
23
+ if (!scriptId) {
24
+ return false;
25
+ }
26
+ if (el.hasAttribute("data-eco-rerun")) {
27
+ return false;
28
+ }
29
+ if (el.src) {
30
+ return false;
31
+ }
32
+ return !isNonExecutableHeadScript(el);
33
+ }
2
34
  function getHeadElementKey(el) {
3
35
  const tag = el.tagName.toLowerCase();
4
36
  switch (tag) {
@@ -18,6 +50,8 @@ function getHeadElementKey(el) {
18
50
  }
19
51
  case "script": {
20
52
  if (el.getAttribute("type") === "importmap") return "importmap";
53
+ const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
54
+ if (scriptId) return `script-id:${scriptId}`;
21
55
  const src = el.src;
22
56
  return src ? `script:${src}` : null;
23
57
  }
@@ -62,6 +96,8 @@ async function morphHead(newDocument) {
62
96
  currentHead.appendChild(cloned);
63
97
  } else if (key === "title" && currentEl.textContent !== newEl.textContent) {
64
98
  currentEl.textContent = newEl.textContent;
99
+ } else if (isNonExecutableHeadScript(newEl) && currentEl.textContent !== newEl.textContent) {
100
+ currentEl.textContent = newEl.textContent;
65
101
  } else if (key.startsWith("style:") && currentEl.textContent !== newEl.textContent) {
66
102
  currentEl.textContent = newEl.textContent;
67
103
  }
@@ -78,7 +114,7 @@ async function morphHead(newDocument) {
78
114
  for (const [key, el] of currentElements) {
79
115
  if (!newElements.has(key)) {
80
116
  const shouldPreserve = PRESERVE_SELECTORS.some((sel) => el.matches(sel));
81
- if (!shouldPreserve) {
117
+ if (!shouldPreserve && !shouldPersistExecutableInlineHeadScript(el)) {
82
118
  elementsToRemove.push(el);
83
119
  }
84
120
  }
@@ -6,6 +6,46 @@
6
6
 
7
7
  const PRESERVE_SELECTORS = ['script[type="importmap"]', 'meta[charset]', '[data-eco-persist]'];
8
8
 
9
+ function isNonExecutableHeadScript(el: Element): boolean {
10
+ if (el.tagName !== 'SCRIPT') {
11
+ return false;
12
+ }
13
+
14
+ const type = (el.getAttribute('type') ?? '').trim().toLowerCase();
15
+ if (!type) {
16
+ return false;
17
+ }
18
+
19
+ return ![
20
+ 'application/javascript',
21
+ 'application/ecmascript',
22
+ 'module',
23
+ 'text/ecmascript',
24
+ 'text/javascript',
25
+ ].includes(type);
26
+ }
27
+
28
+ function shouldPersistExecutableInlineHeadScript(el: Element): boolean {
29
+ if (el.tagName !== 'SCRIPT') {
30
+ return false;
31
+ }
32
+
33
+ const scriptId = el.getAttribute('data-eco-script-id') || el.getAttribute('id');
34
+ if (!scriptId) {
35
+ return false;
36
+ }
37
+
38
+ if (el.hasAttribute('data-eco-rerun')) {
39
+ return false;
40
+ }
41
+
42
+ if ((el as HTMLScriptElement).src) {
43
+ return false;
44
+ }
45
+
46
+ return !isNonExecutableHeadScript(el);
47
+ }
48
+
9
49
  /**
10
50
  * Computes a unique key for a head element to enable diffing.
11
51
  * Elements with the same key are considered the same across navigations.
@@ -33,6 +73,8 @@ function getHeadElementKey(el: Element): string | null {
33
73
 
34
74
  case 'script': {
35
75
  if (el.getAttribute('type') === 'importmap') return 'importmap';
76
+ const scriptId = el.getAttribute('data-eco-script-id') || el.getAttribute('id');
77
+ if (scriptId) return `script-id:${scriptId}`;
36
78
  const src = (el as HTMLScriptElement).src;
37
79
  return src ? `script:${src}` : null;
38
80
  }
@@ -122,6 +164,8 @@ export async function morphHead(newDocument: Document): Promise<() => void> {
122
164
  currentHead.appendChild(cloned);
123
165
  } else if (key === 'title' && currentEl.textContent !== newEl.textContent) {
124
166
  currentEl.textContent = newEl.textContent;
167
+ } else if (isNonExecutableHeadScript(newEl) && currentEl.textContent !== newEl.textContent) {
168
+ currentEl.textContent = newEl.textContent;
125
169
  } else if (key.startsWith('style:') && currentEl.textContent !== newEl.textContent) {
126
170
  currentEl.textContent = newEl.textContent;
127
171
  }
@@ -151,7 +195,7 @@ export async function morphHead(newDocument: Document): Promise<() => void> {
151
195
  for (const [key, el] of currentElements) {
152
196
  if (!newElements.has(key)) {
153
197
  const shouldPreserve = PRESERVE_SELECTORS.some((sel) => el.matches(sel));
154
- if (!shouldPreserve) {
198
+ if (!shouldPreserve && !shouldPersistExecutableInlineHeadScript(el)) {
155
199
  elementsToRemove.push(el);
156
200
  }
157
201
  }
@@ -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
  };