@ecopages/react-router 0.2.0-alpha.3 → 0.2.0-alpha.31

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/src/navigation.js CHANGED
@@ -1,3 +1,14 @@
1
+ import { getEcoDocumentOwner } from "@ecopages/core/router/navigation-coordinator";
2
+ import { isReactPageHydrationAssetSrc } from "./hydration-assets.js";
3
+ const ROUTER_PROPS_SCRIPT_ID = "__ECO_PAGE_DATA__";
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;
11
+ }
1
12
  function getInterceptDecision(event, link, options) {
2
13
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
3
14
  return { shouldIntercept: false, reason: "modified-click" };
@@ -13,26 +24,51 @@ function getInterceptDecision(event, link, options) {
13
24
  }
14
25
  const url = new URL(href, window.location.origin);
15
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
+ }
16
30
  return { shouldIntercept: true };
17
31
  }
18
32
  function extractComponentUrlFromMarker(doc) {
19
- if (doc === document && window.__ECO_PAGE__?.module) {
20
- return window.__ECO_PAGE__.module;
33
+ if (doc === document && window.__ECO_PAGES__?.page?.module) {
34
+ return window.__ECO_PAGES__.page.module;
21
35
  }
22
36
  return null;
23
37
  }
24
38
  const DEFAULT_IMPORT_REGEX = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/;
25
39
  const NAMESPACE_IMPORT_REGEX = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]([^'"]+)['"]/;
26
- 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
+ function extractModulePathFromCode(code, fallbackUrl) {
43
+ const markerMatch = code.match(PAGE_MODULE_MARKER_REGEX);
44
+ if (markerMatch) {
45
+ return markerMatch[1] ?? null;
46
+ }
47
+ const moduleIdentifier = code.match(PAGE_MODULE_IDENTIFIER_REGEX)?.[1];
48
+ if (moduleIdentifier) {
49
+ const assignmentRegex = new RegExp(
50
+ `(?:const|let|var)[^;]*\\b${moduleIdentifier}\\s*=\\s*(?:['"]([^'"]+)['"]|(import\\.meta\\.url))`
51
+ );
52
+ const assignmentMatch = code.match(assignmentRegex);
53
+ if (assignmentMatch?.[1]) {
54
+ return assignmentMatch[1];
55
+ }
56
+ if (fallbackUrl && assignmentMatch?.[2]) {
57
+ return fallbackUrl;
58
+ }
59
+ }
60
+ if (fallbackUrl && code.includes("module:import.meta.url")) {
61
+ return fallbackUrl;
62
+ }
27
63
  const defaultMatch = code.match(DEFAULT_IMPORT_REGEX);
28
64
  const namespaceMatch = code.match(NAMESPACE_IMPORT_REGEX);
29
65
  return (defaultMatch || namespaceMatch)?.[2] ?? null;
30
66
  }
31
67
  function extractProps(doc) {
32
- if (doc === document && window.__ECO_PAGE__?.props) {
33
- return window.__ECO_PAGE__.props;
68
+ if (doc === document && window.__ECO_PAGES__?.page?.props) {
69
+ return window.__ECO_PAGES__.page.props;
34
70
  }
35
- const propsScript = doc.getElementById("__ECO_PAGE_DATA__");
71
+ const propsScript = doc.getElementById(ROUTER_PROPS_SCRIPT_ID);
36
72
  if (propsScript?.textContent) {
37
73
  try {
38
74
  return JSON.parse(propsScript.textContent);
@@ -43,6 +79,9 @@ function extractProps(doc) {
43
79
  }
44
80
  return {};
45
81
  }
82
+ function isReactRouteDocument(doc) {
83
+ return getEcoDocumentOwner(doc) === "react-router";
84
+ }
46
85
  function addCacheBuster(url) {
47
86
  if (import.meta.env?.MODE === "production" || import.meta.env?.PROD) {
48
87
  return url;
@@ -55,66 +94,85 @@ async function extractComponentUrl(doc) {
55
94
  if (markerUrl) return markerUrl;
56
95
  const scripts = Array.from(doc.querySelectorAll("script"));
57
96
  const inlineHydrationScript = scripts.find(
58
- (s) => !s.src && !!s.textContent && s.textContent.includes("__ECO_PAGE__") && s.textContent.includes("hydrateRoot") && s.textContent.includes("import")
97
+ (s) => !s.src && !!s.textContent && s.textContent.includes("__ECO_PAGES__") && s.textContent.includes("hydrateRoot") && s.textContent.includes("import")
59
98
  );
60
99
  if (inlineHydrationScript?.textContent) {
61
100
  return extractModulePathFromCode(inlineHydrationScript.textContent);
62
101
  }
63
- const hydrationScript = scripts.find((s) => s.src?.includes("hydration.js") && s.src?.includes("ecopages-react"));
102
+ const hydrationScript = scripts.find((s) => isReactPageHydrationAssetSrc(s.src ?? ""));
64
103
  if (!hydrationScript?.src) return null;
65
104
  try {
66
105
  const scriptUrl = addCacheBuster(hydrationScript.src);
67
106
  const res = await fetch(scriptUrl);
68
107
  const code = await res.text();
69
- return extractModulePathFromCode(code);
108
+ return extractModulePathFromCode(code, hydrationScript.src);
70
109
  } catch {
71
110
  return null;
72
111
  }
73
112
  }
74
- async function loadPageModule(url) {
113
+ async function loadPageModule(url, options = {}) {
114
+ const fetchedPage = await fetchPageDocument(url, options);
115
+ if (!fetchedPage) {
116
+ return null;
117
+ }
118
+ return loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
119
+ }
120
+ async function fetchPageDocument(url, options = {}) {
75
121
  try {
76
- const res = await fetch(url);
122
+ const res = await fetch(url, {
123
+ signal: options.signal,
124
+ headers: {
125
+ Accept: "text/html"
126
+ }
127
+ });
77
128
  const html = await res.text();
78
129
  const finalUrl = new URL(res.url || url, window.location.origin);
79
130
  const finalPath = finalUrl.pathname + finalUrl.search;
80
131
  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");
132
+ return { doc, finalPath, html };
133
+ } catch (e) {
134
+ if (e instanceof DOMException && e.name === "AbortError") {
96
135
  return null;
97
136
  }
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
137
  console.error("[EcoRouter] Navigation failed:", e);
108
138
  return null;
109
139
  }
110
140
  }
141
+ async function loadPageModuleFromDocument(doc, finalPath, options = {}) {
142
+ const props = extractProps(doc);
143
+ const componentUrl = options.moduleUrlOverride ?? await extractComponentUrl(doc);
144
+ if (!componentUrl) {
145
+ if (isReactRouteDocument(doc)) {
146
+ console.error("[EcoRouter] Could not find component URL");
147
+ }
148
+ return null;
149
+ }
150
+ const moduleUrl = addCacheBuster(componentUrl);
151
+ const module = await import(
152
+ /* @vite-ignore */
153
+ moduleUrl
154
+ );
155
+ const rawComponent = module.Content || module.default?.Content || module.default;
156
+ const config = module.config || rawComponent?.config;
157
+ if (!rawComponent) {
158
+ console.error("[EcoRouter] No component found in module");
159
+ return null;
160
+ }
161
+ if (config && !rawComponent.config) {
162
+ rawComponent.config = config;
163
+ }
164
+ return { Component: rawComponent, props, doc, finalPath, moduleUrl: componentUrl };
165
+ }
111
166
  function shouldInterceptClick(event, link, options) {
112
167
  return getInterceptDecision(event, link, options).shouldIntercept;
113
168
  }
114
169
  export {
115
170
  extractComponentUrl,
116
171
  extractProps,
172
+ fetchPageDocument,
117
173
  getInterceptDecision,
174
+ isSamePageHashNavigationHref,
118
175
  loadPageModule,
176
+ loadPageModuleFromDocument,
119
177
  shouldInterceptClick
120
178
  };
@@ -5,7 +5,7 @@ export interface EcoPropsScriptProps {
5
5
  }
6
6
  /**
7
7
  * Serializes page props as JSON for SPA navigation.
8
- * The hydration script reads this and sets window.__ECO_PAGE__.
8
+ * The hydration script reads this and sets window.__ECO_PAGES__.page.
9
9
  * Using application/json allows direct parsing without regex.
10
10
  */
11
11
  export declare const EcoPropsScript: FC<EcoPropsScriptProps>;
package/src/router.d.ts CHANGED
@@ -29,7 +29,11 @@ export declare function clearLayoutCache(): void;
29
29
  * Renders the current page with its layout.
30
30
  *
31
31
  * Must be a child of {@link EcoRouter}. When `persistLayouts` is enabled,
32
- * shared layouts remain mounted across navigations.
32
+ * shared layouts remain mounted across navigations. When the server serialized
33
+ * request `locals` for hydration, the same `locals` object is passed to the
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`.
33
37
  *
34
38
  * @example
35
39
  * ```tsx