@ecopages/react 0.2.0-alpha.10 → 0.2.0-alpha.12

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
@@ -6,17 +6,21 @@ All notable changes to `@ecopages/react` are documented here.
6
6
 
7
7
  ## [UNRELEASED] — TBD
8
8
 
9
- ### Features & Performance
9
+ ### Bug Fixes
10
10
 
11
- - **Performance Hydration**: Introduced static reachability analysis to enforce explicit hydration boundaries and optimized HMR via metadata caching.
12
- - **Service-Oriented Internals**: Refactored the integration into focused core-backed services for bundling, hydration, and page-module loading.
13
- - **React MDX**: Inlined MDX support directly into the React integration for a zero-config setup, including Node-native compatibility for experimental startup.
11
+ - Fixed development hydration, router HMR ownership, and page bootstraps across Bun, Vite, and Nitro flows.
12
+ - Fixed React page and MDX module loading to use host-provided loaders on Vite or Nitro and a lightweight browser `eco` shim in preview and build output.
13
+ - Fixed React Fast Refresh to keep React-owned island entrypoints on the React HMR path while ignoring non-React watched script entrypoints.
14
+ - Fixed `renderDocument` to prepend `<!DOCTYPE html>` for both React-managed and non-React HTML templates, matching the behavior of all other integrations.
15
+ - Fixed React island asset generation to share both bundled component modules and hydration bootstraps across repeated island instances of the same component.
14
16
 
15
- ### Bug Fixes & Refactoring
17
+ ### Features
16
18
 
17
- - **Handoff Stability**: Standardized router-backed page payloads and document owner markers for mixed-router stability during navigation.
18
- - **Hydration Hardening**: Fixed island remount races, prop collisions, and layout metadata resolution during development and route handoffs.
19
- - **Architecture**: Centralized runtime specifiers and consolidated browser-side integration state under `window.__ECO_PAGES__`.
19
+ - Added built-in React MDX support and reachability-based hydration analysis for React page bundles.
20
+
21
+ ### Refactoring
22
+
23
+ - Consolidated React bundling, hydration, and runtime state behind shared service boundaries and `window.__ECO_PAGES__`.
20
24
 
21
25
  ---
22
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react",
3
- "version": "0.2.0-alpha.10",
3
+ "version": "0.2.0-alpha.12",
4
4
  "description": "React integration for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -53,19 +53,19 @@
53
53
  "directory": "packages/integrations/react"
54
54
  },
