@ecopages/react-router 0.2.0-alpha.8 → 0.2.1

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
@@ -4,14 +4,12 @@ All notable changes to `@ecopages/react-router` are documented here.
4
4
 
5
5
  > **Note:** Changelog tracking begins at version `0.2.0`. Changes prior to this release are not recorded here but are available in the git history.
6
6
 
7
- ## [UNRELEASED] — TBD
7
+ ## [0.2.1] — 2026-04-16
8
8
 
9
9
  ### Bug Fixes
10
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.
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.
15
13
 
16
14
  ### Refactoring
17
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react-router",
3
- "version": "0.2.0-alpha.8",
3
+ "version": "0.2.1",
4
4
  "description": "Client-side SPA router for EcoPages React applications",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -32,10 +32,10 @@
32
32
  "directory": "packages/react-router"
33
33
  },
34
34
  "peerDependencies": {
35
- "@ecopages/react": "0.2.0-alpha.8"
35
+ "@ecopages/core": "0.2.1",
36
+ "@ecopages/react": "0.2.1"
36
37
  },
37
38
  "dependencies": {
38
- "@ecopages/core": "0.2.0-alpha.8",
39
39
  "react": "^19",
40
40
  "react-dom": "^19.2.4"
41
41
  },
@@ -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,6 @@
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;
2
4
  function isNonExecutableHeadScript(el) {
3
5
  if (el.tagName !== "SCRIPT") {
4
6
  return false;
@@ -31,6 +33,13 @@ function shouldPersistExecutableInlineHeadScript(el) {
31
33
  }
32
34
  return !isNonExecutableHeadScript(el);
33
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
+ }
34
43
  function getHeadElementKey(el) {
35
44
  const tag = el.tagName.toLowerCase();
36
45
  switch (tag) {
@@ -52,7 +61,7 @@ function getHeadElementKey(el) {
52
61
  if (el.getAttribute("type") === "importmap") return "importmap";
53
62
  const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
54
63
  if (scriptId) return `script-id:${scriptId}`;
55
- const src = el.src;
64
+ const src = el.getAttribute(RERUN_SRC_ATTR) || el.src;
56
65
  return src ? `script:${src}` : null;
57
66
  }
58
67
  case "style": {
@@ -70,6 +79,12 @@ async function morphHead(newDocument) {
70
79
  const newElements = /* @__PURE__ */ new Map();
71
80
  const stylesheetPromises = [];
72
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
+ }));
73
88
  for (const el of Array.from(currentHead.children)) {
74
89
  const key = getHeadElementKey(el);
75
90
  if (key) currentElements.set(key, el);
@@ -80,9 +95,11 @@ async function morphHead(newDocument) {
80
95
  }
81
96
  for (const [key, newEl] of newElements) {
82
97
  const currentEl = currentElements.get(key);
98
+ if (isRerunScript(newEl)) {
99
+ continue;
100
+ }
83
101
  if (!currentEl) {
84
- const src = newEl.getAttribute("src");
85
- if (newEl.tagName === "SCRIPT" && src && src.includes("hydration.js") && src.includes("ecopages-react")) {
102
+ if (newEl.tagName === "SCRIPT" && isHydrationScript(newEl)) {
86
103
  continue;
87
104
  }
88
105
  const cloned = newEl.cloneNode(true);
@@ -104,7 +121,7 @@ async function morphHead(newDocument) {
104
121
  }
105
122
  for (const newEl of Array.from(newHead.children)) {
106
123
  const key = getHeadElementKey(newEl);
107
- if (!key) {
124
+ if (!key && !isRerunScript(newEl)) {
108
125
  currentHead.appendChild(newEl.cloneNode(true));
109
126
  }
110
127
  }
@@ -119,12 +136,55 @@ async function morphHead(newDocument) {
119
136
  }
120
137
  }
121
138
  }
122
- return () => {
123
- for (const el of elementsToRemove) {
124
- 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
+ }
125
165
  }
126
166
  };
127
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
+ }
128
188
  export {
129
189
  morphHead
130
190
  };
package/src/router.js CHANGED
@@ -217,7 +217,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
217
217
  if (result) {
218
218
  const { Component, props, doc, finalPath, moduleUrl } = result;
219
219
  const nextPage = { Component, props };
220
- const cleanupHead = await morphHead(doc);
220
+ const { cleanup: cleanupHead, flushRerunScripts } = await morphHead(doc);
221
221
  if (isStale()) {
222
222
  cleanupHead();
223
223
  return;
@@ -257,6 +257,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
257
257
  if (isStale()) {
258
258
  return;
259
259
  }
260
+ flushRerunScripts();
260
261
  cleanupHead();
261
262
  applyViewTransitionNames();
262
263
  } finally {
@@ -266,8 +267,21 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
266
267
  });
267
268
  await navigationCommitPromise;
268
269
  } else {
270
+ pendingRenderRef.current?.resolve();
271
+ const renderDfd = createDeferred();
272
+ pendingRenderRef.current = {
273
+ navigationId,
274
+ page: nextPage,
275
+ resolve: renderDfd.resolve
276
+ };
269
277
  commitPageData(moduleUrl, props);
270
278
  setCurrentPage(nextPage);
279
+ await renderDfd.promise;
280
+ if (isStale()) {
281
+ cleanupHead();
282
+ return;
283
+ }
284
+ flushRerunScripts();
271
285
  cleanupHead();
272
286
  applyViewTransitionNames();
273
287
  }
package/browser.ts DELETED
@@ -1,17 +0,0 @@
1
- /**
2
- * Browser entry point for @ecopages/react-router.
3
- * This file exports only the client-side components needed for hydration.
4
- * @module
5
- */
6
-
7
- export { EcoRouter, PageContent } from './src/router.ts';
8
- export type { EcoRouterProps } from './src/router.ts';
9
-
10
- export { useRouter } from './src/context.ts';
11
- export type { RouterContextValue } from './src/context.ts';
12
- export { EcoPropsScript } from './src/props-script.ts';
13
- export type { EcoPropsScriptProps } from './src/props-script.ts';
14
-
15
- export { morphHead } from './src/head-morpher.ts';
16
-
17
- export type { PageState } from './src/navigation.ts';
package/src/adapter.ts DELETED
@@ -1,48 +0,0 @@
1
- /**
2
- * Router adapter for React integration.
3
- * @module
4
- */
5
-
6
- import type { ReactRouterAdapter } from '@ecopages/react/router-adapter';
7
- import type { EcoRouterOptions } from './types.ts';
8
-
9
- /**
10
- * Creates a ReactRouterAdapter for EcoPages React Router.
11
- * Use this with the React plugin to enable SPA navigation.
12
- *
13
- * @param options - Router configuration options
14
- * @example
15
- * ```ts
16
- * import { reactPlugin } from '@ecopages/react';
17
- * import { ecoRouter } from '@ecopages/react-router';
18
- *
19
- * export default {
20
- * integrations: [reactPlugin({ router: ecoRouter() })],
21
- * };
22
- * ```
23
- *
24
- * @example
25
- * ```ts
26
- * // Disable view transitions
27
- * reactPlugin({ router: ecoRouter({ viewTransitions: false }) })
28
- * ```
29
- */
30
- export function ecoRouter(options?: EcoRouterOptions): ReactRouterAdapter {
31
- return {
32
- name: 'eco-router',
33
- bundle: {
34
- importPath: '@ecopages/react-router/browser.ts',
35
- outputName: 'react-router-esm',
36
- externals: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
37
- },
38
- importMapKey: '@ecopages/react-router',
39
- components: {
40
- router: 'EcoRouter',
41
- pageContent: 'PageContent',
42
- },
43
- getRouterProps(page: string, props: string): string {
44
- const optionsStr = options ? `, options: ${JSON.stringify(options)}` : '';
45
- return `{ page: ${page}, pageProps: ${props}${optionsStr} }`;
46
- },
47
- };
48
- }
package/src/context.ts DELETED
@@ -1,25 +0,0 @@
1
- /**
2
- * Router context and hook for accessing navigation state.
3
- * @module
4
- */
5
-
6
- import { createContext, useContext } from 'react';
7
-
8
- export type RouterContextValue = {
9
- navigate: (url: string) => void;
10
- isNavigating: boolean;
11
- };
12
-
13
- export const RouterContext = createContext<RouterContextValue | null>(null);
14
-
15
- /**
16
- * Hook to access the router's navigate function and navigation state.
17
- * Must be used within an EcoRouter.
18
- *
19
- * @throws Error if used outside of EcoRouter
20
- */
21
- export const useRouter = (): RouterContextValue => {
22
- const context = useContext(RouterContext);
23
- if (!context) throw new Error('useRouter must be used within EcoRouter');
24
- return context;
25
- };
@@ -1,214 +0,0 @@
1
- /**
2
- * Head morphing utilities for client-side navigation.
3
- * Intelligently syncs head elements between pages using key-based diffing.
4
- * @module
5
- */
6
-
7
- const PRESERVE_SELECTORS = ['script[type="importmap"]', 'meta[charset]', '[data-eco-persist]'];
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
-
49
- /**
50
- * Computes a unique key for a head element to enable diffing.
51
- * Elements with the same key are considered the same across navigations.
52
- */
53
- function getHeadElementKey(el: Element): string | null {
54
- const tag = el.tagName.toLowerCase();
55
-
56
- switch (tag) {
57
- case 'title':
58
- return 'title';
59
-
60
- case 'meta': {
61
- const name = el.getAttribute('name') || el.getAttribute('property') || el.getAttribute('http-equiv');
62
- return name ? `meta:${name}` : null;
63
- }
64
-
65
- case 'link': {
66
- const rel = el.getAttribute('rel');
67
- const href = el.getAttribute('href');
68
- if (rel === 'stylesheet' && href) return `stylesheet:${href}`;
69
- if (rel === 'icon' || rel === 'shortcut icon') return 'favicon';
70
- if (rel === 'canonical') return 'canonical';
71
- return href ? `link:${href}` : null;
72
- }
73
-
74
- case 'script': {
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}`;
78
- const src = (el as HTMLScriptElement).src;
79
- return src ? `script:${src}` : null;
80
- }
81
-
82
- case 'style': {
83
- const dataId = el.getAttribute('data-eco-style');
84
- return dataId ? `style:${dataId}` : null;
85
- }
86
-
87
- default:
88
- return null;
89
- }
90
- }
91
-
92
- /**
93
- * Morphs the current document head to match the new document's head.
94
- * Now splits the process into adding new elements and returning a cleanup function
95
- * to remove old ones. This is crucial for View Transitions to ensure styles
96
- * don't disappear before the "old" snapshot is taken.
97
- *
98
- * @param newDocument - The parsed document from the navigation target
99
- * @returns Promise that resolves to a cleanup function when new stylesheets have loaded
100
- */
101
- export async function morphHead(newDocument: Document): Promise<() => void> {
102
- const currentHead = document.head;
103
- const newHead = newDocument.head;
104
-
105
- const currentElements = new Map<string, Element>();
106
- const newElements = new Map<string, Element>();
107
- const stylesheetPromises: Promise<void>[] = [];
108
- const elementsToRemove: Element[] = [];
109
-
110
- /**
111
- * First, map existing head elements by their keys
112
- * to enable efficient diffing.
113
- */
114
- for (const el of Array.from(currentHead.children)) {
115
- const key = getHeadElementKey(el);
116
- if (key) currentElements.set(key, el);
117
- }
118
-
119
- /**
120
- * Next, map new head elements by their keys.
121
- * This allows us to see which elements are new, updated, or removed.
122
- */
123
- for (const el of Array.from(newHead.children)) {
124
- const key = getHeadElementKey(el);
125
- if (key) newElements.set(key, el);
126
- }
127
-
128
- /**
129
- * Now, iterate over new elements to add or update them in the current head.
130
- */
131
- for (const [key, newEl] of newElements) {
132
- const currentEl = currentElements.get(key);
133
-
134
- if (!currentEl) {
135
- const src = newEl.getAttribute('src');
136
- /**
137
- * Skip hydration scripts during SPA navigation to prevent re-mounting
138
- *
139
- * In an SPA transition, the EcoRouter is already running and handling the page update.
140
- * The new page's HTML includes a hydration script (for initial load support), but
141
- * if we let it execute now, it would re-bootstrap the React app from scratch,
142
- * causing a full re-mount, state loss, and a visual flash.
143
- *
144
- * By blocking this script, we ensure the router maintains control and state.
145
- */
146
- if (newEl.tagName === 'SCRIPT' && src && src.includes('hydration.js') && src.includes('ecopages-react')) {
147
- continue;
148
- }
149
-
150
- const cloned = newEl.cloneNode(true) as Element;
151
-
152
- /**
153
- * If the new element is a stylesheet, we need to wait for it to load
154
- * before considering the head morph complete. This prevents FOUC.
155
- */
156
- if (cloned.tagName === 'LINK' && (cloned as HTMLLinkElement).rel === 'stylesheet') {
157
- const loadPromise = new Promise<void>((resolve) => {
158
- (cloned as HTMLLinkElement).onload = () => resolve();
159
- (cloned as HTMLLinkElement).onerror = () => resolve();
160
- });
161
- stylesheetPromises.push(loadPromise);
162
- }
163
-
164
- currentHead.appendChild(cloned);
165
- } else if (key === 'title' && currentEl.textContent !== newEl.textContent) {
166
- currentEl.textContent = newEl.textContent;
167
- } else if (isNonExecutableHeadScript(newEl) && currentEl.textContent !== newEl.textContent) {
168
- currentEl.textContent = newEl.textContent;
169
- } else if (key.startsWith('style:') && currentEl.textContent !== newEl.textContent) {
170
- currentEl.textContent = newEl.textContent;
171
- }
172
- }
173
-
174
- /**
175
- * Finally, handle any new elements without keys (e.g., inline scripts/styles)
176
- */
177
- for (const newEl of Array.from(newHead.children)) {
178
- const key = getHeadElementKey(newEl);
179
- if (!key) {
180
- currentHead.appendChild(newEl.cloneNode(true));
181
- }
182
- }
183
-
184
- /**
185
- * Wait for all new stylesheets to load before proceeding.
186
- */
187
- if (stylesheetPromises.length > 0) {
188
- await Promise.all(stylesheetPromises);
189
- }
190
-
191
- /**
192
- * Identify and prepare to remove any old elements
193
- * that are no longer present in the new head.
194
- */
195
- for (const [key, el] of currentElements) {
196
- if (!newElements.has(key)) {
197
- const shouldPreserve = PRESERVE_SELECTORS.some((sel) => el.matches(sel));
198
- if (!shouldPreserve && !shouldPersistExecutableInlineHeadScript(el)) {
199
- elementsToRemove.push(el);
200
- }
201
- }
202
- }
203
-
204
- /**
205
- * Return a cleanup function to remove old elements.
206
- * This allows the caller to control when the removal happens,
207
- * which is important for View Transitions.
208
- */
209
- return () => {
210
- for (const el of elementsToRemove) {
211
- el.remove();
212
- }
213
- };
214
- }
package/src/index.ts DELETED
@@ -1,21 +0,0 @@
1
- /**
2
- * EcoPages React Router - SPA navigation for React with SSR support.
3
- * @module
4
- */
5
-
6
- export { EcoRouter, PageContent } from './router.ts';
7
- export type { EcoRouterProps } from './router.ts';
8
-
9
- export { EcoPropsScript } from './props-script.ts';
10
- export type { EcoPropsScriptProps } from './props-script.ts';
11
-
12
- export { useRouter } from './context.ts';
13
- export type { RouterContextValue } from './context.ts';
14
-
15
- export type { EcoRouterOptions } from './types.ts';
16
-
17
- export { morphHead } from './head-morpher.ts';
18
-
19
- export type { PageState } from './navigation.ts';
20
-
21
- export { ecoRouter } from './adapter.ts';
@@ -1,47 +0,0 @@
1
- /**
2
- * Manages scroll position during navigations
3
- * @module
4
- */
5
-
6
- import type { EcoRouterOptions } from './types.ts';
7
-
8
- /**
9
- * Service for handling scroll position during page transitions.
10
- * Handles window scroll behavior and hash navigation.
11
- */
12
- /**
13
- * Handle window scroll position based on scrollBehavior option.
14
- * Hash links always scroll to target regardless of option.
15
- */
16
- export function manageScroll(
17
- newUrl: URL,
18
- previousUrl: URL,
19
- options: {
20
- scrollBehavior: Required<EcoRouterOptions>['scrollBehavior'];
21
- smoothScroll: boolean;
22
- },
23
- ): void {
24
- const { scrollBehavior, smoothScroll } = options;
25
-
26
- if (newUrl.hash) {
27
- const target = document.getElementById(newUrl.hash.slice(1));
28
- target?.scrollIntoView({ behavior: smoothScroll ? 'smooth' : 'instant' });
29
- return;
30
- }
31
-
32
- const behavior = smoothScroll ? 'smooth' : 'instant';
33
-
34
- switch (scrollBehavior) {
35
- case 'preserve':
36
- break;
37
- case 'auto':
38
- if (newUrl.pathname !== previousUrl.pathname) {
39
- window.scrollTo({ top: 0, left: 0, behavior });
40
- }
41
- break;
42
- case 'top':
43
- default:
44
- window.scrollTo({ top: 0, left: 0, behavior });
45
- break;
46
- }
47
- }