@ecopages/react 0.2.0-alpha.4 → 0.2.0-alpha.40

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.
Files changed (70) hide show
  1. package/README.md +161 -18
  2. package/package.json +16 -12
  3. package/src/eco-embed.d.ts +11 -0
  4. package/src/eco-embed.js +11 -0
  5. package/src/react-hmr-strategy.d.ts +42 -32
  6. package/src/react-hmr-strategy.js +103 -124
  7. package/src/react-renderer.d.ts +169 -42
  8. package/src/react-renderer.js +484 -164
  9. package/src/react.constants.d.ts +1 -0
  10. package/src/react.constants.js +4 -0
  11. package/src/react.plugin.d.ts +38 -111
  12. package/src/react.plugin.js +132 -61
  13. package/src/react.types.d.ts +88 -0
  14. package/src/react.types.js +0 -0
  15. package/src/router-adapter.d.ts +7 -14
  16. package/src/services/react-bundle.service.d.ts +15 -26
  17. package/src/services/react-bundle.service.js +45 -93
  18. package/src/services/react-hmr-page-metadata-cache.d.ts +9 -0
  19. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  20. package/src/services/react-hydration-asset.service.d.ts +26 -19
  21. package/src/services/react-hydration-asset.service.js +72 -66
  22. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  23. package/src/services/react-mdx-config-dependency.service.js +122 -0
  24. package/src/services/react-page-module.service.d.ts +10 -2
  25. package/src/services/react-page-module.service.js +47 -39
  26. package/src/services/react-page-payload.service.d.ts +46 -0
  27. package/src/services/react-page-payload.service.js +67 -0
  28. package/src/services/react-runtime-bundle.service.d.ts +15 -13
  29. package/src/services/react-runtime-bundle.service.js +103 -180
  30. package/src/utils/client-graph-boundary-plugin.d.ts +1 -1
  31. package/src/utils/client-graph-boundary-plugin.js +149 -11
  32. package/src/utils/component-config-traversal.d.ts +36 -0
  33. package/src/utils/component-config-traversal.js +54 -0
  34. package/src/utils/declared-modules.d.ts +1 -1
  35. package/src/utils/declared-modules.js +7 -16
  36. package/src/utils/dynamic.test.browser.d.ts +1 -0
  37. package/src/utils/dynamic.test.browser.js +33 -0
  38. package/src/utils/hydration-scripts.d.ts +25 -6
  39. package/src/utils/hydration-scripts.js +150 -44
  40. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  41. package/src/utils/hydration-scripts.test.browser.js +198 -0
  42. package/src/utils/reachability-analyzer.d.ts +12 -1
  43. package/src/utils/reachability-analyzer.js +101 -5
  44. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  45. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  46. package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
  47. package/src/utils/react-mdx-loader-plugin.js +13 -5
  48. package/src/utils/react-runtime-alias-map.d.ts +6 -0
  49. package/src/utils/react-runtime-alias-map.js +33 -0
  50. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  51. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  52. package/CHANGELOG.md +0 -62
  53. package/src/react-hmr-strategy.ts +0 -444
  54. package/src/react-renderer.ts +0 -403
  55. package/src/react.plugin.ts +0 -241
  56. package/src/router-adapter.ts +0 -95
  57. package/src/services/react-bundle.service.ts +0 -212
  58. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  59. package/src/services/react-hydration-asset.service.ts +0 -260
  60. package/src/services/react-page-module.service.ts +0 -214
  61. package/src/services/react-runtime-bundle.service.ts +0 -271
  62. package/src/utils/client-graph-boundary-plugin.ts +0 -590
  63. package/src/utils/client-only.ts +0 -27
  64. package/src/utils/declared-modules.ts +0 -99
  65. package/src/utils/dynamic.ts +0 -27
  66. package/src/utils/hmr-scripts.ts +0 -47
  67. package/src/utils/html-boundary.ts +0 -66
  68. package/src/utils/hydration-scripts.ts +0 -338
  69. package/src/utils/reachability-analyzer.ts +0 -440
  70. package/src/utils/react-mdx-loader-plugin.ts +0 -40
@@ -1,3 +1,4 @@
1
+ import { collectFromConfigTree } from "./component-config-traversal.js";
1
2
  function parseDeclaredModuleSource(value) {
2
3
  const source = value.trim();
3
4
  if (source.length === 0) return void 0;
@@ -16,21 +17,8 @@ function normalizeDeclaredModuleSources(modules) {
16
17
  }
17
18
  return Array.from(seen);
18
19
  }
