@ecopages/react 0.2.0-alpha.23 → 0.2.0-alpha.26

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,10 @@ All notable changes to `@ecopages/react` are documented here.
8
8
 
9
9
  ### Bug Fixes
10
10
 
11
+ - Fixed router-managed React HMR page entries to reload the active route with a cleared persisted-layout cache so shared layout edits apply while the current page stays mounted.
12
+ - Fixed router-managed React HMR handlers to forward the active page HMR entry when reloading the current route through React Router.
13
+ - Fixed React route hydration bundles to resolve the router through the published import-map key and keep rerun navigation on the shared runtime graph.
14
+ - Removed the redundant React page props bootstrap script so route hydration relies on the canonical `__ECO_PAGE_DATA__` payload.
11
15
  - Fixed React hydration, Fast Refresh, module loading, doctype handling, island asset reuse, and mixed-renderer boundary resolution across Bun, Vite, and Nitro flows.
12
16
  - Restored direct `ReactPlugin` construction so the exported class still accepts the public plugin options shape.
13
17
  - Fixed React boundary payload compatibility coverage and removed the plugin/renderer integration-name import cycle.
@@ -18,6 +22,7 @@ All notable changes to `@ecopages/react` are documented here.
18
22
 
19
23
  ### Refactoring
20
24
 
25
+ - Collapsed React route hydration into one page-owned entry module that re-exports the page component and bundles runtime dependencies in production.
21
26
  - Consolidated React bundling, hydration, and runtime state behind shared service boundaries and `window.__ECO_PAGES__`.
22
27
  - Moved React plugin option/default resolution into the factory and replaced renderer static config with instance-owned runtime wiring.
23
28
  - Extracted React page-payload and locals serialization into a dedicated service to keep the renderer focused on orchestration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react",
3
- "version": "0.2.0-alpha.23",
3
+ "version": "0.2.0-alpha.26",
4
4
  "description": "React integration for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -53,14 +53,14 @@
53
53
  "directory": "packages/integrations/react"
54
54
  },
55
55
  "peerDependencies": {
56
- "@ecopages/core": "0.2.0-alpha.23",
56
+ "@ecopages/core": "0.2.0-alpha.26",
57
57
  "@types/react": "^19",
58
58
  "@types/react-dom": "^19",
59
59
  "react": "^19",
60
60
  "react-dom": "^19"
61
61
  },