55
55
  "peerDependencies": {
56
- "@ecopages/core": "0.2.0-alpha.10",
56
+ "@ecopages/core": "0.2.0-alpha.12",
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.10",
64
- "@ecopages/logger": "latest",
63
+ "@ecopages/file-system": "0.2.0-alpha.12",
64
+ "@ecopages/logger": "^0.2.3",
65
65
  "@mdx-js/esbuild": "^3.0.1",
66
66
  "@mdx-js/mdx": "^3.1.0",
67
- "oxc-parser": "^0.114.0",
68
- "oxc-transform": "^0.114.0",
67
+ "oxc-parser": "^0.124.0",
68
+ "oxc-transform": "^0.124.0",
69
69
  "source-map": "^0.7.6",
70
70
  "vfile": "^6.0.3"
71
71
  },
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { HmrStrategy, HmrStrategyType, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
9
+ import { HmrStrategy, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
10
10
  import type { DefaultHmrContext } from '@ecopages/core';
11
11
  import type { CompileOptions } from '@mdx-js/mdx';
12
12
  import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.js';
@@ -49,7 +49,7 @@ import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metada
49
49
  * ```
50
50
  */
51
51
  export declare class ReactHmrStrategy extends HmrStrategy {
52
- readonly type = HmrStrategyType.INTEGRATION;
52
+ readonly type: 100;
53
53
  private mdxCompilerOptions?;
54
54
  private readonly ownedTemplateExtensions;
55
55
  private readonly allTemplateExtensions;
@@ -89,6 +89,7 @@ export declare class ReactHmrStrategy extends HmrStrategy {
89
89
  */
90
90
  private isRouteTemplate;
91
91
  private resolveTemplateExtension;
92
+ private ownsWatchedEntrypoint;
92
93
  /**
93
94
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
94
95
  *
@@ -128,6 +129,7 @@ export declare class ReactHmrStrategy extends HmrStrategy {
128
129
  * @returns True if bundling was successful
129
130
  */
130
131
  private bundleReactEntrypoint;
132
+ private resolveTempOutputPath;
131
133
  /**
132
134
  * Encodes dynamic route segments (brackets) in file paths.
133
135
  * Converts `[slug]` to `_slug_` to avoid filesystem issues.
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
3
+ import { rewriteRuntimeSpecifierAliases } from "@ecopages/core/build/runtime-specifier-aliases";
3
4
  import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
4
5
  import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
5
6
  import { Logger } from "@ecopages/logger";
@@ -49,8 +50,9 @@ class ReactHmrStrategy extends HmrStrategy {
49
50
  * (including `node:*`) from breaking the browser bundle.
50
51
  */
51
52
  getBuildPlugins(declaredModules) {
52
- const allowSpecifiers = getReactClientGraphAllowSpecifiers(this.context.getSpecifierMap().keys());
53
- const runtimeAliasPlugin = createRuntimeSpecifierAliasPlugin(this.context.getSpecifierMap(), {
53
+ const runtimeSpecifierMap = this.context.getSpecifierMap();
54
+ const allowSpecifiers = getReactClientGraphAllowSpecifiers(runtimeSpecifierMap.keys());
55
+ const runtimeAliasPlugin = createRuntimeSpecifierAliasPlugin(runtimeSpecifierMap, {
54
56
  name: "react-hmr-runtime-specifier-alias"
55
57
  });
56
58
  return [
@@ -97,6 +99,9 @@ class ReactHmrStrategy extends HmrStrategy {
97
99
  resolveTemplateExtension(filePath) {
98
100
  return this.allTemplateExtensions.find((extension) => filePath.endsWith(extension));
99
101
  }
102
+ ownsWatchedEntrypoint(filePath) {
103
+ return this.pageMetadataCache.ownsEntrypoint(filePath);
104
+ }
100
105
  /**
101
106
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
102
107
  *
@@ -109,6 +114,9 @@ class ReactHmrStrategy extends HmrStrategy {
109
114
  if (watchedFiles.size === 0) {
110
115
  return false;
111
116
  }
117
+ if (watchedFiles.has(filePath)) {
118
+ return this.ownsWatchedEntrypoint(filePath);
119
+ }
112
120
  return this.isReactEntrypoint(filePath);
113
121
  }
114
122
  /**
@@ -148,6 +156,10 @@ class ReactHmrStrategy extends HmrStrategy {
148
156
  appLogger.debug(`Detected layout file change: ${_filePath}`);
149
157
  }
150
158
  const changedEntrypointOutput = watchedFiles.get(_filePath);
159
+ if (changedEntrypointOutput && !this.ownsWatchedEntrypoint(_filePath)) {
160
+ appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
161
+ return { type: "none" };
162
+ }
151
163
  const entrypointsToBuild = changedEntrypointOutput ? [[_filePath, changedEntrypointOutput]] : watchedFiles.entries();
152
164
  const updates = [];
153
165
  for (const [entrypoint, outputUrl] of entrypointsToBuild) {
@@ -225,13 +237,33 @@ class ReactHmrStrategy extends HmrStrategy {
225
237
  appLogger.error(`No output file generated for ${entrypointPath}`);
226
238
  return false;
227
239
  }
228
- const processed = await this.processOutput(tempFile, outputPath, outputUrl);
240
+ const resolvedTempFile = await this.resolveTempOutputPath(tempFile);
241
+ if (!resolvedTempFile) {
242
+ appLogger.debug(`Skipping stale temp output for ${outputUrl}: ${tempFile}`);
243
+ return false;
244
+ }
245
+ const processed = await this.processOutput(resolvedTempFile, outputPath, outputUrl);
229
246
  return processed;
230
247
  } catch (error) {
231
248
  appLogger.error(`Error bundling ${entrypointPath}:`, error);
232
249
  return false;
233
250
  }
234
251
  }
252
+ async resolveTempOutputPath(tempPath) {
253
+ if (fileSystem.exists(tempPath)) {
254
+ return tempPath;
255
+ }
256
+ if (!tempPath.includes("[hash]")) {
257
+ return tempPath;
258
+ }
259
+ const directory = path.dirname(tempPath);
260
+ const pattern = path.basename(tempPath).replaceAll("[hash]", "*");
261
+ const matches = await fileSystem.glob([pattern], { cwd: directory });
262
+ if (matches.length === 0) {
263
+ return null;
264
+ }
265
+ return path.isAbsolute(matches[0]) ? matches[0] : path.join(directory, matches[0]);
266
+ }
235
267
  /**
236
268
  * Encodes dynamic route segments (brackets) in file paths.
237
269
  * Converts `[slug]` to `_slug_` to avoid filesystem issues.
@@ -255,6 +287,7 @@ class ReactHmrStrategy extends HmrStrategy {
255
287
  }
256
288
  try {
257
289
  let code = await fileSystem.readFile(tempPath);
290
+ code = rewriteRuntimeSpecifierAliases(code, this.context.getSpecifierMap());
258
291
  code = injectHmrHandler(code);
259
292
  await fileSystem.writeAsync(finalPath, code);
260
293
  await fileSystem.removeAsync(tempPath).catch(() => {
@@ -2,7 +2,7 @@
2
2
  * This module contains the React renderer
3
3
  * @module
4
4
  */
5
- import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, EcoComponentConfig, EcoPageFile, IntegrationRendererRenderOptions, RouteRendererBody } from '@ecopages/core';
5
+ import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, EcoPageFile, IntegrationRendererRenderOptions, RouteRendererBody } from '@ecopages/core';
6
6
  import { IntegrationRenderer, type RenderToResponseContext } from '@ecopages/core/route-renderer/integration-renderer';
7
7
  import type { ProcessedAsset } from '@ecopages/core/services/asset-processing-service';
8
8
  import { type ReactNode } from 'react';
@@ -12,11 +12,6 @@ import { ReactBundleService } from './services/react-bundle.service.js';
12
12
  import { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.js';
13
13
  import { ReactPageModuleService } from './services/react-page-module.service.js';
14
14
  import { ReactHydrationAssetService } from './services/react-hydration-asset.service.js';
15
- type ReactPageModule = EcoPageFile<{
16
- config?: EcoComponentConfig;
17
- }> & {
18
- config?: EcoComponentConfig;
19
- };
20
15
  /**
21
16
  * Error thrown when an error occurs while rendering a React component.
22
17
  */
@@ -132,6 +127,20 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
132
127
  * if that shell yields a string that can be inserted into the final document.
133
128
  */
134
129
  private renderNonReactShellComponent;
130
+ /**
131
+ * Renders one React component boundary for marker-graph orchestration.
132
+ *
133
+ * When the marker resolver has already resolved child HTML for this boundary,
134
+ * the child payload must remain raw SSR output rather than a React string
135
+ * child, otherwise React would escape it. This helper renders a unique token
136
+ * through React and swaps that token back to the resolved HTML afterward.
137
+ *
138
+ * @param input Component render input reconstructed from marker metadata.
139
+ * @param context React-specific render context for stable token generation.
140
+ * @returns Serialized component HTML with resolved child markup preserved.
141
+ */
142
+ private renderComponentHtml;
143
+ private buildHydrationProps;
135
144
  /**
136
145
  * Produces the page body before the final HTML template is applied.
137
146
  *
@@ -156,6 +165,8 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
156
165
  * - When an explicit component instance id is provided, a stable
157
166
  * `data-eco-component-id` attribute is attached so island hydration can target it.
158
167
  * - Without an explicit instance id, component renders remain plain SSR output.
168
+ * - When resolved child HTML is provided, that boundary is treated as a pure SSR
169
+ * composition step and does not emit hydration assets for the parent wrapper.
159
170
  *
160
171
  * This preserves DOM shape for global CSS/layout selectors while keeping a
161
172
  * deterministic mount target per component instance.
@@ -167,6 +178,9 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
167
178
  * @returns True if the file is an MDX file
168
179
  */
169
180
  isMdxFile(filePath: string): boolean;
181
+ protected usesIntegrationPageImporter(file: string): boolean;
182
+ protected importIntegrationPageFile(file: string): Promise<EcoPageFile>;
183
+ protected normalizeImportedPageFile<TPageModule extends EcoPageFile>(file: string, pageModule: TPageModule): TPageModule;
170
184
  /**
171
185
  * Processes MDX-specific configuration dependencies including layout dependencies.
172
186
  * @param pagePath - Absolute path to the MDX page file
@@ -176,15 +190,6 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
176
190
  private processDeclaredMdxSsrLazyDependencies;
177
191
  private collectDeclaredMdxSsrLazyDependencies;
178
192
  buildRouteRenderAssets(pagePath: string): Promise<ProcessedAsset[]>;
179
- /**
180
- * Imports a page module while normalizing React MDX modules to the same shape
181
- * as ordinary React page files.
182
- *
183
- * MDX page imports can expose `config` separately from the default export. The
184
- * React renderer reattaches that config to the page component so downstream
185
- * layout, dependency, and hydration logic can treat MDX and TSX pages the same.
186
- */
187
- protected importPageFile(file: string): Promise<ReactPageModule>;
188
193
  /**
189
194
  * Renders a full route response for the filesystem page pipeline.
190
195
  *
@@ -222,4 +227,3 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
222
227
  */
223
228
  renderToResponse<P = Record<string, unknown>>(view: EcoComponent<P>, props: P, ctx: RenderToResponseContext): Promise<Response>;
224
229
  }
225
- export {};
@@ -13,7 +13,25 @@ import { hasSingleRootElement } from "./utils/html-boundary.js";
13
13
  import { ReactBundleService } from "./services/react-bundle.service.js";
14
14
  import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
15
15
  import { ReactPageModuleService } from "./services/react-page-module.service.js";
16
- import { ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
16
+ import {
17
+ getReactIslandComponentKey,
18
+ ReactHydrationAssetService
19
+ } from "./services/react-hydration-asset.service.js";
20
+ function decodeHtmlEntities(value) {
21
+ let decoded = value;
22
+ let previous;
23
+ do {
24
+ previous = decoded;
25
+ decoded = decoded.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&#x27;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
26
+ } while (decoded !== previous);
27
+ return decoded;
28
+ }
29
+ function restoreEscapedComponentMarkers(html) {
30
+ return html.replace(
31
+ /&(?:amp;)?lt;eco-marker\b[\s\S]*?&(?:amp;)?gt;&(?:amp;)?lt;\/eco-marker&(?:amp;)?gt;/g,
32
+ (marker) => decodeHtmlEntities(marker)
33
+ );
34
+ }
17
35
  class ReactRenderError extends Error {
18
36
  constructor(message) {
19
37
  super(message);
@@ -53,7 +71,9 @@ class ReactRenderer extends IntegrationRenderer {
53
71
  this.bundleService = new ReactBundleService({
54
72
  rootDir: this.appConfig.rootDir,
55
73
  routerAdapter: ReactRenderer.routerAdapter,
56
- mdxCompilerOptions: ReactRenderer.mdxCompilerOptions
74
+ mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
75
+ jsxImportSource: (this.appConfig.integrations ?? []).find((integration) => integration.name === this.name)?.jsxImportSource,
76
+ nonReactExtensions: (this.appConfig.integrations ?? []).filter((integration) => integration.name !== this.name).flatMap((integration) => integration.extensions)
57
77
  });
58
78
  this.pageModuleService = new ReactPageModuleService({
59
79
  rootDir: this.appConfig.rootDir,
@@ -197,6 +217,38 @@ class ReactRenderer extends IntegrationRenderer {
197
217
  }
198
218
  throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
199
219
  }
220
+ /**
221
+ * Renders one React component boundary for marker-graph orchestration.
222
+ *
223
+ * When the marker resolver has already resolved child HTML for this boundary,
224
+ * the child payload must remain raw SSR output rather than a React string
225
+ * child, otherwise React would escape it. This helper renders a unique token
226
+ * through React and swaps that token back to the resolved HTML afterward.
227
+ *
228
+ * @param input Component render input reconstructed from marker metadata.
229
+ * @param context React-specific render context for stable token generation.
230
+ * @returns Serialized component HTML with resolved child markup preserved.
231
+ */
232
+ renderComponentHtml(input, context) {
233
+ if (input.children === void 0) {
234
+ return restoreEscapedComponentMarkers(
235
+ renderToString(createElement(this.asReactComponent(input.component), input.props))
236
+ );
237
+ }
238
+ const resolvedChildHtml = typeof input.children === "string" ? input.children : String(input.children ?? "");
239
+ const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
240
+ const html = renderToString(
241
+ createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
242
+ );
243
+ return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(resolvedChildHtml));
244
+ }
245
+ buildHydrationProps(props) {
246
+ if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
247
+ return props ?? {};
248
+ }
249
+ const { locals: _locals, ...hydrationProps } = props;
250
+ return hydrationProps;
251
+ }
200
252
  /**
201
253
  * Produces the page body before the final HTML template is applied.
202
254
  *
@@ -206,7 +258,7 @@ class ReactRenderer extends IntegrationRenderer {
206
258
  */
207
259
  async composePageContent(options) {
208
260
  const pageElement = createElement(options.Page, options.pageProps);
209
- const pageHtml = renderToString(pageElement);
261
+ const pageHtml = restoreEscapedComponentMarkers(renderToString(pageElement));
210
262
  const layoutProps = options.locals ? { locals: options.locals } : {};
211
263
  if (!options.Layout) {
212
264
  return { contentNode: pageElement, contentHtml: pageHtml };
@@ -215,7 +267,7 @@ class ReactRenderer extends IntegrationRenderer {
215
267
  const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
216
268
  return {
217
269
  contentNode: layoutElement,
218
- contentHtml: renderToString(layoutElement)
270
+ contentHtml: restoreEscapedComponentMarkers(renderToString(layoutElement))
219
271
  };
220
272
  }
221
273
  const layoutHtml = await this.renderNonReactShellComponent(
@@ -234,19 +286,23 @@ class ReactRenderer extends IntegrationRenderer {
234
286
  */
235
287
  async renderDocument(options) {
236
288
  if (this.isReactManagedComponent(options.HtmlTemplate)) {
237
- return renderToReadableStream(
238
- createElement(
239
- this.asReactComponent(options.HtmlTemplate),
240
- {
241
- metadata: options.metadata,
242
- pageProps: options.pageProps
243
- },
244
- options.contentNode
289
+ const rawChildrenToken = "__ECO_RAW_HTML_DOCUMENT_CHILD__";
290
+ const html = restoreEscapedComponentMarkers(
291
+ renderToString(
292
+ createElement(
293
+ this.asReactComponent(options.HtmlTemplate),
294
+ {
295
+ metadata: options.metadata,
296
+ pageProps: options.pageProps
297
+ },
298
+ rawChildrenToken
299
+ )
245
300
  )
246
301
  );
302
+ return this.DOC_TYPE + html.split(rawChildrenToken).join(options.contentHtml);
247
303
  }
248
304
  const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
249
- return this.renderNonReactShellComponent(
305
+ return this.DOC_TYPE + await this.renderNonReactShellComponent(
250
306
  this.asNonReactShellComponent(options.HtmlTemplate),
251
307
  {
252
308
  metadata: options.metadata,
@@ -265,32 +321,32 @@ class ReactRenderer extends IntegrationRenderer {
265
321
  * - When an explicit component instance id is provided, a stable
266
322
  * `data-eco-component-id` attribute is attached so island hydration can target it.
267
323
  * - Without an explicit instance id, component renders remain plain SSR output.
324
+ * - When resolved child HTML is provided, that boundary is treated as a pure SSR
325
+ * composition step and does not emit hydration assets for the parent wrapper.
268
326
  *
269
327
  * This preserves DOM shape for global CSS/layout selectors while keeping a
270
328
  * deterministic mount target per component instance.
271
329
  */
272
330
  async renderComponent(input) {
273
- const Component = this.asReactComponent(input.component);
274
331
  const componentConfig = input.component.config;
275
- const element = input.children === void 0 ? createElement(Component, input.props) : createElement(Component, input.props, input.children);
276
- let html = renderToString(element);
332
+ const context = input.integrationContext ?? {};
333
+ const hasResolvedChildHtml = input.children !== void 0;
334
+ let html = this.renderComponentHtml(input, context);
277
335
  let canAttachAttributes = hasSingleRootElement(html);
278
336
  let rootTag = this.getRootTagName(html);
279
337
  const componentFile = componentConfig?.__eco?.file;
280
- const context = input.integrationContext ?? {};
281
338
  let rootAttributes;
282
339
  let assets;
283
- if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService) {
340
+ if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
284
341
  const componentInstanceId = context.componentInstanceId;
285
342
  assets = await this.hydrationAssetService.buildComponentRenderAssets(
286
343
  componentFile,
287
- componentInstanceId,
288
- input.props,
289
344
  componentConfig
290
345
  );
291
346
  rootAttributes = {
292
347
  "data-eco-component-id": componentInstanceId,
293
- "data-eco-props": btoa(JSON.stringify(input.props ?? {}))
348
+ "data-eco-component-key": getReactIslandComponentKey(componentFile, componentConfig),
349
+ "data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
294
350
  };
295
351
  }
296
352
  return {
@@ -310,13 +366,33 @@ class ReactRenderer extends IntegrationRenderer {
310
366
  isMdxFile(filePath) {
311
367
  return this.pageModuleService.isMdxFile(filePath);
312
368
  }
369
+ usesIntegrationPageImporter(file) {
370
+ return this.pageModuleService.isMdxFile(file);
371
+ }
372
+ async importIntegrationPageFile(file) {
373
+ return await this.pageModuleService.importMdxPageFile(file);
374
+ }
375
+ normalizeImportedPageFile(file, pageModule) {
376
+ const reactModule = pageModule;
377
+ const { default: Page, getMetadata, config } = reactModule;
378
+ if (this.pageModuleService.isMdxFile(file) && config) {
379
+ Page.config = config;
380
+ }
381
+ return {
382
+ ...pageModule,
383
+ default: Page,
384
+ getMetadata,
385
+ config
386
+ };
387
+ }
313
388
  /**
314
389
  * Processes MDX-specific configuration dependencies including layout dependencies.
315
390
  * @param pagePath - Absolute path to the MDX page file
316
391
  * @returns Processed assets for MDX configuration dependencies
317
392
  */
318
393
  async processMdxConfigDependencies(pagePath) {
319
- const { config } = await this.importPageFile(pagePath);
394
+ const pageModule = await this.importPageFile(pagePath);
395
+ const config = pageModule.config;
320
396
  const resolvedLayout = config?.layout;
321
397
  const components = [];
322
398
  if (resolvedLayout?.config?.dependencies) {
@@ -439,26 +515,6 @@ class ReactRenderer extends IntegrationRenderer {
439
515
  );
440
516
  }
441
517
  }
442
- /**
443
- * Imports a page module while normalizing React MDX modules to the same shape
444
- * as ordinary React page files.
445
- *
446
- * MDX page imports can expose `config` separately from the default export. The
447
- * React renderer reattaches that config to the page component so downstream
448
- * layout, dependency, and hydration logic can treat MDX and TSX pages the same.
449
- */
450
- async importPageFile(file) {
451
- const module = this.pageModuleService.isMdxFile(file) ? await this.pageModuleService.importMdxPageFile(file) : await super.importPageFile(file);
452
- const { default: Page, getMetadata, config } = module;
453
- if (this.pageModuleService.isMdxFile(file) && config) {
454
- Page.config = config;
455
- }
456
- return {
457
- default: Page,
458
- getMetadata,
459
- config
460
- };
461
- }
462
518
  /**
463
519
  * Renders a full route response for the filesystem page pipeline.
464
520
  *
@@ -38,6 +38,7 @@ class ReactPlugin extends IntegrationPlugin {
38
38
  super({
39
39
  name: PLUGIN_NAME,
40
40
  extensions,
41
+ jsxImportSource: "react",
41
42
  ...restOptions
42
43
  });
43
44
  this.mdxEnabled = options?.mdx?.enabled ?? false;
@@ -70,6 +71,7 @@ class ReactPlugin extends IntegrationPlugin {
70
71
  if (this.runtimeDependenciesInitialized) {
71
72
  return;
72
73
  }
74
+ this.runtimeBundleService.setRootDir(this.appConfig?.rootDir);
73
75
  this.integrationDependencies.unshift(...this.runtimeBundleService.getDependencies());
74
76
  this.runtimeDependenciesInitialized = true;
75
77
  }
@@ -16,6 +16,8 @@ export interface ReactBundleServiceConfig {
16
16
  rootDir: string;
17
17
  routerAdapter?: ReactRouterAdapter;
18
18
  mdxCompilerOptions?: CompileOptions;
19
+ nonReactExtensions?: string[];
20
+ jsxImportSource?: string;
19
21
  }
20
22
  /**
21
23
  * Manages esbuild bundle configuration and plugin creation for React page/component builds.
@@ -41,5 +43,5 @@ export declare class ReactBundleService {
41
43
  * Creates the esbuild plugin that rewrites bare React specifiers
42
44
  * to their runtime asset URLs.
43
45
  */
44
- createRuntimeAliasPlugin(runtimeSpecifierMap: Record<string, string>): import("packages/core/src/build/build-types.ts").EcoBuildPlugin | null;
46
+ createRuntimeAliasPlugin(runtimeSpecifierMap: Record<string, string>): import("@ecopages/core/build/build-types").EcoBuildPlugin | null;
45
47
  }
@@ -6,6 +6,7 @@ import {
6
6
  } from "../utils/react-runtime-specifier-map.js";
7
7
  import { createUseSyncExternalStoreShimPlugin } from "../utils/use-sync-external-store-shim-plugin.js";
8
8
  import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
9
+ import { createForeignJsxOverridePlugin } from "@ecopages/core/plugins/foreign-jsx-override-plugin";
9
10
  import { ReactRuntimeBundleService } from "./react-runtime-bundle.service.js";
10
11
  class ReactBundleService {
11
12
  runtimeBundleService;
@@ -13,6 +14,7 @@ class ReactBundleService {
13
14
  constructor(config) {
14
15
  this.config = config;
15
16
  this.runtimeBundleService = new ReactRuntimeBundleService({
17
+ rootDir: config.rootDir,
16
18
  routerAdapter: config.routerAdapter
17
19
  });
18
20
  }
@@ -48,6 +50,11 @@ class ReactBundleService {
48
50
  declaredModules,
49
51
  alwaysAllowSpecifiers: getReactClientGraphAllowSpecifiers([], this.config.routerAdapter)
50
52
  });
53
+ const foreignJsxOverridePlugin = createForeignJsxOverridePlugin({
54
+ name: "react-renderer-foreign-jsx-override",
55
+ hostJsxImportSource: this.config.jsxImportSource ?? "react",
56
+ foreignExtensions: this.config.nonReactExtensions ?? []
57
+ });
51
58
  const runtimeAliasPlugin = this.createRuntimeAliasPlugin(runtimeSpecifierMap);
52
59
  const useSyncExternalStoreShimPlugin = createUseSyncExternalStoreShimPlugin({
53
60
  name: "react-renderer-use-sync-external-store-shim",
@@ -56,9 +63,20 @@ class ReactBundleService {
56
63
  if (isMdx && this.config.mdxCompilerOptions) {
57
64
  const { createReactMdxLoaderPlugin } = await import("../utils/react-mdx-loader-plugin.js");
58
65
  const mdxPlugin = createReactMdxLoaderPlugin(this.config.mdxCompilerOptions);
59
- options.plugins = [graphBoundaryPlugin, runtimeAliasPlugin, mdxPlugin, useSyncExternalStoreShimPlugin];
66
+ options.plugins = [
67
+ foreignJsxOverridePlugin,
68
+ graphBoundaryPlugin,
69
+ runtimeAliasPlugin,
70
+ mdxPlugin,
71
+ useSyncExternalStoreShimPlugin
72
+ ];
60
73
  } else {
61
- options.plugins = [graphBoundaryPlugin, runtimeAliasPlugin, useSyncExternalStoreShimPlugin];
74
+ options.plugins = [
75
+ foreignJsxOverridePlugin,
76
+ graphBoundaryPlugin,
77
+ runtimeAliasPlugin,
78
+ useSyncExternalStoreShimPlugin
79
+ ];
62
80
  }
63
81
  return options;
64
82
  }
@@ -6,6 +6,11 @@
6
6
  */
7
7
  export declare class ReactHmrPageMetadataCache {
8
8
  private readonly declaredModulesByEntrypoint;
9
+ private readonly ownedEntrypoints;
10
+ /**
11
+ * Marks an HMR entrypoint as React-owned.
12
+ */
13
+ markOwnedEntrypoint(entrypointPath: string): void;
9
14
  /**
10
15
  * Stores the declared browser modules for a page entrypoint.
11
16
  */
@@ -14,4 +19,8 @@ export declare class ReactHmrPageMetadataCache {
14
19
  * Returns the last known declared browser modules for a page entrypoint.
15
20
  */
16
21
  getDeclaredModules(entrypointPath: string): string[] | undefined;
22
+ /**
23
+ * Returns true when the watched entrypoint is owned by the React integration.
24
+ */
25
+ ownsEntrypoint(entrypointPath: string): boolean;
17
26
  }
@@ -1,18 +1,34 @@
1
+ import path from "node:path";
1
2
  class ReactHmrPageMetadataCache {
2
3
  declaredModulesByEntrypoint = /* @__PURE__ */ new Map();
4
+ ownedEntrypoints = /* @__PURE__ */ new Set();
5
+ /**
6
+ * Marks an HMR entrypoint as React-owned.
7
+ */
8
+ markOwnedEntrypoint(entrypointPath) {
9
+ this.ownedEntrypoints.add(path.resolve(entrypointPath));
10
+ }
3
11
  /**
4
12
  * Stores the declared browser modules for a page entrypoint.
5
13
  */
6
14
  setDeclaredModules(entrypointPath, declaredModules) {
7
- this.declaredModulesByEntrypoint.set(entrypointPath, [...declaredModules]);
15
+ const resolvedEntrypointPath = path.resolve(entrypointPath);
16
+ this.markOwnedEntrypoint(resolvedEntrypointPath);
17
+ this.declaredModulesByEntrypoint.set(resolvedEntrypointPath, [...declaredModules]);
8
18
  }
9
19
  /**
10
20
  * Returns the last known declared browser modules for a page entrypoint.
11
21
  */
12
22
  getDeclaredModules(entrypointPath) {
13
- const declaredModules = this.declaredModulesByEntrypoint.get(entrypointPath);
23
+ const declaredModules = this.declaredModulesByEntrypoint.get(path.resolve(entrypointPath));
14
24
  return declaredModules ? [...declaredModules] : void 0;
15
25
  }
26
+ /**
27
+ * Returns true when the watched entrypoint is owned by the React integration.
28
+ */
29
+ ownsEntrypoint(entrypointPath) {
30
+ return this.ownedEntrypoints.has(path.resolve(entrypointPath));
31
+ }
16
32
  }
17
33
  export {
18
34
  ReactHmrPageMetadataCache
@@ -23,21 +23,24 @@ export interface ReactHydrationAssetServiceConfig {
23
23
  bundleService: ReactBundleService;
24
24
  hmrPageMetadataCache?: ReactHmrPageMetadataCache;
25
25
  }
26
+ export declare function getReactIslandComponentKey(componentFile: string, config?: EcoComponentConfig): string;
26
27
  /**
27
28
  * Manages the creation of client-side hydration assets for React pages and component islands.
28
29
  */
29
30
  export declare class ReactHydrationAssetService {
30
31
  private readonly config;
31
32
  constructor(config: ReactHydrationAssetServiceConfig);
33
+ private getIslandBundleName;
34
+ private getIslandHydrationName;
32
35
  /**
33
36
  * Resolves the import path for the bundled page component.
34
37
  * Uses HMR manager for development or constructs static path for production.
35
38
  *
36
39
  * @param pagePath - Absolute path to the page source file
37
- * @param componentName - Generated unique component name
40
+ * @param assetName - Generated asset name
38
41
  * @returns The resolved import path for the bundled component
39
42
  */
40
- resolveAssetImportPath(pagePath: string, componentName: string): Promise<string>;
43
+ resolveAssetImportPath(pagePath: string, assetName: string): Promise<string>;
41
44
  /**
42
45
  * Creates the asset dependencies for a page: the bundled component and hydration script.
43
46
  *
@@ -54,15 +57,13 @@ export declare class ReactHydrationAssetService {
54
57
  /**
55
58
  * Builds client-side assets for a React component island.
56
59
  *
57
- * Includes the bundled component entry and an inline hydration bootstrap script.
60
+ * Includes the bundled component entry and a shared hydration bootstrap script.
58
61
  *
59
62
  * @param componentFile - Absolute path to the component source file
60
- * @param componentInstanceId - Unique instance ID for DOM targeting
61
- * @param props - Serialized props for client-side hydration
62
63
  * @param config - Optional component config with `__eco` metadata
63
64
  * @returns Processed assets ready for injection
64
65
  */
65
- buildComponentRenderAssets(componentFile: string, componentInstanceId: string, props: Record<string, unknown>, config?: EcoComponentConfig): Promise<ProcessedAsset[]>;
66
+ buildComponentRenderAssets(componentFile: string, config?: EcoComponentConfig): Promise<ProcessedAsset[]>;
66
67
  /**
67
68
  * Builds all client-side route assets for a page.
68
69
  *
@@ -6,25 +6,34 @@ import {
6
6
  } from "@ecopages/core/services/asset-processing-service";
7
7
  import { createHydrationScript, createIslandHydrationScript } from "../utils/hydration-scripts.js";
8
8
  import { collectDeclaredModulesInConfig } from "../utils/declared-modules.js";
9
+ function getReactIslandComponentKey(componentFile, config) {
10
+ return rapidhash(`${componentFile}:${config?.__eco?.id ?? ""}`).toString();
11
+ }
9
12
  class ReactHydrationAssetService {
10
13
  config;
11
14
  constructor(config) {
12
15
  this.config = config;
13
16
  }
17
+ getIslandBundleName(componentFile) {
18
+ return `ecopages-react-island-${rapidhash(componentFile)}`;
19
+ }
20
+ getIslandHydrationName(bundleName, componentKey) {
21
+ return `${bundleName}-hydration-${componentKey}`;
22
+ }
14
23
  /**
15
24
  * Resolves the import path for the bundled page component.
16
25
  * Uses HMR manager for development or constructs static path for production.
17
26
  *
18
27
  * @param pagePath - Absolute path to the page source file
19
- * @param componentName - Generated unique component name
28
+ * @param assetName - Generated asset name
20
29
  * @returns The resolved import path for the bundled component
21
30
  */
22
- async resolveAssetImportPath(pagePath, componentName) {
31
+ async resolveAssetImportPath(pagePath, assetName) {
23
32
  const hmrManager = this.config.assetProcessingService?.getHmrManager();
24
33
  if (hmrManager?.isEnabled()) {
25
34
  return hmrManager.registerEntrypoint(pagePath);
26
35
  }
27
- return `/${path.join(RESOLVED_ASSETS_DIR, path.relative(this.config.srcDir, pagePath)).replace(path.basename(pagePath), `${componentName}.js`).replace(/\\/g, "/")}`;
36
+ return `/${path.join(RESOLVED_ASSETS_DIR, path.relative(this.config.srcDir, pagePath)).replace(path.basename(pagePath), `${assetName}.js`).replace(/\\/g, "/")}`;
28
37
  }
29
38
  /**
30
39
  * Creates the asset dependencies for a page: the bundled component and hydration script.
@@ -96,19 +105,22 @@ class ReactHydrationAssetService {
96
105
  /**
97
106
  * Builds client-side assets for a React component island.
98
107
  *
99
- * Includes the bundled component entry and an inline hydration bootstrap script.
108
+ * Includes the bundled component entry and a shared hydration bootstrap script.
100
109
  *
101
110
  * @param componentFile - Absolute path to the component source file
102
- * @param componentInstanceId - Unique instance ID for DOM targeting
103
- * @param props - Serialized props for client-side hydration
104
111
  * @param config - Optional component config with `__eco` metadata
105
112
  * @returns Processed assets ready for injection
106
113
  */
107
- async buildComponentRenderAssets(componentFile, componentInstanceId, props, config) {
108
- const componentName = `ecopages-react-island-${rapidhash(`${componentFile}:${componentInstanceId}`)}`;
109
- const importPath = await this.resolveAssetImportPath(componentFile, componentName);
114
+ async buildComponentRenderAssets(componentFile, config) {
115
+ const componentName = this.getIslandBundleName(componentFile);
116
+ const componentKey = getReactIslandComponentKey(componentFile, config);
117
+ const hydrationName = this.getIslandHydrationName(componentName, componentKey);
110
118
  const hmrManager = this.config.assetProcessingService?.getHmrManager();
111
119
  const isDevelopment = hmrManager?.isEnabled() ?? false;
120
+ if (isDevelopment) {
121
+ this.config.hmrPageMetadataCache?.markOwnedEntrypoint(componentFile);
122
+ }
123
+ const importPath = await this.resolveAssetImportPath(componentFile, componentName);
112
124
  const declaredModules = collectDeclaredModulesInConfig(config);
113
125
  const bundleOptions = await this.config.bundleService.createBundleOptions(
114
126
  componentName,
@@ -136,19 +148,18 @@ class ReactHydrationAssetService {
136
148
  importPath,
137
149
  reactImportPath: runtimeImports.react,
138
150
  reactDomClientImportPath: runtimeImports.reactDomClient,
139
- targetSelector: `[data-eco-component-id="${componentInstanceId}"]`,
140
- props,
151
+ targetSelector: `[data-eco-component-key="${componentKey}"]`,
141
152
  componentRef: config?.__eco?.id,
142
153
  componentFile,
143
154
  isDevelopment
144
155
  }),
145
- name: `${componentName}-hydration`,
156
+ name: hydrationName,
146
157
  bundle: false,
147
158
  attributes: {
148
159
  type: "module",
149
160
  defer: "",
150
161
  "data-eco-rerun": "true",
151
- "data-eco-script-id": `${componentName}-hydration`,
162
+ "data-eco-script-id": hydrationName,
152
163
  "data-eco-persist": "true"
153
164
  }
154
165
  })
@@ -194,5 +205,6 @@ class ReactHydrationAssetService {
194
205
  }
195
206
  }
196
207
  export {
197
- ReactHydrationAssetService
208
+ ReactHydrationAssetService,
209
+ getReactIslandComponentKey
198
210
  };
@@ -42,7 +42,7 @@ class ReactPageModuleService {
42
42
  entrypoints: [filePath],
43
43
  root: this.config.rootDir,
44
44
  outdir,
45
- target: "node",
45
+ target: "es2022",
46
46
  format: "esm",
47
47
  sourcemap: "none",
48
48
  splitting: false,
@@ -62,7 +62,10 @@ class ReactPageModuleService {
62
62
  if (!compiledOutput) {
63
63
  throw new Error(`No compiled MDX output generated for page: ${filePath}`);
64
64
  }
65
- return await import(pathToFileURL(compiledOutput).href);
65
+ return await import(
66
+ /* @vite-ignore */
67
+ pathToFileURL(compiledOutput).href
68
+ );
66
69
  }
67
70
  /**
68
71
  * Ensures that an EcoComponentConfig has proper `__eco` metadata attached.
@@ -19,11 +19,13 @@ export type ReactRuntimeImports = {
19
19
  };
20
20
  export interface ReactRuntimeBundleServiceConfig {
21
21
  routerAdapter?: ReactRouterAdapter;
22
+ rootDir?: string;
22
23
  }
23
24
  type RuntimeMode = 'development' | 'production';
24
25
  export declare class ReactRuntimeBundleService {
25
26
  private readonly config;
26
27
  constructor(config: ReactRuntimeBundleServiceConfig);
28
+ setRootDir(rootDir: string | undefined): void;
27
29
  private get isDevelopment();
28
30
  private getCurrentRuntimeMode;
29
31
  private createRuntimeDefines;
@@ -11,6 +11,9 @@ class ReactRuntimeBundleService {
11
11
  constructor(config) {
12
12
  this.config = config;
13
13
  }
14
+ setRootDir(rootDir) {
15
+ this.config.rootDir = rootDir;
16
+ }
14
17
  get isDevelopment() {
15
18
  return process.env.NODE_ENV === "development";
16
19
  }
@@ -79,6 +82,7 @@ class ReactRuntimeBundleService {
79
82
  name: "react",
80
83
  fileName: this.getReactVendorFileName(mode),
81
84
  cacheDirName: `ecopages-react-runtime-${mode}`,
85
+ rootDir: this.config.rootDir,
82
86
  bundleOptions: {
83
87
  define: this.createRuntimeDefines(mode)
84
88
  }
@@ -88,6 +92,7 @@ class ReactRuntimeBundleService {
88
92
  name: "react-dom",
89
93
  fileName: this.getReactDomVendorFileName(mode),
90
94
  cacheDirName: `ecopages-react-runtime-${mode}`,
95
+ rootDir: this.config.rootDir,
91
96
  bundleOptions: {
92
97
  define: this.createRuntimeDefines(mode),
93
98
  plugins: reactDomBundlePlugins
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, extname, resolve } from "node:path";
3
3
  import { parseSync } from "oxc-parser";
4
- import { analyzeReachability } from "./reachability-analyzer";
4
+ import { analyzeReachability } from "./reachability-analyzer.js";
5
5
  const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
6
6
  const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = /* @__PURE__ */ new Set([
7
7
  "cache",
@@ -484,7 +484,7 @@ function createClientGraphBoundaryPlugin(options) {
484
484
  }
485
485
  if (!modified) return void 0;
486
486
  const ext = extname(args.path).slice(1);
487
- return { contents: transformed, loader: ext };
487
+ return { contents: transformed, loader: ext, resolveDir: dirname(args.path) };
488
488
  });
489
489
  }
490
490
  };
@@ -41,7 +41,10 @@ function collectPageDeclaredModulesFromModule(pageModule) {
41
41
  }
42
42
  async function collectPageDeclaredModules(pagePath) {
43
43
  try {
44
- const pageModule = await import(pagePath);
44
+ const pageModule = await import(
45
+ /* @vite-ignore */
46
+ pagePath
47
+ );
45
48
  return collectPageDeclaredModulesFromModule(pageModule);
46
49
  } catch {
47
50
  return [];
@@ -30,10 +30,8 @@ export type IslandHydrationScriptOptions = {
30
30
  reactImportPath: string;
31
31
  /** Browser import path for react-dom/client runtime. */
32
32
  reactDomClientImportPath: string;
33
- /** Selector that resolves to the SSR root element for this island instance. */
33
+ /** Selector that resolves to all SSR root elements for this island component. */
34
34
  targetSelector: string;
35
- /** Serialized component props emitted at render time. */
36
- props: Record<string, unknown>;
37
35
  /** Optional stable component id used to resolve named exports reliably. */
38
36
  componentRef?: string;
39
37
  /** Optional source file hint used as fallback for component resolution. */
@@ -31,16 +31,19 @@ function getProdPageRootCleanupScript() {
31
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
32
  }
33
33
  function getDevRouterBootstrapRegistrationScript() {
34
- return `window.__ECO_PAGES__?.navigation?.register({
34
+ return `const currentOwnerState = window.__ECO_PAGES__?.navigation?.getOwnerState?.();
35
+ if (!(currentOwnerState?.owner === "react-router" && currentOwnerState.canHandleSpaNavigation)) {
36
+ window.__ECO_PAGES__?.navigation?.register({
35
37
  owner: "react-router",
36
38
  cleanupBeforeHandoff: async () => {
37
39
  window.__ECO_PAGES__?.react?.cleanupPageRoot?.();
38
40
  }
39
41
  });
40
- window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router");`;
42
+ window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router");
43
+ }`;
41
44
  }
42
45
  function getProdRouterBootstrapRegistrationScript() {
43
- return 'window.__ECO_PAGES__?.navigation?.register({owner:"react-router",cleanupBeforeHandoff:async()=>{window.__ECO_PAGES__?.react?.cleanupPageRoot?.()}});window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router");';
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")}';
44
47
  }
45
48
  function createDevScriptWithRouter(options) {
46
49
  const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath } = options;
@@ -93,16 +96,20 @@ const mount = () => {
93
96
  window.__ECO_PAGES__.react.pageRoot = root;
94
97
  }
95
98
  window.__ECO_PAGES__.hmrHandlers["${importPath}"] = async (newUrl) => {
96
- if (window.__ECO_PAGES__?.navigation?.getOwnerState().owner === "react-router") {
97
- await window.__ECO_PAGES__?.navigation?.reloadCurrentPage?.({ clearCache: false, source: "react-router" });
98
- console.log("[ecopages] ${getComponentType(isMdx)} component updated via router");
99
- return;
100
- }
101
99
  try {
102
100
  const newModule = await import(newUrl);
101
+ const nextProps = getPageData();
103
102
  ${getHmrImportStatement(isMdx)}
104
- root.render(createTree(NewPage, props));
105
- console.log("[ecopages] ${getComponentType(isMdx)} component updated");
103
+ window.__ECO_PAGES__.page = {
104
+ module: "${importPath}",
105
+ props: nextProps
106
+ };
107
+ 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
+ }
106
113
  } catch (e) {
107
114
  console.error("[ecopages] Failed to hot-reload ${getComponentType(isMdx)} component:", e);
108
115
  }
@@ -245,17 +252,22 @@ const resolveComponent = () => {
245
252
  };
246
253
 
247
254
  const mount = () => {
248
- const target = document.querySelector(${targetSelector});
255
+ const targets = document.querySelectorAll(${targetSelector});
249
256
  const Component = resolveComponent();
250
- if (!target || !Component) {
257
+ if (!Component || targets.length === 0) {
251
258
  return;
252
259
  }
253
- const props = JSON.parse(atob(target.getAttribute("data-eco-props") || "e30="));
254
- const container = document.createElement("eco-island");
255
- container.style.display = "block";
256
- target.replaceWith(container);
257
- const root = createRoot(container);
258
- root.render(createElement(Component, props));
260
+ targets.forEach((target) => {
261
+ if (!(target instanceof HTMLElement)) {
262
+ return;
263
+ }
264
+ const props = JSON.parse(atob(target.getAttribute("data-eco-props") || "e30="));
265
+ const container = document.createElement("eco-island");
266
+ container.style.display = "block";
267
+ target.replaceWith(container);
268
+ const root = createRoot(container);
269
+ root.render(createElement(Component, props));
270
+ });
259
271
  };
260
272
 
261
273
  if (document.readyState === "loading") {
@@ -265,7 +277,7 @@ if (document.readyState === "loading") {
265
277
  }
266
278
  `.trim();
267
279
  }
268
- 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)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()`;
280
+ 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()`;
269
281
  }
270
282
  export {
271
283
  createHydrationScript,