@ecopages/react-router 0.2.0-alpha.22 → 0.2.0-alpha.25

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,6 +8,8 @@ All notable changes to `@ecopages/react-router` are documented here.
8
8
 
9
9
  ### Bug Fixes
10
10
 
11
+ - Extended page-module extraction to honor explicit hydration markers and self-owned React page entry bundles during navigation.
12
+ - Fixed current-page reloads to accept HMR module overrides so persisted-layout refreshes import the rebuilt active page entry.
11
13
  - Fixed React-to-browser-router handoffs, queued-click replay, and stale-navigation races during mixed-router navigations.
12
14
  - Standardized route payload reads, document-owner markers, rerun scripts, and current-page HMR refreshes for persisted React layouts.
13
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react-router",
3
- "version": "0.2.0-alpha.22",
3
+ "version": "0.2.0-alpha.25",
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.22",
36
- "@ecopages/react": "0.2.0-alpha.22"
35
+ "@ecopages/core": "0.2.0-alpha.25",
36
+ "@ecopages/react": "0.2.0-alpha.25"
37
37
  },
38
38
  "dependencies": {
39
39
  "react": "^19",
@@ -1,3 +1,4 @@
1
+ import { isReactRouterPageBootstrapAssetSrc } from "./hydration-assets.js";
1
2
  const PRESERVE_SELECTORS = ['script[type="importmap"]', "meta[charset]", "[data-eco-persist]"];
2
3
  const RERUN_SRC_ATTR = "data-eco-rerun-src";
3
4
  let rerunNonce = 0;
@@ -38,7 +39,7 @@ function isRerunScript(el) {
38
39
  }
39
40
  function isHydrationScript(el) {
40
41
  const src = el.getAttribute("src");
41
- return !!src && src.includes("hydration.js") && src.includes("ecopages-react");
42
+ return !!src && isReactRouterPageBootstrapAssetSrc(src);
42
43
  }
43
44
  function getHeadElementKey(el) {
44
45
  const tag = el.tagName.toLowerCase();
@@ -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,6 +23,16 @@ 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
  } | {
@@ -69,7 +79,21 @@ export declare function extractComponentUrl(doc: Document): Promise<string | nul
69
79
  */
70
80
  export declare function loadPageModule(url: string, options?: LoadPageModuleOptions): Promise<LoadedPageModule | null>;
71
81
  export declare function fetchPageDocument(url: string, options?: LoadPageModuleOptions): Promise<FetchedPageDocument | null>;
72
- export declare function loadPageModuleFromDocument(doc: Document, finalPath: string): Promise<LoadedPageModule | null>;
82
+ /**
83
+ * Loads the page module for a fetched or current document.
84
+ *
85
+ * The router normally extracts the page module URL from the document's
86
+ * hydration assets. Callers can provide `options.moduleUrlOverride` when the
87
+ * document is stale with respect to the active runtime module identity, such as
88
+ * during HMR-driven current-page reloads.
89
+ *
90
+ * @param doc - Parsed destination document.
91
+ * @param finalPath - Final route path after redirects.
92
+ * @param options - Module loading overrides.
93
+ * @returns Loaded page module payload or `null` when the document is not a
94
+ * React-router page or no page component can be resolved.
95
+ */
96
+ export declare function loadPageModuleFromDocument(doc: Document, finalPath: string, options?: LoadPageModuleFromDocumentOptions): Promise<LoadedPageModule | null>;
73
97
  /**
74
98
  * Convenience wrapper around getInterceptDecision that returns a boolean.
75
99
  * Use getInterceptDecision directly when you need the reason for debugging.
package/src/navigation.js CHANGED
@@ -1,8 +1,6 @@
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-");
5
- }
6
4
  function getInterceptDecision(event, link, options) {
7
5
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
8
6
  return { shouldIntercept: false, reason: "modified-click" };
@@ -28,7 +26,29 @@ function extractComponentUrlFromMarker(doc) {
28
26
  }
29
27
  const DEFAULT_IMPORT_REGEX = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/;
30
28
  const NAMESPACE_IMPORT_REGEX = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]([^'"]+)['"]/;
31
- function extractModulePathFromCode(code) {
29
+ const PAGE_MODULE_MARKER_REGEX = /module\s*:\s*['"]([^'"]+)['"]/;
30
+ const PAGE_MODULE_IDENTIFIER_REGEX = /module\s*:\s*([A-Za-z_$][\w$]*)\s*,/;
31
+ function extractModulePathFromCode(code, fallbackUrl) {
32
+ const markerMatch = code.match(PAGE_MODULE_MARKER_REGEX);
33
+ if (markerMatch) {
34
+ return markerMatch[1] ?? null;
35
+ }
36
+ const moduleIdentifier = code.match(PAGE_MODULE_IDENTIFIER_REGEX)?.[1];
37
+ if (moduleIdentifier) {
38
+ const assignmentRegex = new RegExp(
39
+ `(?:const|let|var)[^;]*\\b${moduleIdentifier}\\s*=\\s*(?:['"]([^'"]+)['"]|(import\\.meta\\.url))`
40
+ );
41
+ const assignmentMatch = code.match(assignmentRegex);
42
+ if (assignmentMatch?.[1]) {
43
+ return assignmentMatch[1];
44
+ }
45
+ if (fallbackUrl && assignmentMatch?.[2]) {
46
+ return fallbackUrl;
47
+ }
48
+ }
49
+ if (fallbackUrl && code.includes("module:import.meta.url")) {
50
+ return fallbackUrl;
51
+ }
32
52
  const defaultMatch = code.match(DEFAULT_IMPORT_REGEX);
33
53
  const namespaceMatch = code.match(NAMESPACE_IMPORT_REGEX);
34
54
  return (defaultMatch || namespaceMatch)?.[2] ?? null;
@@ -68,13 +88,13 @@ async function extractComponentUrl(doc) {
68
88
  if (inlineHydrationScript?.textContent) {
69
89
  return extractModulePathFromCode(inlineHydrationScript.textContent);
70
90
  }
71
- const hydrationScript = scripts.find((s) => isReactPageHydrationAsset(s.src ?? ""));
91
+ const hydrationScript = scripts.find((s) => isReactPageHydrationAssetSrc(s.src ?? ""));
72
92
  if (!hydrationScript?.src) return null;
73
93
  try {
74
94
  const scriptUrl = addCacheBuster(hydrationScript.src);
75
95
  const res = await fetch(scriptUrl);
76
96
  const code = await res.text();
77
- return extractModulePathFromCode(code);
97
+ return extractModulePathFromCode(code, hydrationScript.src);
78
98
  } catch {
79
99
  return null;
80
100
  }
@@ -107,9 +127,9 @@ async function fetchPageDocument(url, options = {}) {
107
127
  return null;
108
128
  }
109
129
  }
110
- async function loadPageModuleFromDocument(doc, finalPath) {
130
+ async function loadPageModuleFromDocument(doc, finalPath, options = {}) {
111
131
  const props = extractProps(doc);
112
- const componentUrl = await extractComponentUrl(doc);
132
+ const componentUrl = options.moduleUrlOverride ?? await extractComponentUrl(doc);
113
133
  if (!componentUrl) {
114
134
  if (isReactRouteDocument(doc)) {
115
135
  console.error("[EcoRouter] Could not find component URL");
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
package/src/router.js CHANGED
@@ -64,7 +64,7 @@ const PageContent = () => {
64
64
  }
65
65
  return null;
66
66
  }
67
- const { Component: Page, props } = pageContext;
67
+ const { Component: Page, props, refreshPersistedLayout } = pageContext;
68
68
  const Layout = getLayoutFromPage(Page);
69
69
  const pageElement = createElement(Page, props);
70
70
  const layoutProps = props?.locals ? { locals: props.locals } : null;
@@ -76,7 +76,7 @@ const PageContent = () => {
76
76
  const layoutConfig = Layout.config;
77
77
  const layoutKeyRaw = layoutConfig?.__eco?.id || Layout.displayName || Layout.name || "layout";
78
78
  const layoutKey = normalizeLayoutKey(layoutKeyRaw);
79
- if (!layoutCache.has(layoutKey)) {
79
+ if (!layoutCache.has(layoutKey) || refreshPersistedLayout && layoutCache.get(layoutKey) !== Layout) {
80
80
  layoutCache.set(layoutKey, Layout);
81
81
  }
82
82
  const CachedLayout = layoutCache.get(layoutKey);
@@ -114,7 +114,7 @@ function useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef
114
114
  clearLayoutCache();
115
115
  }
116
116
  const currentUrl = window.location.pathname + window.location.search;
117
- await navigate(currentUrl);
117
+ await navigate(currentUrl, { moduleUrlOverride: request?.moduleUrl });
118
118
  },
119
119
  cleanupBeforeHandoff: async () => {
120
120
  runtimeActiveRef.current = false;
@@ -136,7 +136,11 @@ function useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef
136
136
  }
137
137
  const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
138
138
  const options = useMemo(() => ({ ...DEFAULT_OPTIONS, ...userOptions }), [userOptions]);
139
- const [currentPage, setCurrentPage] = useState({ Component: page, props: pageProps });
139
+ const [currentPage, setCurrentPage] = useState({
140
+ Component: page,
141
+ props: pageProps,
142
+ refreshPersistedLayout: false
143
+ });
140
144
  const [isNavigating, setIsNavigating] = useState(false);
141
145
  const pendingRenderRef = useRef(null);
142
146
  const activeNavigationRef = useRef(null);
@@ -145,13 +149,12 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
145
149
  const pendingPointerNavigationRef = useRef(null);
146
150
  const pendingHoverNavigationRef = useRef(null);
147
151
  const queuedNavigationHrefRef = useRef(null);
148
- const pendingScrollRestoreRef = useRef(null);
149
152
  const previousUrlRef = useRef(typeof window !== "undefined" ? window.location.href : "");
150
153
  useEffect(() => {
151
154
  isNavigatingRef.current = isNavigating;
152
155
  }, [isNavigating]);
153
156
  useEffect(() => {
154
- setCurrentPage({ Component: page, props: pageProps });
157
+ setCurrentPage({ Component: page, props: pageProps, refreshPersistedLayout: true });
155
158
  }, [page, pageProps]);
156
159
  useEffect(() => {
157
160
  applyViewTransitionNames();
@@ -182,15 +185,15 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
182
185
  });
183
186
  previousUrlRef.current = url.href;
184
187
  }
185
- if (pendingScrollRestoreRef.current) {
186
- const { url: targetUrl, isPopState } = pendingScrollRestoreRef.current;
187
- restoreScrollPositions(targetUrl, isPopState);
188
- pendingScrollRestoreRef.current = null;
189
- }
190
188
  }, [currentPage, options.scrollBehavior, options.smoothScroll]);
191
189
  const navigate = useCallback(
192
190
  async (url, navigationOptions = {}) => {
193
- const { isPopState = false, pushHistory = false, skipViewTransition = false } = navigationOptions;
191
+ const {
192
+ isPopState = false,
193
+ pushHistory = false,
194
+ skipViewTransition = false,
195
+ moduleUrlOverride
196
+ } = navigationOptions;
194
197
  const navigationRuntime = getEcoNavigationRuntime(window);
195
198
  const navigation = navigationRuntime.beginNavigationTransaction();
196
199
  activeNavigationRef.current = navigation;
@@ -212,24 +215,25 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
212
215
  window.location.href = url;
213
216
  return;
214
217
  }
215
- const result = await loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
218
+ const result = await loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath, {
219
+ moduleUrlOverride
220
+ });
216
221
  if (isStale()) return;
217
222
  if (result) {
218
223
  const { Component, props, doc, finalPath, moduleUrl } = result;
219
- const nextPage = { Component, props };
224
+ const nextPage = { Component, props, refreshPersistedLayout: Boolean(moduleUrlOverride) };
220
225
  const { cleanup: cleanupHead, flushRerunScripts } = await morphHead(doc);
221
226
  if (isStale()) {
222
227
  cleanupHead();
223
228
  return;
224
229
  }
225
230
  applyViewTransitionNames();
231
+ saveScrollPositions();
226
232
  if (pushHistory) {
227
233
  window.history.pushState(null, "", finalPath);
228
234
  } else if (finalPath !== url) {
229
235
  window.history.replaceState(null, "", finalPath);
230
236
  }
231
- saveScrollPositions();
232
- pendingScrollRestoreRef.current = { url, isPopState };
233
237
  if (!skipViewTransition && options.viewTransitions && document.startViewTransition) {
234
238
  pendingRenderRef.current?.resolve();
235
239
  const renderDfd = createDeferred();
@@ -260,6 +264,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
260
264
  flushRerunScripts();
261
265
  cleanupHead();
262
266
  applyViewTransitionNames();
267
+ restoreScrollPositions(finalPath, isPopState);
263
268
  } finally {
264
269
  resolve();
265
270
  }
@@ -284,6 +289,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
284
289
  flushRerunScripts();
285
290
  cleanupHead();
286
291
  applyViewTransitionNames();
292
+ restoreScrollPositions(finalPath, isPopState);
287
293
  }
288
294
  } else {
289
295
  if (isStale()) return;
@@ -28,19 +28,27 @@ function restoreScrollPositions(targetUrl, isPopState) {
28
28
  currentScrollSnapshot = null;
29
29
  return;
30
30
  }
31
- requestAnimationFrame(() => {
31
+ const restore = (remainingAttempts) => {
32
32
  requestAnimationFrame(() => {
33
+ const restoredKeys = /* @__PURE__ */ new Set();
33
34
  document.querySelectorAll(PERSIST_SELECTOR).forEach((el) => {
34
35
  const key = getElementKey(el);
35
- if (key && positions.has(key)) {
36
- const pos = positions.get(key);
37
- el.scrollTop = pos.top;
38
- el.scrollLeft = pos.left;
36
+ if (!key || !positions.has(key)) {
37
+ return;
39
38
  }
39
+ const pos = positions.get(key);
40
+ el.scrollTop = pos.top;
41
+ el.scrollLeft = pos.left;
42
+ restoredKeys.add(key);
40
43
  });
41
- currentScrollSnapshot = null;
44
+ if (restoredKeys.size === positions.size || remainingAttempts <= 1) {
45
+ currentScrollSnapshot = null;
46
+ return;
47
+ }
48
+ setTimeout(() => restore(remainingAttempts - 1), 50);
42
49
  });
43
- });
50
+ };
51
+ restore(20);
44
52
  }
45
53
  function getScrollPositions(url) {
46
54
  return urlScrollStore.get(url);