62
62
  "dependencies": {
63
- "@ecopages/file-system": "0.2.0-alpha.23",
63
+ "@ecopages/file-system": "0.2.0-alpha.26",
64
64
  "@ecopages/logger": "^0.2.3",
65
65
  "@mdx-js/esbuild": "^3.0.1",
66
66
  "@mdx-js/mdx": "^3.1.0",
@@ -80,6 +80,7 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
80
80
  * already selected the React integration.
81
81
  */
82
82
  private isReactManagedComponent;
83
+ private getComponentRequires;
83
84
  private getRouterDocumentAttributes;
84
85
  /**
85
86
  * Commits a framework-agnostic component to React semantics.
@@ -118,6 +118,9 @@ class ReactRenderer extends IntegrationRenderer {
118
118
  const integration = this.getComponentIntegration(component);
119
119
  return integration === void 0 || integration === this.name;
120
120
  }
121
+ getComponentRequires(component) {
122
+ return component?.requires;
123
+ }
121
124
  getRouterDocumentAttributes() {
122
125
  if (!this.routerAdapter) {
123
126
  return void 0;
@@ -351,7 +354,9 @@ class ReactRenderer extends IntegrationRenderer {
351
354
  */
352
355
  async renderReactComponentBoundary(input, runtimeContext) {
353
356
  const componentConfig = input.component.config;
354
- const context = input.integrationContext ?? {};
357
+ const context = {
358
+ componentInstanceId: input.integrationContext?.componentInstanceId
359
+ };
355
360
  const hasResolvedChildHtml = input.children !== void 0;
356
361
  let html = this.renderComponentHtml(input, context, runtimeContext);
357
362
  const queuedBoundaryResolution = await this.resolveQueuedBoundaryHtml(html, runtimeContext);
@@ -498,10 +503,7 @@ class ReactRenderer extends IntegrationRenderer {
498
503
  pageProps
499
504
  }) {
500
505
  try {
501
- const safeLocals = this.pagePayloadService.getSerializableLocals(
502
- locals,
503
- Page.requires
504
- );
506
+ const safeLocals = this.pagePayloadService.getSerializableLocals(locals, this.getComponentRequires(Page));
505
507
  const allPageProps = this.pagePayloadService.buildSerializedPageProps({
506
508
  pageProps,
507
509
  params,
@@ -19,6 +19,16 @@ export interface ReactBundleServiceConfig {
19
19
  nonReactExtensions?: string[];
20
20
  jsxImportSource?: string;
21
21
  }
22
+ /**
23
+ * Optional flags that adjust how a React client entry is bundled.
24
+ */
25
+ export interface ReactClientBundleOptions {
26
+ /**
27
+ * When `true`, bundle React runtime dependencies into the emitted entry instead of
28
+ * rewriting them to external runtime specifiers.
29
+ */
30
+ includeRuntime?: boolean;
31
+ }
22
32
  /**
23
33
  * Manages esbuild bundle configuration and plugin creation for React page/component builds.
24
34
  */
@@ -38,7 +48,7 @@ export declare class ReactBundleService {
38
48
  * @param declaredModules - Explicitly declared browser module specifiers
39
49
  * @returns Bundle options object for the build adapter
40
50
  */
41
- createBundleOptions(componentName: string, isMdx: boolean, declaredModules: string[]): Promise<Record<string, unknown>>;
51
+ createBundleOptions(componentName: string, isMdx: boolean, declaredModules: string[], bundleOptions?: ReactClientBundleOptions): Promise<Record<string, unknown>>;
42
52
  /**
43
53
  * Creates the esbuild plugin that rewrites bare React specifiers
44
54
  * to their runtime asset URLs.
@@ -32,11 +32,8 @@ class ReactBundleService {
32
32
  * @param declaredModules - Explicitly declared browser module specifiers
33
33
  * @returns Bundle options object for the build adapter
34
34
  */
35
- async createBundleOptions(componentName, isMdx, declaredModules) {
36
- const runtimeImports = this.getRuntimeImports();
37
- const runtimeSpecifierMap = buildReactRuntimeSpecifierMap(runtimeImports, this.config.routerAdapter);
35
+ async createBundleOptions(componentName, isMdx, declaredModules, bundleOptions = {}) {
38
36
  const options = {
39
- external: getReactRuntimeExternalSpecifiers(),
40
37
  mainFields: ["module", "browser", "main"],
41
38
  naming: `${componentName}.[ext]`,
42
39
  ...import.meta.env?.NODE_ENV === "production" && {
@@ -45,6 +42,9 @@ class ReactBundleService {
45
42
  treeshaking: true
46
43
  }
47
44
  };
45
+ if (!bundleOptions.includeRuntime) {
46
+ options.external = getReactRuntimeExternalSpecifiers();
47
+ }
48
48
  const graphBoundaryPlugin = createClientGraphBoundaryPlugin({
49
49
  absWorkingDir: this.config.rootDir,
50
50
  declaredModules,
@@ -55,18 +55,22 @@ class ReactBundleService {
55
55
  hostJsxImportSource: this.config.jsxImportSource ?? "react",
56
56
  foreignExtensions: this.config.nonReactExtensions ?? []
57
57
  });
58
- const runtimeAliasPlugin = this.createRuntimeAliasPlugin(runtimeSpecifierMap);
59
58
  const useSyncExternalStoreShimPlugin = createUseSyncExternalStoreShimPlugin({
60
59
  name: "react-renderer-use-sync-external-store-shim",
61
60
  namespace: "ecopages-react-renderer-shim"
62
61
  });
62
+ const runtimePlugins = bundleOptions.includeRuntime ? [] : [
63
+ this.createRuntimeAliasPlugin(
64
+ buildReactRuntimeSpecifierMap(this.getRuntimeImports(), this.config.routerAdapter)
65
+ )
66
+ ];
63
67
  if (isMdx && this.config.mdxCompilerOptions) {
64
68
  const { createReactMdxLoaderPlugin } = await import("../utils/react-mdx-loader-plugin.js");
65
69
  const mdxPlugin = createReactMdxLoaderPlugin(this.config.mdxCompilerOptions);
66
70
  options.plugins = [
67
71
  foreignJsxOverridePlugin,
68
72
  graphBoundaryPlugin,
69
- runtimeAliasPlugin,
73
+ ...runtimePlugins,
70
74
  mdxPlugin,
71
75
  useSyncExternalStoreShimPlugin
72
76
  ];
@@ -74,7 +78,7 @@ class ReactBundleService {
74
78
  options.plugins = [
75
79
  foreignJsxOverridePlugin,
76
80
  graphBoundaryPlugin,
77
- runtimeAliasPlugin,
81
+ ...runtimePlugins,
78
82
  useSyncExternalStoreShimPlugin
79
83
  ];
80
84
  }
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Hydration asset creation service for React integration.
3
3
  *
4
- * Builds the asset definitions (bundled component scripts + hydration bootstrap scripts)
5
- * required for client-side React rendering — both at the page level and the component
6
- * island level.
4
+ * Builds the asset definitions required for client-side React rendering both at
5
+ * the page level and the component island level.
7
6
  *
8
7
  * @module
9
8
  */
@@ -33,27 +32,26 @@ export declare class ReactHydrationAssetService {
33
32
  private getIslandBundleName;
34
33
  private getIslandHydrationName;
35
34
  /**
36
- * Resolves the import path for the bundled page component.
35
+ * Resolves the browser import path used for a React-owned page or island module.
37
36
  * Uses HMR manager for development or constructs static path for production.
38
37
  *
39
38
  * @param pagePath - Absolute path to the page source file
40
39
  * @param assetName - Generated asset name
41
- * @returns The resolved import path for the bundled component
40
+ * @returns The resolved browser import path for the module
42
41
  */
43
42
  resolveAssetImportPath(pagePath: string, assetName: string): Promise<string>;
44
43
  /**
45
- * Creates the asset dependencies for a page: the bundled component and hydration script.
44
+ * Creates the page-owned route entry asset for hydration and client navigation.
46
45
  *
47
46
  * @param pagePath - Absolute path to the page source file
48
47
  * @param componentName - Generated unique component name
49
- * @param importPath - Resolved import path for the bundled component
48
+ * @param importPath - Resolved browser import path used by development HMR
50
49
  * @param bundleOptions - Bundle configuration options
51
50
  * @param isDevelopment - Whether running in development mode with HMR
52
51
  * @param isMdx - Whether the source file is an MDX file
53
- * @param props - Optional page props for client serialization
54
- * @returns Array of asset definitions for processing
52
+ * @returns One page-owned asset definition for processing
55
53
  */
56
- createPageDependencies(pagePath: string, componentName: string, importPath: string, bundleOptions: Record<string, unknown>, isDevelopment: boolean, isMdx: boolean, props?: Record<string, unknown>): AssetDefinition[];
54
+ createPageDependencies(pagePath: string, componentName: string, importPath: string, bundleOptions: Record<string, unknown>, isDevelopment: boolean, isMdx: boolean): AssetDefinition[];
57
55
  /**
58
56
  * Builds client-side assets for a React component island.
59
57
  *
@@ -21,12 +21,12 @@ class ReactHydrationAssetService {
21
21
  return `${bundleName}-hydration-${componentKey}`;
22
22
  }
23
23
  /**
24
- * Resolves the import path for the bundled page component.
24
+ * Resolves the browser import path used for a React-owned page or island module.
25
25
  * Uses HMR manager for development or constructs static path for production.
26
26
  *
27
27
  * @param pagePath - Absolute path to the page source file
28
28
  * @param assetName - Generated asset name
29
- * @returns The resolved import path for the bundled component
29
+ * @returns The resolved browser import path for the module
30
30
  */
31
31
  async resolveAssetImportPath(pagePath, assetName) {
32
32
  const hmrManager = this.config.assetProcessingService?.getHmrManager();
@@ -36,71 +36,44 @@ class ReactHydrationAssetService {
36
36
  return `/${path.join(RESOLVED_ASSETS_DIR, path.relative(this.config.srcDir, pagePath)).replace(path.basename(pagePath), `${assetName}.js`).replace(/\\/g, "/")}`;
37
37
  }
38
38
  /**
39
- * Creates the asset dependencies for a page: the bundled component and hydration script.
39
+ * Creates the page-owned route entry asset for hydration and client navigation.
40
40
  *
41
41
  * @param pagePath - Absolute path to the page source file
42
42
  * @param componentName - Generated unique component name
43
- * @param importPath - Resolved import path for the bundled component
43
+ * @param importPath - Resolved browser import path used by development HMR
44
44
  * @param bundleOptions - Bundle configuration options
45
45
  * @param isDevelopment - Whether running in development mode with HMR
46
46
  * @param isMdx - Whether the source file is an MDX file
47
- * @param props - Optional page props for client serialization
48
- * @returns Array of asset definitions for processing
47
+ * @returns One page-owned asset definition for processing
49
48
  */
50
- createPageDependencies(pagePath, componentName, importPath, bundleOptions, isDevelopment, isMdx, props) {
49
+ createPageDependencies(pagePath, componentName, importPath, bundleOptions, isDevelopment, isMdx) {
51
50
  const runtimeImports = this.config.bundleService.getRuntimeImports();
52
- const dependencies = [
53
- AssetFactory.createFileScript({
54
- position: "head",
55
- filepath: pagePath,
56
- name: componentName,
57
- excludeFromHtml: true,
58
- bundle: true,
59
- bundleOptions,
60
- attributes: {
61
- type: "module",
62
- defer: "",
63
- "data-eco-persist": "true"
64
- }
65
- })
66
- ];
67
- if (props && Object.keys(props).length > 0) {
68
- dependencies.push(
69
- AssetFactory.createContentScript({
70
- position: "head",
71
- content: `window.__ECO_PAGES__=window.__ECO_PAGES__||{};window.__ECO_PAGES__.page={module:"${importPath}",props:${JSON.stringify(props)}};`,
72
- name: `${componentName}-props`,
73
- bundle: false,
74
- attributes: {
75
- type: "module"
76
- }
77
- })
78
- );
79
- }
80
- dependencies.push(
51
+ return [
81
52
  AssetFactory.createContentScript({
82
53
  position: "head",
83
54
  content: createHydrationScript({
84
- importPath,
85
- reactImportPath: runtimeImports.react,
86
- reactDomClientImportPath: runtimeImports.reactDomClient,
87
- routerImportPath: runtimeImports.router,
55
+ importPath: isDevelopment ? importPath : pagePath,
56
+ reactImportPath: isDevelopment ? runtimeImports.react : "react",
57
+ reactDomClientImportPath: isDevelopment ? runtimeImports.reactDomClient : "react-dom/client",
58
+ routerImportPath: isDevelopment ? runtimeImports.router : this.config.routerAdapter?.importMapKey,
88
59
  isDevelopment,
89
60
  isMdx,
90
- router: this.config.routerAdapter
61
+ router: this.config.routerAdapter,
62
+ scriptId: componentName
91
63
  }),
92
- name: `${componentName}-hydration`,
93
- bundle: false,
64
+ name: componentName,
65
+ packageRole: "page-script",
66
+ bundle: !isDevelopment,
67
+ bundleOptions,
94
68
  attributes: {
95
69
  type: "module",
96
70
  defer: "",
97
71
  "data-eco-rerun": "true",
98
- "data-eco-script-id": `${componentName}-hydration`,
72
+ "data-eco-script-id": componentName,
99
73
  "data-eco-persist": "true"
100
74
  }
101
75
  })
102
- );
103
- return dependencies;
76
+ ];
104
77
  }
105
78
  /**
106
79
  * Builds client-side assets for a React component island.
@@ -133,6 +106,7 @@ class ReactHydrationAssetService {
133
106
  position: "head",
134
107
  filepath: componentFile,
135
108
  name: componentName,
109
+ packageRole: "dynamic-chunk",
136
110
  excludeFromHtml: true,
137
111
  bundle: true,
138
112
  bundleOptions,
@@ -154,6 +128,7 @@ class ReactHydrationAssetService {
154
128
  isDevelopment
155
129
  }),
156
130
  name: hydrationName,
131
+ packageRole: "keep-separate",
157
132
  bundle: false,
158
133
  attributes: {
159
134
  type: "module",
@@ -46,7 +46,9 @@ export declare class ReactPageModuleService {
46
46
  importMdxPageFile(filePath: string, options?: {
47
47
  bypassCache?: boolean;
48
48
  cacheScope?: string;
49
- }): Promise<unknown>;
49
+ }): Promise<EcoPageFile<{
50
+ config?: EcoComponentConfig;
51
+ }>>;
50
52
  /**
51
53
  * Ensures that an EcoComponentConfig has proper `__eco` metadata attached.
52
54
  * Resolves the file path from dependency declarations when not already set.
@@ -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,10 @@ 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
+ /** Stable id of the page entry script tag in the document. */
14
+ scriptId: string;
13
15
  /** Direct import path for React runtime module */
14
16
  reactImportPath: string;
15
17
  /** Direct import path for react-dom/client runtime module */
@@ -45,8 +45,21 @@ window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router");
45
45
  function getProdRouterBootstrapRegistrationScript() {
46
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
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
+ }
48
61
  function createDevScriptWithRouter(options) {
49
- const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath } = options;
62
+ const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath, scriptId } = options;
50
63
  const { components, getRouterProps } = router;
51
64
  if (!routerImportPath) {
52
65
  throw new Error("routerImportPath is required when router adapter is configured");
@@ -56,6 +69,12 @@ import { hydrateRoot } from "${reactDomClientImportPath}";
56
69
  import { createElement } from "${reactImportPath}";
57
70
  import { ${components.router}, ${components.pageContent} } from "${routerImportPath}";
58
71
  ${getImportStatement(importPath, isMdx)}
72
+ const pageModuleUrl = import.meta.url;
73
+ export default Page;
74
+ export const config = Page.config;
75
+ const isActivePageEntry = Boolean(document.querySelector('script[data-eco-script-id="${scriptId}"]'));
76
+
77
+ if (isActivePageEntry) {
59
78
 
60
79
  window.__ECO_PAGES__ = window.__ECO_PAGES__ || {};
61
80
  window.__ECO_PAGES__.hmrHandlers = window.__ECO_PAGES__.hmrHandlers || {};
@@ -64,6 +83,7 @@ window.__ECO_PAGES__.react.pageRoot = window.__ECO_PAGES__.react.pageRoot || nul
64
83
  let root = window.__ECO_PAGES__.react.pageRoot;
65
84
  ${getDevPageRootCleanupScript()}
66
85
  ${getDevRouterBootstrapRegistrationScript()}
86
+ ${getDevReuseExistingRouterRootScript()}
67
87
 
68
88
  const getPageData = () => {
69
89
  const el = document.getElementById("__ECO_PAGE_DATA__");
@@ -76,7 +96,7 @@ const getPageData = () => {
76
96
  const props = getPageData();
77
97
 
78
98
  window.__ECO_PAGES__.page = {
79
- module: "${importPath}",
99
+ module: pageModuleUrl,
80
100
  props
81
101
  };
82
102
 
@@ -86,7 +106,9 @@ const createTree = (Component, props) => {
86
106
  };
87
107
 
88
108
  const mount = () => {
89
- if (window.__ECO_PAGES__.react?.pageRoot) {
109
+ if (shouldReuseExistingRouterRoot()) {
110
+ root = window.__ECO_PAGES__.react.pageRoot;
111
+ } else if (window.__ECO_PAGES__.react?.pageRoot) {
90
112
  root = window.__ECO_PAGES__.react.pageRoot;
91
113
  root.render(createTree(Page, props));
92
114
  } else {
@@ -100,16 +122,25 @@ const mount = () => {
100
122
  const newModule = await import(newUrl);
101
123
  const nextProps = getPageData();
102
124
  ${getHmrImportStatement(isMdx)}
125
+ const currentPageLayout = Page.config?.layout;
126
+ const nextPageLayout = NewPage.config?.layout;
127
+
128
+ if (window.__ECO_PAGES__?.navigation?.getOwnerState().owner === "react-router") {
129
+ await window.__ECO_PAGES__?.navigation?.reloadCurrentPage?.({
130
+ clearCache: currentPageLayout !== nextPageLayout,
131
+ moduleUrl: "${importPath}",
132
+ source: "react-router"
133
+ });
134
+ console.log("[ecopages] ${getComponentType(isMdx)} component updated via router");
135
+ return;
136
+ }
137
+
103
138
  window.__ECO_PAGES__.page = {
104
- module: "${importPath}",
139
+ module: pageModuleUrl,
105
140
  props: nextProps
106
141
  };
107
142
  root.render(createTree(NewPage, nextProps));
108
- if (window.__ECO_PAGES__?.navigation?.getOwnerState().owner === "react-router") {
109
- console.log("[ecopages] ${getComponentType(isMdx)} component updated via router");
110
- } else {
111
- console.log("[ecopages] ${getComponentType(isMdx)} component updated");
112
- }
143
+ console.log("[ecopages] ${getComponentType(isMdx)} component updated");
113
144
  } catch (e) {
114
145
  console.error("[ecopages] Failed to hot-reload ${getComponentType(isMdx)} component:", e);
115
146
  }
@@ -121,14 +152,21 @@ if (document.readyState === "loading") {
121
152
  } else {
122
153
  mount();
123
154
  }
155
+ }
124
156
  `.trim();
125
157
  }
126
158
  function createDevScriptWithoutRouter(options) {
127
- const { importPath, isMdx, reactImportPath, reactDomClientImportPath } = options;
159
+ const { importPath, isMdx, reactImportPath, reactDomClientImportPath, scriptId } = options;
128
160
  return `
129
161
  import { hydrateRoot } from "${reactDomClientImportPath}";
130
162
  import { createElement } from "${reactImportPath}";
131
163
  ${getImportStatement(importPath, isMdx)}
164
+ const pageModuleUrl = import.meta.url;
165
+ export default Page;
166
+ export const config = Page.config;
167
+ const isActivePageEntry = Boolean(document.querySelector('script[data-eco-script-id="${scriptId}"]'));
168
+
169
+ if (isActivePageEntry) {
132
170
 
133
171
  window.__ECO_PAGES__ = window.__ECO_PAGES__ || {};
134
172
  window.__ECO_PAGES__.hmrHandlers = window.__ECO_PAGES__.hmrHandlers || {};
@@ -148,7 +186,7 @@ const getPageData = () => {
148
186
  const props = getPageData();
149
187
 
150
188
  window.__ECO_PAGES__.page = {
151
- module: "${importPath}",
189
+ module: pageModuleUrl,
152
190
  props
153
191
  };
154
192
 
@@ -186,25 +224,26 @@ if (document.readyState === "loading") {
186
224
  } else {
187
225
  mount();
188
226
  }
227
+ }
189
228
  `.trim();
190
229
  }
191
230
  function createProdScriptWithRouter(options) {
192
- const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath } = options;
231
+ const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath, scriptId } = options;
193
232
  const { components, getRouterProps } = router;
194
233
  if (!routerImportPath) {
195
234
  throw new Error("routerImportPath is required when router adapter is configured");
196
235
  }
197
236
  if (isMdx) {
198
- 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;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()}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:"${importPath}",props:pr};const ct=(C,p)=>ce(R,${getRouterProps("C", "p")},ce(PC));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()`;
237
+ 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=import.meta.url;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()}`;
199
238
  }
200
- 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}";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()}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:"${importPath}",props:pr};const ct=(C,p)=>ce(R,${getRouterProps("C", "p")},ce(PC));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()`;
239
+ 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=import.meta.url;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()}`;
201
240
  }
202
241
  function createProdScriptWithoutRouter(options) {
203
- const { importPath, isMdx, reactImportPath, reactDomClientImportPath } = options;
242
+ const { importPath, isMdx, reactImportPath, reactDomClientImportPath, scriptId } = options;
204
243
  if (isMdx) {
205
- 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;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:"${importPath}",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()`;
244
+ 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=import.meta.url;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()}`;
206
245
  }
207
- return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import P from"${importPath}";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:"${importPath}",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()`;
246
+ return `import{hydrateRoot as hr}from"${reactDomClientImportPath}";import{createElement as ce}from"${reactImportPath}";import P from"${importPath}";const u=import.meta.url;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()}`;
208
247
  }
209
248
  function createHydrationScript(options) {
210
249
  const { isDevelopment, router } = options;
@@ -17,11 +17,18 @@ const routerAdapter = {
17
17
  function createModuleUrl(source) {
18
18
  return `data:text/javascript;base64,${btoa(source)}`;
19
19
  }
20
- async function importModule(moduleUrl) {
20
+ async function importModule(moduleUrl, scriptId) {
21
+ let marker;
22
+ if (scriptId) {
23
+ marker = document.createElement("script");
24
+ marker.setAttribute("data-eco-script-id", scriptId);
25
+ document.head.appendChild(marker);
26
+ }
21
27
  await import(
22
28
  /* @vite-ignore */
23
29
  moduleUrl
24
30
  );
31
+ marker?.remove();
25
32
  }
26
33
  function createRuntimeModules() {
27
34
  const reactImportPath = createModuleUrl("export const createElement = (...args) => ({ args });");
@@ -71,6 +78,7 @@ describe("createHydrationScript browser execution", () => {
71
78
  const testWindow = window;
72
79
  testWindow.__ECO_REACT_HYDRATION_TEST__ = {
73
80
  hydrateCalls: [],
81
+ renderCalls: [],
74
82
  claimedOwners: [],
75
83
  releasedOwners: [],
76
84
  registrations: [],
@@ -99,11 +107,13 @@ describe("createHydrationScript browser execution", () => {
99
107
  })}<\/script>`;
100
108
  const script = createHydrationScript({
101
109
  ...runtimeModules,
110
+ scriptId: "ecopages-react-page",
102
111
  isDevelopment: true,
103
112
  isMdx: false,
104
113
  router: routerAdapter
105
114
  });
106
- await importModule(createModuleUrl(script));
115
+ const moduleUrl = createModuleUrl(script);
116
+ await importModule(moduleUrl, "ecopages-react-page");
107
117
  expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls).toHaveLength(1);
108
118
  expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls[0]?.containerTag).toBe("BODY");
109
119
  expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls[0]?.hasRecoverableErrorHandler).toBe(true);
@@ -111,7 +121,7 @@ describe("createHydrationScript browser execution", () => {
111
121
  expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations).toHaveLength(1);
112
122
  expect(typeof testWindow.__ECO_PAGES__?.react?.cleanupPageRoot).toBe("function");
113
123
  expect(testWindow.__ECO_PAGES__?.page).toEqual({
114
- module: runtimeModules.importPath,
124
+ module: moduleUrl,
115
125
  props: {
116
126
  title: "Hello React",
117
127
  locals: { theme: "dark" }
@@ -123,4 +133,67 @@ describe("createHydrationScript browser execution", () => {
123
133
  expect(testWindow.__ECO_PAGES__?.page).toBeUndefined();
124
134
  expect(testWindow.__ECO_PAGES__?.react?.pageRoot).toBeNull();
125
135
  });
136
+ it("reuses an existing router-owned page root during rerun bootstrap execution", async () => {
137
+ const runtimeModules = createRuntimeModules();
138
+ const testWindow = window;
139
+ testWindow.__ECO_REACT_HYDRATION_TEST__ = {
140
+ hydrateCalls: [],
141
+ renderCalls: [],
142
+ claimedOwners: [],
143
+ releasedOwners: [],
144
+ registrations: [],
145
+ unmountCount: 0
146
+ };
147
+ const existingRoot = {
148
+ render: (tree) => {
149
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.renderCalls.push(tree);
150
+ },
151
+ unmount: () => {
152
+ testWindow.__ECO_REACT_HYDRATION_TEST__.unmountCount += 1;
153
+ }
154
+ };
155
+ testWindow.__ECO_PAGES__ = {
156
+ navigation: {
157
+ getOwnerState: () => ({
158
+ owner: "react-router",
159
+ canHandleSpaNavigation: true
160
+ }),
161
+ register: (registration) => {
162
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations.push(registration);
163
+ },
164
+ claimOwnership: (owner) => {
165
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.claimedOwners.push(owner);
166
+ },
167
+ releaseOwnership: (owner) => {
168
+ testWindow.__ECO_REACT_HYDRATION_TEST__?.releasedOwners.push(owner);
169
+ }
170
+ },
171
+ react: {
172
+ pageRoot: existingRoot
173
+ }
174
+ };
175
+ document.body.innerHTML = `<script id="__ECO_PAGE_DATA__" type="application/json">${JSON.stringify({
176
+ title: "Rerun"
177
+ })}<\/script>`;
178
+ const script = createHydrationScript({
179
+ ...runtimeModules,
180
+ scriptId: "ecopages-react-page-rerun",
181
+ isDevelopment: true,
182
+ isMdx: false,
183
+ router: routerAdapter
184
+ });
185
+ const moduleUrl = createModuleUrl(script);
186
+ await importModule(moduleUrl, "ecopages-react-page-rerun");
187
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls).toHaveLength(0);
188
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.renderCalls).toHaveLength(0);
189
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.claimedOwners).toHaveLength(0);
190
+ expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations).toHaveLength(0);
191
+ expect(testWindow.__ECO_PAGES__?.react?.pageRoot).toBe(existingRoot);
192
+ expect(testWindow.__ECO_PAGES__?.page).toEqual({
193
+ module: moduleUrl,
194
+ props: {
195
+ title: "Rerun"
196
+ }
197
+ });
198
+ });
126
199
  });