@ecopages/react-router 0.2.0-alpha.9 → 0.2.0-beta.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/README.md CHANGED
@@ -23,7 +23,7 @@ This keeps React-router focused on React rendering while still allowing mixed Re
23
23
  ## Installation
24
24
 
25
25
  ```bash
26
- bunx jsr add @ecopages/react-router
26
+ bun add @ecopages/react-router
27
27
  ```
28
28
 
29
29
  ## Quick Start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react-router",
3
- "version": "0.2.0-alpha.9",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Client-side SPA router for EcoPages React applications",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -32,12 +32,12 @@
32
32
  "directory": "packages/react-router"
33
33
  },
34
34
  "peerDependencies": {
35
- "@ecopages/core": "0.2.0-alpha.9",
36
- "@ecopages/react": "0.2.0-alpha.9"
35
+ "@ecopages/core": "0.2.0-beta.1",
36
+ "@ecopages/react": "0.2.0-beta.1"
37
37
  },
38
38
  "dependencies": {
39
- "react": "^19",
40
- "react-dom": "^19.2.4"
39
+ "react": "^19.2.6",
40
+ "react-dom": "^19.2.6"
41
41
  },
42
42
  "types": "./src/index.d.ts"
43
43
  }
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.ts",
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"
@@ -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,7 @@
1
- const PRESERVE_SELECTORS = ['script[type="importmap"]', "meta[charset]", "[data-eco-persist]"];
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;
2
5
  function isNonExecutableHeadScript(el) {
3
6
  if (el.tagName !== "SCRIPT") {
4
7
  return false;
@@ -31,6 +34,17 @@ function shouldPersistExecutableInlineHeadScript(el) {
31
34
  }
32
35
  return !isNonExecutableHeadScript(el);
33
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
+ }
34
48
  function getHeadElementKey(el) {
35
49
  const tag = el.tagName.toLowerCase();
36
50
  switch (tag) {
@@ -49,10 +63,9 @@ function getHeadElementKey(el) {
49
63
  return href ? `link:${href}` : null;
50
64
  }
51
65
  case "script": {
52
- if (el.getAttribute("type") === "importmap") return "importmap";
53
66
  const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
54
67
  if (scriptId) return `script-id:${scriptId}`;
55
- const src = el.src;
68
+ const src = el.getAttribute(RERUN_SRC_ATTR) || el.src;
56
69
  return src ? `script:${src}` : null;
57
70
  }
58
71
  case "style": {
@@ -70,6 +83,12 @@ async function morphHead(newDocument) {
70
83
  const newElements = /* @__PURE__ */ new Map();
71
84
  const stylesheetPromises = [];
72
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
+ }));
73
92
  for (const el of Array.from(currentHead.children)) {
74
93
  const key = getHeadElementKey(el);
75
94
  if (key) currentElements.set(key, el);
@@ -80,9 +99,11 @@ async function morphHead(newDocument) {
80
99
  }
81
100
  for (const [key, newEl] of newElements) {
82
101
  const currentEl = currentElements.get(key);
102
+ if (isRerunScript(newEl)) {
103
+ continue;
104
+ }
83
105
  if (!currentEl) {
84
- const src = newEl.getAttribute("src");
85
- if (newEl.tagName === "SCRIPT" && src && src.includes("hydration.js") && src.includes("ecopages-react")) {
106
+ if (newEl.tagName === "SCRIPT" && isHydrationScript(newEl)) {
86
107
  continue;
87
108
  }
88
109
  const cloned = newEl.cloneNode(true);
@@ -104,7 +125,7 @@ async function morphHead(newDocument) {
104
125
  }
105
126
  for (const newEl of Array.from(newHead.children)) {
106
127
  const key = getHeadElementKey(newEl);
107
- if (!key) {
128
+ if (!key && !isRerunScript(newEl)) {
108
129
  currentHead.appendChild(newEl.cloneNode(true));
109
130
  }
110
131
  }
@@ -119,12 +140,70 @@ async function morphHead(newDocument) {
119
140
  }
120
141
  }
121
142
  }
122
- return () => {
123
- for (const el of elementsToRemove) {
124
- el.remove();
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 registeredRerun = getRegisteredRerunScript(script.scriptId);
152
+ const replacement = document.createElement("script");
153
+ const shouldBustModuleSrc = isExternalModuleRerunScript(script) && !registeredRerun;
154
+ for (const [name, value] of script.attributes) {
155
+ if (name === "src" && shouldBustModuleSrc) {
156
+ replacement.setAttribute(RERUN_SRC_ATTR, value);
157
+ replacement.setAttribute("src", createRerunScriptUrl(value));
158
+ continue;
159
+ }
160
+ replacement.setAttribute(name, value);
161
+ }
162
+ replacement.textContent = script.textContent;
163
+ const existingScript = findExistingRerunScript(script);
164
+ if (registeredRerun) {
165
+ if (!existingScript) {
166
+ document.head.appendChild(replacement);
167
+ }
168
+ registeredRerun();
169
+ continue;
170
+ }
171
+ if (existingScript) {
172
+ existingScript.replaceWith(replacement);
173
+ continue;
174
+ }
175
+ document.head.appendChild(replacement);
176
+ }
125
177
  }
126
178
  };
127
179
  }
180
+ function findExistingRerunScript(script) {
181
+ const scripts = Array.from(document.head.querySelectorAll("script"));
182
+ if (script.scriptId) {
183
+ return scripts.find((candidate) => candidate.getAttribute("data-eco-script-id") === script.scriptId) ?? null;
184
+ }
185
+ return scripts.find(
186
+ (candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
187
+ ) ?? null;
188
+ }
189
+ function isExternalModuleRerunScript(script) {
190
+ if (!script.src) {
191
+ return false;
192
+ }
193
+ return script.attributes.some(([name, value]) => name === "type" && value === "module");
194
+ }
195
+ function getRegisteredRerunScript(scriptId) {
196
+ if (!scriptId) {
197
+ return null;
198
+ }
199
+ const runtimeWindow = window;
200
+ return runtimeWindow.__ECO_PAGES__?.rerunScripts?.[scriptId] ?? null;
201
+ }
202
+ function createRerunScriptUrl(src) {
203
+ const url = new URL(src, document.baseURI);
204
+ url.searchParams.set("__eco_rerun", String(++rerunNonce));
205
+ return url.toString();
206
+ }
128
207
  export {
129
208
  morphHead
130
209
  };
@@ -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
+ };
@@ -23,12 +23,23 @@ export type FetchedPageDocument = {
23
23
  type LoadPageModuleOptions = {
24
24
  signal?: AbortSignal;
25
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
+ };
26
36
  export type InterceptDecision = {
27
37
  shouldIntercept: true;
28
38
  } | {
29
39
  shouldIntercept: false;
30
- 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';
31
41
  };
42
+ export declare function isSamePageHashNavigationHref(href: string): boolean;
32
43
  /**
33
44
  * Determines whether a link click should be intercepted for client-side navigation.
34
45
  *
@@ -69,7 +80,21 @@ export declare function extractComponentUrl(doc: Document): Promise<string | nul
69
80
  */
70
81
  export declare function loadPageModule(url: string, options?: LoadPageModuleOptions): Promise<LoadedPageModule | null>;
71
82
  export declare function fetchPageDocument(url: string, options?: LoadPageModuleOptions): Promise<FetchedPageDocument | null>;
72
- export declare function loadPageModuleFromDocument(doc: Document, finalPath: string): Promise<LoadedPageModule | 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>;
73
98
  /**
74
99
  * Convenience wrapper around getInterceptDecision that returns a boolean.
75
100
  * Use getInterceptDecision directly when you need the reason for debugging.
package/src/navigation.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import { getEcoDocumentOwner } from "@ecopages/core/router/navigation-coordinator";
2
+ import { isReactPageHydrationAssetSrc } from "./hydration-assets.js";
2
3
  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-");
4
+ function isSamePageHashNavigationHref(href) {
5
+ if (!href) {
6
+ return false;
7
+ }
8
+ const currentUrl = new URL(window.location.href);
9
+ const targetUrl = new URL(href, currentUrl);
10
+ return targetUrl.origin === currentUrl.origin && targetUrl.hash.length > 0 && targetUrl.pathname === currentUrl.pathname && targetUrl.search === currentUrl.search;
5
11
  }
6
12
  function getInterceptDecision(event, link, options) {
7
13
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
@@ -18,6 +24,9 @@ function getInterceptDecision(event, link, options) {
18
24
  }
19
25
  const url = new URL(href, window.location.origin);
20
26
  if (url.origin !== window.location.origin) return { shouldIntercept: false, reason: "cross-origin" };
27
+ if (isSamePageHashNavigationHref(href)) {
28
+ return { shouldIntercept: false, reason: "same-page-hash" };
29
+ }
21
30
  return { shouldIntercept: true };
22
31
  }
23
32
  function extractComponentUrlFromMarker(doc) {
@@ -28,7 +37,30 @@ function extractComponentUrlFromMarker(doc) {
28
37
  }
29
38
  const DEFAULT_IMPORT_REGEX = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/;
30
39
  const NAMESPACE_IMPORT_REGEX = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]([^'"]+)['"]/;
31
- function extractModulePathFromCode(code) {
40
+ const PAGE_MODULE_MARKER_REGEX = /__ECO_PAGES__\.page\s*=\s*\{\s*module\s*:\s*['"]([^'"]+)['"]/;
41
+ const PAGE_MODULE_IDENTIFIER_REGEX = /__ECO_PAGES__\.page\s*=\s*\{\s*module\s*:\s*([A-Za-z_$][\w$]*)\s*,/;
42
+ const PAGE_BOOTSTRAP_SELECTOR = 'script[data-eco-page-bootstrap="react-router"]';
43
+ function extractModulePathFromCode(code, fallbackUrl) {
44
+ const markerMatch = code.match(PAGE_MODULE_MARKER_REGEX);
45
+ if (markerMatch) {
46
+ return markerMatch[1] ?? null;
47
+ }
48
+ const moduleIdentifier = code.match(PAGE_MODULE_IDENTIFIER_REGEX)?.[1];
49
+ if (moduleIdentifier) {
50
+ const assignmentRegex = new RegExp(
51
+ `(?:const|let|var)[^;]*\\b${moduleIdentifier}\\s*=\\s*(?:['"]([^'"]+)['"]|(import\\.meta\\.url))`
52
+ );
53
+ const assignmentMatch = code.match(assignmentRegex);
54
+ if (assignmentMatch?.[1]) {
55
+ return assignmentMatch[1];
56
+ }
57
+ if (fallbackUrl && assignmentMatch?.[2]) {
58
+ return fallbackUrl;
59
+ }
60
+ }
61
+ if (fallbackUrl && code.includes("module:import.meta.url")) {
62
+ return fallbackUrl;
63
+ }
32
64
  const defaultMatch = code.match(DEFAULT_IMPORT_REGEX);
33
65
  const namespaceMatch = code.match(NAMESPACE_IMPORT_REGEX);
34
66
  return (defaultMatch || namespaceMatch)?.[2] ?? null;
@@ -68,13 +100,13 @@ async function extractComponentUrl(doc) {
68
100
  if (inlineHydrationScript?.textContent) {
69
101
  return extractModulePathFromCode(inlineHydrationScript.textContent);
70
102
  }
71
- const hydrationScript = scripts.find((s) => isReactPageHydrationAsset(s.src ?? ""));
103
+ const hydrationScript = doc.querySelector(PAGE_BOOTSTRAP_SELECTOR) ?? scripts.find((s) => isReactPageHydrationAssetSrc(s.src ?? ""));
72
104
  if (!hydrationScript?.src) return null;
73
105
  try {
74
106
  const scriptUrl = addCacheBuster(hydrationScript.src);
75
107
  const res = await fetch(scriptUrl);
76
108
  const code = await res.text();
77
- return extractModulePathFromCode(code);
109
+ return extractModulePathFromCode(code, hydrationScript.src);
78
110
  } catch {
79
111
  return null;
80
112
  }
@@ -107,9 +139,9 @@ async function fetchPageDocument(url, options = {}) {
107
139
  return null;
108
140
  }
109
141
  }
110
- async function loadPageModuleFromDocument(doc, finalPath) {
142
+ async function loadPageModuleFromDocument(doc, finalPath, options = {}) {
111
143
  const props = extractProps(doc);
112
- const componentUrl = await extractComponentUrl(doc);
144
+ const componentUrl = options.moduleUrlOverride ?? await extractComponentUrl(doc);
113
145
  if (!componentUrl) {
114
146
  if (isReactRouteDocument(doc)) {
115
147
  console.error("[EcoRouter] Could not find component URL");
@@ -140,6 +172,7 @@ export {
140
172
  extractProps,
141
173
  fetchPageDocument,
142
174
  getInterceptDecision,
175
+ isSamePageHashNavigationHref,
143
176
  loadPageModule,
144
177
  loadPageModuleFromDocument,
145
178
  shouldInterceptClick
package/src/router.d.ts CHANGED
@@ -31,7 +31,9 @@ export declare function clearLayoutCache(): void;
31
31
  * Must be a child of {@link EcoRouter}. When `persistLayouts` is enabled,
32
32
  * shared layouts remain mounted across navigations. When the server serialized
33
33
  * request `locals` for hydration, the same `locals` object is passed to the
34
- * layout on the client so the hydrated tree matches SSR.
34
+ * layout on the client so the hydrated tree matches SSR. Refresh-style updates
35
+ * may still replace the cached layout implementation when the router marks the
36
+ * page state with `refreshPersistedLayout`.
35
37
  *
36
38
  * @example
37
39
  * ```tsx