@ecopages/react-router 0.2.0-alpha.10 → 0.2.0-alpha.11

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
@@ -8,10 +8,8 @@ All notable changes to `@ecopages/react-router` are documented here.
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.10",
3
+ "version": "0.2.0-alpha.11",
4
4
  "description": "Client-side SPA router for EcoPages React applications",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -32,8 +32,8 @@
32
32
  "directory": "packages/react-router"
33
33
  },
34
34
  "peerDependencies": {
35
- "@ecopages/core": "0.2.0-alpha.10",
36
- "@ecopages/react": "0.2.0-alpha.10"
35
+ "@ecopages/core": "0.2.0-alpha.11",
36
+ "@ecopages/react": "0.2.0-alpha.11"
37
37
  },
38
38
  "dependencies": {
39
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,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
  }