19
- function collectDeclaredModulesInConfig(config, visited = /* @__PURE__ */ new Set()) {
20
- if (!config || visited.has(config)) {
21
- return [];
22
- }
23
- visited.add(config);
24
- const declarations = normalizeDeclaredModuleSources(config.dependencies?.modules);
25
- if (config.layout?.config) {
26
- declarations.push(...collectDeclaredModulesInConfig(config.layout.config, visited));
27
- }
28
- for (const component of config.dependencies?.components ?? []) {
29
- if (component.config) {
30
- declarations.push(...collectDeclaredModulesInConfig(component.config, visited));
31
- }
32
- }
33
- return declarations;
20
+ function collectDeclaredModulesInConfig(config) {
21
+ return collectFromConfigTree(config, (node) => normalizeDeclaredModuleSources(node.dependencies?.modules));
34
22
  }
35
23
  function collectPageDeclaredModulesFromModule(pageModule) {
36
24
  const declarations = [
@@ -41,7 +29,10 @@ function collectPageDeclaredModulesFromModule(pageModule) {
41
29
  }
42
30
  async function collectPageDeclaredModules(pagePath) {
43
31
  try {
44
- const pageModule = await import(pagePath);
32
+ const pageModule = await import(
33
+ /* @vite-ignore */
34
+ pagePath
35
+ );
45
36
  return collectPageDeclaredModulesFromModule(pageModule);
46
37
  } catch {
47
38
  return [];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { Suspense } from "react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { cleanup, render, screen } from "@testing-library/react";
5
+ import { dynamic } from "./dynamic.js";
6
+ function createDeferredImport() {
7
+ let resolve;
8
+ const promise = new Promise((innerResolve) => {
9
+ resolve = innerResolve;
10
+ });
11
+ return {
12
+ promise,
13
+ resolve
14
+ };
15
+ }
16
+ describe("dynamic", () => {
17
+ afterEach(() => {
18
+ cleanup();
19
+ });
20
+ it("returns a browser lazy component that resolves through Suspense", async () => {
21
+ const deferredImport = createDeferredImport();
22
+ const DynamicComponent = dynamic(() => deferredImport.promise);
23
+ render(
24
+ /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx("span", { children: "Loading dynamic component" }), children: /* @__PURE__ */ jsx(DynamicComponent, {}) })
25
+ );
26
+ expect(screen.getByText("Loading dynamic component")).toBeTruthy();
27
+ deferredImport.resolve({
28
+ default: () => /* @__PURE__ */ jsx("span", { children: "Dynamic content" })
29
+ });
30
+ expect(await screen.findByText("Dynamic content")).toBeTruthy();
31
+ expect(screen.queryByText("Loading dynamic component")).toBeNull();
32
+ });
33
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Hydration script generators for React pages.
3
- * These functions create the client-side scripts that hydrate React components.
3
+ * These functions create the page entry modules that hydrate React routes.
4
4
  * @module
5
5
  */
6
6
  import type { ReactRouterAdapter } from '../router-adapter.js';
@@ -8,8 +8,12 @@ import type { ReactRouterAdapter } from '../router-adapter.js';
8
8
  * Options for generating a hydration script.
9
9
  */
10
10
  export type HydrationScriptOptions = {
11
- /** The import path for the bundled page component */
11
+ /** The module path imported by the page entry module. */
12
12
  importPath: string;
13
+ /** Browser expression that resolves to the page module URL the router should import. */
14
+ pageModuleUrlExpression?: string;
15
+ /** Stable id of the page entry script tag in the document. */
16
+ scriptId: string;
13
17
  /** Direct import path for React runtime module */
14
18
  reactImportPath: string;
15
19
  /** Direct import path for react-dom/client runtime module */
@@ -30,10 +34,8 @@ export type IslandHydrationScriptOptions = {
30
34
  reactImportPath: string;
31
35
  /** Browser import path for react-dom/client runtime. */
32
36
  reactDomClientImportPath: string;
33
- /** Selector that resolves to the SSR root element for this island instance. */
37
+ /** Selector that resolves to all SSR root elements for this island component. */
34
38
  targetSelector: string;
35
- /** Serialized component props emitted at render time. */
36
- props: Record<string, unknown>;
37
39
  /** Optional stable component id used to resolve named exports reliably. */
38
40
  componentRef?: string;
39
41
  /** Optional source file hint used as fallback for component resolution. */
@@ -43,7 +45,18 @@ export type IslandHydrationScriptOptions = {
43
45
  };
44
46
  /**
45
47
  * Creates a hydration script for client-side React hydration.
46
- * Generates appropriate script based on environment and router configuration.
48
+ *
49
+ * Why this dispatcher exists:
50
+ * the runtime matrix is small but behaviorally different across development vs
51
+ * production and router vs non-router pages. Keeping that branch here preserves
52
+ * a compact public API while allowing each emitted script to stay focused.
53
+ *
54
+ * Selection rules:
55
+ * - development uses readable scripts with HMR hooks
56
+ * - production uses minified equivalents
57
+ * - router presence decides whether page updates flow through the router runtime
58
+ * or rebuild directly from the page module
59
+ *
47
60
  * @param options - Configuration options for script generation
48
61
  * @returns The generated hydration script as a string
49
62
  */
@@ -63,8 +76,14 @@ export declare function createHydrationScript(options: HydrationScriptOptions):
63
76
  * - resolves the component export by metadata (`componentRef`, `componentFile`)
64
77
  * before falling back to default/first function export
65
78
  * - selects island root using `targetSelector`
79
+ * - replaces the SSR host with a dedicated client-owned container
66
80
  * - creates a fresh React root and renders with serialized `props`
67
81
  *
82
+ * Why it remounts instead of hydrating:
83
+ * island SSR intentionally avoids synthetic wrapper elements. The runtime swaps
84
+ * the authored SSR node for a dedicated client-owned container before mounting
85
+ * so the server markup stays clean while the client still gets a stable root.
86
+ *
68
87
  * @param options Island script generation options.
69
88
  * @returns Browser-executable JavaScript module source.
70
89
  */
@@ -9,8 +9,58 @@ function getHmrImportStatement(isMdx) {
9
9
  function getComponentType(isMdx) {
10
10
  return isMdx ? "MDX" : "React";
11
11
  }
12
+ function getDevPageRootCleanupScript() {
13
+ return `window.__ECO_PAGES__ = window.__ECO_PAGES__ || {};
14
+ window.__ECO_PAGES__.react = window.__ECO_PAGES__.react || {};
15
+ window.__ECO_PAGES__.react.cleanupPageRoot = () => {
16
+ const activeRoot = window.__ECO_PAGES__.react?.pageRoot || root;
17
+ if (!activeRoot) {
18
+ window.__ECO_PAGES__.react.pageRoot = null;
19
+ window.__ECO_PAGES__?.navigation?.releaseOwnership?.("react-router");
20
+ delete window.__ECO_PAGES__.page;
21
+ return;
22
+ }
23
+ window.__ECO_PAGES__.react.pageRoot = null;
24
+ window.__ECO_PAGES__?.navigation?.releaseOwnership?.("react-router");
25
+ delete window.__ECO_PAGES__.page;
26
+ root = null;
27
+ activeRoot.unmount();
28
+ };`;
29
+ }
30
+ function getProdPageRootCleanupScript() {
31
+ return 'window.__ECO_PAGES__=window.__ECO_PAGES__||{};window.__ECO_PAGES__.react=window.__ECO_PAGES__.react||{};window.__ECO_PAGES__.react.cleanupPageRoot=()=>{const a=window.__ECO_PAGES__.react?.pageRoot||root;if(!a){window.__ECO_PAGES__.react.pageRoot=null;window.__ECO_PAGES__?.navigation?.releaseOwnership?.("react-router");delete window.__ECO_PAGES__.page;return}window.__ECO_PAGES__.react.pageRoot=null;window.__ECO_PAGES__?.navigation?.releaseOwnership?.("react-router");delete window.__ECO_PAGES__.page;root=null;a.unmount()};';
32
+ }
33
+ function getDevRouterBootstrapRegistrationScript() {
34
+ return `const currentOwnerState = window.__ECO_PAGES__?.navigation?.getOwnerState?.();
35
+ if (!(currentOwnerState?.owner === "react-router" && currentOwnerState.canHandleSpaNavigation)) {
36
+ window.__ECO_PAGES__?.navigation?.register({
37
+ owner: "react-router",
38
+ cleanupBeforeHandoff: async () => {
39
+ window.__ECO_PAGES__?.react?.cleanupPageRoot?.();
40
+ }
41
+ });
42
+ window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router");
43
+ }`;
44
+ }
45
+ function getProdRouterBootstrapRegistrationScript() {
46
+ return 'const o=window.__ECO_PAGES__?.navigation?.getOwnerState?.();if(!(o?.owner==="react-router"&&o.canHandleSpaNavigation)){window.__ECO_PAGES__?.navigation?.register({owner:"react-router",cleanupBeforeHandoff:async()=>{window.__ECO_PAGES__?.react?.cleanupPageRoot?.()}});window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router")}';
47
+ }
48
+ function getDevReuseExistingRouterRootScript() {
49
+ return `const shouldReuseExistingRouterRoot = () => {
50
+ const ownerState = window.__ECO_PAGES__?.navigation?.getOwnerState?.();
51
+ return Boolean(
52
+ window.__ECO_PAGES__.react?.pageRoot &&
53
+ ownerState?.owner === "react-router" &&
54
+ ownerState.canHandleSpaNavigation
55
+ );
56
+ };`;
57
+ }
58
+ function getProdReuseExistingRouterRootScript() {
59
+ return 'const sr=()=>{const o=window.__ECO_PAGES__?.navigation?.getOwnerState?.();return!!(window.__ECO_PAGES__.react?.pageRoot&&o?.owner==="react-router"&&o.canHandleSpaNavigation)};';
60
+ }
12
61
  function createDevScriptWithRouter(options) {
13
- const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath } = options;
62
+ const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath, scriptId } = options;
63
+ const pageModuleUrlExpression = options.pageModuleUrlExpression ?? "import.meta.url";
14
64
  const { components, getRouterProps } = router;
15
65
  if (!routerImportPath) {
16
66
  throw new Error("routerImportPath is required when router adapter is configured");
@@ -20,11 +70,21 @@ import { hydrateRoot } from "${reactDomClientImportPath}";
20
70
  import { createElement } from "${reactImportPath}";
21
71
  import { ${components.router}, ${components.pageContent} } from "${routerImportPath}";
22
72
  ${getImportStatement(importPath, isMdx)}
73
+ const pageModuleUrl = ${pageModuleUrlExpression};
74
+ export default Page;
75
+ export const config = Page.config;
76
+ const isActivePageEntry = Boolean(document.querySelector('script[data-eco-script-id="${scriptId}"]'));
77
+
78
+ if (isActivePageEntry) {
23
79
 
24
- window.__ecopages_hmr_handlers__ = window.__ecopages_hmr_handlers__ || {};
25
- window.__ecopages_router_active__ = false;
26
- window.__ecopages_reload_current_page__ = null;
27
- let root = null;
80
+ window.__ECO_PAGES__ = window.__ECO_PAGES__ || {};
81
+ window.__ECO_PAGES__.hmrHandlers = window.__ECO_PAGES__.hmrHandlers || {};
82
+ window.__ECO_PAGES__.react = window.__ECO_PAGES__.react || {};
83
+ window.__ECO_PAGES__.react.pageRoot = window.__ECO_PAGES__.react.pageRoot || null;
84
+ let root = window.__ECO_PAGES__.react.pageRoot;
85
+ ${getDevPageRootCleanupScript()}
86
+ ${getDevRouterBootstrapRegistrationScript()}
87
+ ${getDevReuseExistingRouterRootScript()}
28
88
 
29
89
  const getPageData = () => {
30
90
  const el = document.getElementById("__ECO_PAGE_DATA__");
@@ -36,8 +96,8 @@ const getPageData = () => {
36
96
 
37
97
  const props = getPageData();
38
98
 
39
- window.__ECO_PAGE__ = {
40
- module: "${importPath}",
99
+ window.__ECO_PAGES__.page = {
100
+ module: pageModuleUrl,
41
101
  props
42
102
  };
43
103
 
@@ -47,20 +107,40 @@ const createTree = (Component, props) => {
47
107
  };
48
108
 
49
109
  const mount = () => {
50
- root = hydrateRoot(document, createTree(Page, props), {
51
- onRecoverableError: (err) => console.warn("[ecopages] Hydration error:", err)
52
- });
53
- window.__ecopages_router_active__ = true;
54
- window.__ecopages_hmr_handlers__["${importPath}"] = async (newUrl) => {
55
- if (window.__ecopages_router_active__ && window.__ecopages_reload_current_page__) {
56
- await window.__ecopages_reload_current_page__();
57
- console.log("[ecopages] ${getComponentType(isMdx)} component updated via router");
58
- return;
59
- }
110
+ if (shouldReuseExistingRouterRoot()) {
111
+ root = window.__ECO_PAGES__.react.pageRoot;
112
+ } else if (window.__ECO_PAGES__.react?.pageRoot) {
113
+ root = window.__ECO_PAGES__.react.pageRoot;
114
+ root.render(createTree(Page, props));
115
+ } else {
116
+ root = hydrateRoot(document.body, createTree(Page, props), {
117
+ onRecoverableError: (err) => console.warn("[ecopages] Hydration error:", err)
118
+ });
119
+ window.__ECO_PAGES__.react.pageRoot = root;
120
+ }
121
+ window.__ECO_PAGES__.hmrHandlers["${importPath}"] = async (newUrl) => {
60
122
  try {
61
123
  const newModule = await import(newUrl);
124
+ const nextProps = getPageData();
62
125
  ${getHmrImportStatement(isMdx)}
63
- root.render(createTree(NewPage, props));
126
+ const currentPageLayout = Page.config?.layout;
127
+ const nextPageLayout = NewPage.config?.layout;
128
+
129
+ if (window.__ECO_PAGES__?.navigation?.getOwnerState().owner === "react-router") {
130
+ await window.__ECO_PAGES__?.navigation?.reloadCurrentPage?.({
131
+ clearCache: currentPageLayout !== nextPageLayout,
132
+ moduleUrl: "${importPath}",
133
+ source: "react-router"
134
+ });
135
+ console.log("[ecopages] ${getComponentType(isMdx)} component updated via router");
136
+ return;
137
+ }
138
+
139
+ window.__ECO_PAGES__.page = {
140
+ module: pageModuleUrl,
141
+ props: nextProps
142
+ };
143
+ root.render(createTree(NewPage, nextProps));
64
144
  console.log("[ecopages] ${getComponentType(isMdx)} component updated");
65
145
  } catch (e) {
66
146
  console.error("[ecopages] Failed to hot-reload ${getComponentType(isMdx)} component:", e);
@@ -73,17 +153,29 @@ if (document.readyState === "loading") {
73
153
  } else {
74
154
  mount();
75
155
  }
156
+ }
76
157
  `.trim();
77
158
  }
78
159
  function createDevScriptWithoutRouter(options) {
79
- const { importPath, isMdx, reactImportPath, reactDomClientImportPath } = options;
160
+ const { importPath, isMdx, reactImportPath, reactDomClientImportPath, scriptId } = options;
161
+ const pageModuleUrlExpression = options.pageModuleUrlExpression ?? "import.meta.url";
80
162
  return `
81
163
  import { hydrateRoot } from "${reactDomClientImportPath}";
82
164
  import { createElement } from "${reactImportPath}";
83
165
  ${getImportStatement(importPath, isMdx)}
166
+ const pageModuleUrl = ${pageModuleUrlExpression};
167
+ export default Page;
168
+ export const config = Page.config;
169
+ const isActivePageEntry = Boolean(document.querySelector('script[data-eco-script-id="${scriptId}"]'));
170
+
171
+ if (isActivePageEntry) {
84
172
 
85
- window.__ecopages_hmr_handlers__ = window.__ecopages_hmr_handlers__ || {};
86
- let root = null;
173
+ window.__ECO_PAGES__ = window.__ECO_PAGES__ || {};
174
+ window.__ECO_PAGES__.hmrHandlers = window.__ECO_PAGES__.hmrHandlers || {};
175
+ window.__ECO_PAGES__.react = window.__ECO_PAGES__.react || {};
176
+ window.__ECO_PAGES__.react.pageRoot = window.__ECO_PAGES__.react.pageRoot || null;
177
+ let root = window.__ECO_PAGES__.react.pageRoot;
178
+ ${getDevPageRootCleanupScript()}
87
179
 
88
180
  const getPageData = () => {
89
181
  const el = document.getElementById("__ECO_PAGE_DATA__");
@@ -95,22 +187,29 @@ const getPageData = () => {
95
187
 
96
188
  const props = getPageData();
97
189
 
98
- window.__ECO_PAGE__ = {
99
- module: "${importPath}",
190
+ window.__ECO_PAGES__.page = {
191
+ module: pageModuleUrl,
100
192
  props
101
193
  };
102
194
 
103
195
  const createTree = (Component, props) => {
104
196
  const Layout = Component.config?.layout;
105
197
  const pageElement = createElement(Component, props);
106
- return Layout ? createElement(Layout, null, pageElement) : pageElement;
198
+ const layoutProps = props?.locals ? { locals: props.locals } : null;
199
+ return Layout ? createElement(Layout, layoutProps, pageElement) : pageElement;
107
200
  };
108
201
 
109
202
  const mount = () => {
110
- root = hydrateRoot(document, createTree(Page, props), {
111
- onRecoverableError: (err) => console.warn("[ecopages] Hydration error:", err)
112
- });
113
- window.__ecopages_hmr_handlers__["${importPath}"] = async (newUrl) => {
203
+ if (window.__ECO_PAGES__.react?.pageRoot) {
204
+ root = window.__ECO_PAGES__.react.pageRoot;
205
+ root.render(createTree(Page, props));
206
+ } else {
207
+ root = hydrateRoot(document.body, createTree(Page, props), {
208
+ onRecoverableError: (err) => console.warn("[ecopages] Hydration error:", err)
209
+ });
210
+ window.__ECO_PAGES__.react.pageRoot = root;
211
+ }
212
+ window.__ECO_PAGES__.hmrHandlers["${importPath}"] = async (newUrl) => {
114
213
  try {
115
214
  const newModule = await import(newUrl);
116
215
  ${getHmrImportStatement(isMdx)}
@@ -127,25 +226,28 @@ if (document.readyState === "loading") {
127
226
  } else {
128
227
  mount();
129
228
  }
229
+ }
130
230
  `.trim();
131
231
  }
132
232
  function createProdScriptWithRouter(options) {
133
- const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath } = options;
233
+ const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath, scriptId } = options;
234
+ const pageModuleUrlExpression = options.pageModuleUrlExpression ?? "import.meta.url";
134
235
  const { components, getRouterProps } = router;
135
236
  if (!routerImportPath) {
136
237
  throw new Error("routerImportPath is required when router adapter is configured");
137
238
  }
138
239
  if (isMdx) {
139
- return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import{${components.router} as R,${components.pageContent} as PC}from"${routerImportPath}";import*as M from"${importPath}";const P=M.default;if(M.config)P.config=M.config;const gd=()=>{const e=document.getElementById("__ECO_PAGE_DATA__");if(e?.textContent){try{return JSON.parse(e.textContent)}catch{}}return{}};const pr=gd();window.__ECO_PAGE__={module:"${importPath}",props:pr};const ct=(C,p)=>ce(R,${getRouterProps("C", "p")},ce(PC));const m=()=>hr(document,ct(P,pr),{onRecoverableError:(e)=>console.warn("[ecopages] Hydration error:",e)});document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m()`;
240
+ return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import{${components.router} as R,${components.pageContent} as PC}from"${routerImportPath}";import*as M from"${importPath}";const P=M.default;if(M.config)P.config=M.config;const u=${pageModuleUrlExpression};export default P;export const config=P.config;const a=!!document.querySelector('script[data-eco-script-id="${scriptId}"]');if(a){window.__ECO_PAGES__=window.__ECO_PAGES__||{};window.__ECO_PAGES__.react=window.__ECO_PAGES__.react||{};window.__ECO_PAGES__.react.pageRoot=window.__ECO_PAGES__.react.pageRoot||null;let root=window.__ECO_PAGES__.react.pageRoot;${getProdPageRootCleanupScript()}${getProdRouterBootstrapRegistrationScript()}${getProdReuseExistingRouterRootScript()}const gd=()=>{const e=document.getElementById("__ECO_PAGE_DATA__");if(e?.textContent){try{return JSON.parse(e.textContent)}catch{}}return{}};const pr=gd();window.__ECO_PAGES__.page={module:u,props:pr};const ct=(C,p)=>ce(R,${getRouterProps("C", "p")},ce(PC));const m=()=>{if(sr()){root=window.__ECO_PAGES__.react.pageRoot;return}if(window.__ECO_PAGES__.react?.pageRoot){root=window.__ECO_PAGES__.react.pageRoot;root.render(ct(P,pr));return}root=hr(document.body,ct(P,pr),{onRecoverableError:(e)=>console.warn("[ecopages] Hydration error:",e)});window.__ECO_PAGES__.react.pageRoot=root};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m()}`;
140
241
  }
141
- return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import{${components.router} as R,${components.pageContent} as PC}from"${routerImportPath}";import P from"${importPath}";const gd=()=>{const e=document.getElementById("__ECO_PAGE_DATA__");if(e?.textContent){try{return JSON.parse(e.textContent)}catch{}}return{}};const pr=gd();window.__ECO_PAGE__={module:"${importPath}",props:pr};const ct=(C,p)=>ce(R,${getRouterProps("C", "p")},ce(PC));const m=()=>hr(document,ct(P,pr),{onRecoverableError:(e)=>console.warn("[ecopages] Hydration error:",e)});document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m()`;
242
+ return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import{${components.router} as R,${components.pageContent} as PC}from"${routerImportPath}";import P from"${importPath}";const u=${pageModuleUrlExpression};export default P;export const config=P.config;const a=!!document.querySelector('script[data-eco-script-id="${scriptId}"]');if(a){window.__ECO_PAGES__=window.__ECO_PAGES__||{};window.__ECO_PAGES__.react=window.__ECO_PAGES__.react||{};window.__ECO_PAGES__.react.pageRoot=window.__ECO_PAGES__.react.pageRoot||null;let root=window.__ECO_PAGES__.react.pageRoot;${getProdPageRootCleanupScript()}${getProdRouterBootstrapRegistrationScript()}${getProdReuseExistingRouterRootScript()}const gd=()=>{const e=document.getElementById("__ECO_PAGE_DATA__");if(e?.textContent){try{return JSON.parse(e.textContent)}catch{}}return{}};const pr=gd();window.__ECO_PAGES__.page={module:u,props:pr};const ct=(C,p)=>ce(R,${getRouterProps("C", "p")},ce(PC));const m=()=>{if(sr()){root=window.__ECO_PAGES__.react.pageRoot;return}if(window.__ECO_PAGES__.react?.pageRoot){root=window.__ECO_PAGES__.react.pageRoot;root.render(ct(P,pr));return}root=hr(document.body,ct(P,pr),{onRecoverableError:(e)=>console.warn("[ecopages] Hydration error:",e)});window.__ECO_PAGES__.react.pageRoot=root};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m()}`;
142
243
  }
143
244
  function createProdScriptWithoutRouter(options) {
144
- const { importPath, isMdx, reactImportPath, reactDomClientImportPath } = options;
245
+ const { importPath, isMdx, reactImportPath, reactDomClientImportPath, scriptId } = options;
246
+ const pageModuleUrlExpression = options.pageModuleUrlExpression ?? "import.meta.url";
145
247
  if (isMdx) {
146
- return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import*as M from"${importPath}";const P=M.default;if(M.config)P.config=M.config;const gd=()=>{const e=document.getElementById("__ECO_PAGE_DATA__");if(e?.textContent){try{return JSON.parse(e.textContent)}catch{}}return{}};const pr=gd();window.__ECO_PAGE__={module:"${importPath}",props:pr};const ct=(C,p)=>{const L=C.config?.layout;const pe=ce(C,p);return L?ce(L,null,pe):pe};const m=()=>hr(document,ct(P,pr),{onRecoverableError:(e)=>console.warn("[ecopages] Hydration error:",e)});document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m()`;
248
+ return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import*as M from"${importPath}";const P=M.default;if(M.config)P.config=M.config;const u=${pageModuleUrlExpression};export default P;export const config=P.config;const a=!!document.querySelector('script[data-eco-script-id="${scriptId}"]');if(a){window.__ECO_PAGES__=window.__ECO_PAGES__||{};window.__ECO_PAGES__.react=window.__ECO_PAGES__.react||{};window.__ECO_PAGES__.react.pageRoot=window.__ECO_PAGES__.react.pageRoot||null;let root=window.__ECO_PAGES__.react.pageRoot;${getProdPageRootCleanupScript()}const gd=()=>{const e=document.getElementById("__ECO_PAGE_DATA__");if(e?.textContent){try{return JSON.parse(e.textContent)}catch{}}return{}};const pr=gd();window.__ECO_PAGES__.page={module:u,props:pr};const ct=(C,p)=>{const L=C.config?.layout;const pe=ce(C,p);const lp=p?.locals?{locals:p.locals}:null;return L?ce(L,lp,pe):pe};const m=()=>{if(window.__ECO_PAGES__.react?.pageRoot){root=window.__ECO_PAGES__.react.pageRoot;root.render(ct(P,pr));return}root=hr(document.body,ct(P,pr),{onRecoverableError:(e)=>console.warn("[ecopages] Hydration error:",e)});window.__ECO_PAGES__.react.pageRoot=root};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m()}`;
147
249
  }
148
- return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import P from"${importPath}";const gd=()=>{const e=document.getElementById("__ECO_PAGE_DATA__");if(e?.textContent){try{return JSON.parse(e.textContent)}catch{}}return{}};const pr=gd();window.__ECO_PAGE__={module:"${importPath}",props:pr};const ct=(C,p)=>{const L=C.config?.layout;const pe=ce(C,p);return L?ce(L,null,pe):pe};const m=()=>hr(document,ct(P,pr),{onRecoverableError:(e)=>console.warn("[ecopages] Hydration error:",e)});document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m()`;
250
+ return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import P from"${importPath}";const u=${pageModuleUrlExpression};export default P;export const config=P.config;const a=!!document.querySelector('script[data-eco-script-id="${scriptId}"]');if(a){window.__ECO_PAGES__=window.__ECO_PAGES__||{};window.__ECO_PAGES__.react=window.__ECO_PAGES__.react||{};window.__ECO_PAGES__.react.pageRoot=window.__ECO_PAGES__.react.pageRoot||null;let root=window.__ECO_PAGES__.react.pageRoot;${getProdPageRootCleanupScript()}const gd=()=>{const e=document.getElementById("__ECO_PAGE_DATA__");if(e?.textContent){try{return JSON.parse(e.textContent)}catch{}}return{}};const pr=gd();window.__ECO_PAGES__.page={module:u,props:pr};const ct=(C,p)=>{const L=C.config?.layout;const pe=ce(C,p);const lp=p?.locals?{locals:p.locals}:null;return L?ce(L,lp,pe):pe};const m=()=>{if(window.__ECO_PAGES__.react?.pageRoot){root=window.__ECO_PAGES__.react.pageRoot;root.render(ct(P,pr));return}root=hr(document.body,ct(P,pr),{onRecoverableError:(e)=>console.warn("[ecopages] Hydration error:",e)});window.__ECO_PAGES__.react.pageRoot=root};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m()}`;
149
251
  }
150
252
  function createHydrationScript(options) {
151
253
  const { isDevelopment, router } = options;
@@ -156,10 +258,8 @@ function createHydrationScript(options) {
156
258
  }
157
259
  function createIslandHydrationScript(options) {
158
260
  const targetSelector = JSON.stringify(options.targetSelector);
159
- const serializedProps = JSON.stringify(options.props ?? {});
160
261
  const componentRef = JSON.stringify(options.componentRef ?? "");
161
262
  const componentFile = JSON.stringify(options.componentFile ?? "");
162
- const mountedAttribute = "data-eco-react-mounted";
163
263
  if (options.isDevelopment) {
164
264
  return `
165
265
  import { createRoot } from "${options.reactDomClientImportPath}";
@@ -195,18 +295,24 @@ const resolveComponent = () => {
195
295
  };
196
296
 
197
297
  const mount = () => {
198
- const target = document.querySelector(${targetSelector});
298
+ const targets = document.querySelectorAll(${targetSelector});
199
299
  const Component = resolveComponent();
200
- if (!target || !Component || target.hasAttribute("${mountedAttribute}")) {
300
+ if (!Component || targets.length === 0) {
201
301
  return;
202
302
  }
203
- const props = ${serializedProps};
204
- target.setAttribute("${mountedAttribute}", "true");
205
- const root = createRoot(target);
206
- root.render(createElement(Component, props));
303
+ targets.forEach((target) => {
304
+ if (!(target instanceof HTMLElement)) {
305
+ return;
306
+ }
307
+ const props = JSON.parse(atob(target.getAttribute("data-eco-props") || "e30="));
308
+ const container = document.createElement("eco-island");
309
+ container.style.display = "block";
310
+ target.replaceWith(container);
311
+ const root = createRoot(container);
312
+ root.render(createElement(Component, props));
313
+ });
207
314
  };
208
315
 
209
- document.addEventListener("eco:after-swap", mount);
210
316
  if (document.readyState === "loading") {
211
317
  document.addEventListener("DOMContentLoaded", mount, { once: true });
212
318
  } else {
@@ -214,7 +320,7 @@ if (document.readyState === "loading") {
214
320
  }
215
321
  `.trim();
216
322
  }
217
- return `import{createRoot as cr}from"${options.reactDomClientImportPath}";import{createElement as ce}from"${options.reactImportPath}";import*as M from"${options.importPath}";const r=${componentRef};const f=${componentFile};const mv=Object.values(M);const c=mv.find((e)=>{if(typeof e!=="function")return false;const ec=e.config?.__eco;if(!ec)return false;if(r&&ec.id===r)return true;if(f&&ec.file===f)return true;return false;})??(typeof M.default==="function"?M.default:mv.find((e)=>typeof e==="function")??null);const m=()=>{const t=document.querySelector(${targetSelector});if(!t||!c||t.hasAttribute("${mountedAttribute}"))return;const p=${serializedProps};t.setAttribute("${mountedAttribute}","true");cr(t).render(ce(c,p))};document.addEventListener("eco:after-swap",m);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m,{once:true}):m()`;
323
+ return `import{createRoot as cr}from"${options.reactDomClientImportPath}";import{createElement as ce}from"${options.reactImportPath}";import*as M from"${options.importPath}";const r=${componentRef};const f=${componentFile};const mv=Object.values(M);const c=mv.find((e)=>{if(typeof e!=="function")return false;const ec=e.config?.__eco;if(!ec)return false;if(r&&ec.id===r)return true;if(f&&ec.file===f)return true;return false;})??(typeof M.default==="function"?M.default:mv.find((e)=>typeof e==="function")??null);const m=()=>{const ts=document.querySelectorAll(${targetSelector});if(!c||ts.length===0)return;ts.forEach((t)=>{if(!(t instanceof HTMLElement))return;const p=JSON.parse(atob(t.getAttribute("data-eco-props")||"e30="));const ct=document.createElement("eco-island");ct.style.display="block";t.replaceWith(ct);cr(ct).render(ce(c,p))})};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m,{once:true}):m()`;
218
324
  }
219
325
  export {
220
326
  createHydrationScript,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,198 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createHydrationScript } from "./hydration-scripts.js";
3
+ const routerAdapter = {
4
+ name: "eco-router",
5
+ bundle: {
6
+ importPath: "/assets/router.js",
7
+ outputName: "router",
8
+ externals: []
9
+ },
10
+ components: {
11
+ router: "EcoRouter",
12
+ pageContent: "PageContent"
13
+ },
14
+ getRouterProps: (page, props) => `{ page: ${page}, pageProps: ${props} }`
15
+ };
16
+ function createModuleUrl(source) {
17
+ return `data:text/javascript;base64,${btoa(source)}`;
18
+ }
19
+ async function importModule(moduleUrl, scriptId) {
20
+ let marker;
21
+ if (scriptId) {
22
+ marker = document.createElement("script");
23
+ marker.setAttribute("data-eco-script-id", scriptId);
24
+ document.head.appendChild(marker);
25
+ }
26
+ await import(
27
+ /* @vite-ignore */
28
+ moduleUrl
29
+ );
30
+ marker?.remove();
31
+ }
32
+ function createRuntimeModules() {
33
+ const reactImportPath = createModuleUrl("export const createElement = (...args) => ({ args });");
34
+ const reactDomClientImportPath = createModuleUrl(`
35
+ export const hydrateRoot = (container, tree, options) => {
36
+ const runtime = window.__ECO_REACT_HYDRATION_TEST__;
37
+ runtime.hydrateCalls.push({
38
+ containerTag: container.tagName,
39
+ hasRecoverableErrorHandler: typeof options?.onRecoverableError === "function",
40
+ tree,
41
+ });
42
+
43
+ return {
44
+ render() {},
45
+ unmount() {
46
+ runtime.unmountCount += 1;
47
+ },
48
+ };
49
+ };
50
+ `);
51
+ const importPath = createModuleUrl("export default function Page() { return null; }");
52
+ const routerImportPath = createModuleUrl(`
53
+ export function EcoRouter(props) {
54
+ return props;
55
+ }
56
+
57
+ export function PageContent() {
58
+ return null;
59
+ }
60
+ `);
61
+ return {
62
+ importPath,
63
+ reactImportPath,
64
+ reactDomClientImportPath,
65
+ routerImportPath
66
+ };
67
+ }
68
+ describe("createHydrationScript browser execution", () => {
69
+ afterEach(() => {
70
+ document.body.innerHTML = "";
71
+ const testWindow = window;
72
+ delete testWindow.__ECO_PAGES__;
73
+ delete testWindow.__ECO_REACT_HYDRATION_TEST__;
74
+ });
75
+ it("registers router ownership and cleanup when the browser hydration bootstrap runs", async () => {
76
+ const runtimeModules = createRuntimeModules();
77
+ const testWindow = window;
78
+ testWindow.__ECO_REACT_HYDRATION_TEST__ = {
79
+ hydrateCalls: [],
80
+ renderCalls: [],
81
+ claimedOwners: [],
82
+ releasedOwners: [],
83
+ registrations: [],
84
+ unmountCount: 0
85
+ };
86
+ testWindow.__ECO_PAGES__ = {
87
+ navigation: {
88
+ getOwnerState: () => ({
89
+ owner: "html",
90
+ canHandleSpaNavigation: false
91
+ }),
92
+ register: (registration) => {
93
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations.push(registration);
94
+ },
95
+ claimOwnership: (owner) => {
96
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.claimedOwners.push(owner);
97
+ },
98
+ releaseOwnership: (owner) => {
99
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.releasedOwners.push(owner);
100
+ }
101
+ }
102
+ };
103
+ document.body.innerHTML = `<script id="__ECO_PAGE_DATA__" type="application/json">${JSON.stringify({
104
+ title: "Hello React",
105
+ locals: { theme: "dark" }
106
+ })}<\/script>`;
107
+ const script = createHydrationScript({
108
+ ...runtimeModules,
109
+ scriptId: "ecopages-react-page",
110
+ isDevelopment: true,
111
+ isMdx: false,
112
+ router: routerAdapter
113
+ });
114
+ const moduleUrl = createModuleUrl(script);
115
+ await importModule(moduleUrl, "ecopages-react-page");
116
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls).toHaveLength(1);
117
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls[0]?.containerTag).toBe("BODY");
118
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls[0]?.hasRecoverableErrorHandler).toBe(true);
119
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.claimedOwners).toEqual(["react-router"]);
120
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations).toHaveLength(1);
121
+ expect(typeof testWindow.__ECO_PAGES__?.react?.cleanupPageRoot).toBe("function");
122
+ expect(testWindow.__ECO_PAGES__?.page).toEqual({
123
+ module: moduleUrl,
124
+ props: {
125
+ title: "Hello React",
126
+ locals: { theme: "dark" }
127
+ }
128
+ });
129
+ await testWindow.__ECO_PAGES__?.react?.cleanupPageRoot?.();
130
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.unmountCount).toBe(1);
131
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.releasedOwners).toEqual(["react-router"]);
132
+ expect(testWindow.__ECO_PAGES__?.page).toBeUndefined();
133
+ expect(testWindow.__ECO_PAGES__?.react?.pageRoot).toBeNull();
134
+ });
135
+ it("reuses an existing router-owned page root during rerun bootstrap execution", async () => {
136
+ const runtimeModules = createRuntimeModules();
137
+ const testWindow = window;
138
+ testWindow.__ECO_REACT_HYDRATION_TEST__ = {
139
+ hydrateCalls: [],
140
+ renderCalls: [],
141
+ claimedOwners: [],
142
+ releasedOwners: [],
143
+ registrations: [],
144
+ unmountCount: 0
145
+ };
146
+ const existingRoot = {
147
+ render: (tree) => {
148
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.renderCalls.push(tree);
149
+ },
150
+ unmount: () => {
151
+ testWindow.__ECO_REACT_HYDRATION_TEST__.unmountCount += 1;
152
+ }
153
+ };
154
+ testWindow.__ECO_PAGES__ = {
155
+ navigation: {
156
+ getOwnerState: () => ({
157
+ owner: "react-router",
158
+ canHandleSpaNavigation: true
159
+ }),
160
+ register: (registration) => {
161
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations.push(registration);
162
+ },
163
+ claimOwnership: (owner) => {
164
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.claimedOwners.push(owner);
165
+ },
166
+ releaseOwnership: (owner) => {
167
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.releasedOwners.push(owner);
168
+ }
169
+ },
170
+ react: {
171
+ pageRoot: existingRoot
172
+ }
173
+ };
174
+ document.body.innerHTML = `<script id="__ECO_PAGE_DATA__" type="application/json">${JSON.stringify({
175
+ title: "Rerun"
176
+ })}<\/script>`;
177
+ const script = createHydrationScript({
178
+ ...runtimeModules,
179
+ scriptId: "ecopages-react-page-rerun",
180
+ isDevelopment: true,
181
+ isMdx: false,
182
+ router: routerAdapter
183
+ });
184
+ const moduleUrl = createModuleUrl(script);
185
+ await importModule(moduleUrl, "ecopages-react-page-rerun");
186
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls).toHaveLength(0);
187
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.renderCalls).toHaveLength(0);
188
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.claimedOwners).toHaveLength(0);
189
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations).toHaveLength(0);
190
+ expect(testWindow.__ECO_PAGES__?.react?.pageRoot).toBe(existingRoot);
191
+ expect(testWindow.__ECO_PAGES__?.page).toEqual({
192
+ module: moduleUrl,
193
+ props: {
194
+ title: "Rerun"
195
+ }
196
+ });
197
+ });
198
+ });