@ecopages/react 0.2.0-alpha.11 → 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
@@ -10,6 +10,9 @@ All notable changes to `@ecopages/react` are documented here.
10
10
 
11
11
  - Fixed development hydration, router HMR ownership, and page bootstraps across Bun, Vite, and Nitro flows.
12
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.
13
16
 
14
17
  ### Features
15
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react",
3
- "version": "0.2.0-alpha.11",
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.11",
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.11",
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
  },
@@ -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
  *
@@ -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) {
@@ -275,6 +287,7 @@ class ReactHmrStrategy extends HmrStrategy {
275
287
  }
276
288
  try {
277
289
  let code = await fileSystem.readFile(tempPath);
290
+ code = rewriteRuntimeSpecifierAliases(code, this.context.getSpecifierMap());
278
291
  code = injectHmrHandler(code);
279
292
  await fileSystem.writeAsync(finalPath, code);
280
293
  await fileSystem.removeAsync(tempPath).catch(() => {
@@ -130,14 +130,14 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
130
130
  /**
131
131
  * Renders one React component boundary for marker-graph orchestration.
132
132
  *
133
- * When the marker resolver has already stitched child HTML for this boundary,
133
+ * When the marker resolver has already resolved child HTML for this boundary,
134
134
  * the child payload must remain raw SSR output rather than a React string
135
135
  * child, otherwise React would escape it. This helper renders a unique token
136
- * through React and swaps that token back to the stitched HTML afterward.
136
+ * through React and swaps that token back to the resolved HTML afterward.
137
137
  *
138
138
  * @param input Component render input reconstructed from marker metadata.
139
139
  * @param context React-specific render context for stable token generation.
140
- * @returns Serialized component HTML with stitched child markup preserved.
140
+ * @returns Serialized component HTML with resolved child markup preserved.
141
141
  */
142
142
  private renderComponentHtml;
143
143
  private buildHydrationProps;
@@ -165,7 +165,7 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
165
165
  * - When an explicit component instance id is provided, a stable
166
166
  * `data-eco-component-id` attribute is attached so island hydration can target it.
167
167
  * - Without an explicit instance id, component renders remain plain SSR output.
168
- * - When stitched child HTML is provided, that boundary is treated as a pure SSR
168
+ * - When resolved child HTML is provided, that boundary is treated as a pure SSR
169
169
  * composition step and does not emit hydration assets for the parent wrapper.
170
170
  *
171
171
  * This preserves DOM shape for global CSS/layout selectors while keeping a
@@ -13,7 +13,10 @@ 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";
17
20
  function decodeHtmlEntities(value) {
18
21
  let decoded = value;
19
22
  let previous;
@@ -217,14 +220,14 @@ class ReactRenderer extends IntegrationRenderer {
217
220
  /**
218
221
  * Renders one React component boundary for marker-graph orchestration.
219
222
  *
220
- * When the marker resolver has already stitched child HTML for this boundary,
223
+ * When the marker resolver has already resolved child HTML for this boundary,
221
224
  * the child payload must remain raw SSR output rather than a React string
222
225
  * child, otherwise React would escape it. This helper renders a unique token
223
- * through React and swaps that token back to the stitched HTML afterward.
226
+ * through React and swaps that token back to the resolved HTML afterward.
224
227
  *
225
228
  * @param input Component render input reconstructed from marker metadata.
226
229
  * @param context React-specific render context for stable token generation.
227
- * @returns Serialized component HTML with stitched child markup preserved.
230
+ * @returns Serialized component HTML with resolved child markup preserved.
228
231
  */
229
232
  renderComponentHtml(input, context) {
230
233
  if (input.children === void 0) {
@@ -232,11 +235,12 @@ class ReactRenderer extends IntegrationRenderer {
232
235
  renderToString(createElement(this.asReactComponent(input.component), input.props))
233
236
  );
234
237
  }
238
+ const resolvedChildHtml = typeof input.children === "string" ? input.children : String(input.children ?? "");
235
239
  const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
236
240
  const html = renderToString(
237
241
  createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
238
242
  );
239
- return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(input.children));
243
+ return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(resolvedChildHtml));
240
244
  }
241
245
  buildHydrationProps(props) {
242
246
  if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
@@ -295,10 +299,10 @@ class ReactRenderer extends IntegrationRenderer {
295
299
  )
296
300
  )
297
301
  );
298
- return html.split(rawChildrenToken).join(options.contentHtml);
302
+ return this.DOC_TYPE + html.split(rawChildrenToken).join(options.contentHtml);
299
303
  }
300
304
  const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
301
- return this.renderNonReactShellComponent(
305
+ return this.DOC_TYPE + await this.renderNonReactShellComponent(
302
306
  this.asNonReactShellComponent(options.HtmlTemplate),
303
307
  {
304
308
  metadata: options.metadata,
@@ -317,7 +321,7 @@ class ReactRenderer extends IntegrationRenderer {
317
321
  * - When an explicit component instance id is provided, a stable
318
322
  * `data-eco-component-id` attribute is attached so island hydration can target it.
319
323
  * - Without an explicit instance id, component renders remain plain SSR output.
320
- * - When stitched child HTML is provided, that boundary is treated as a pure SSR
324
+ * - When resolved child HTML is provided, that boundary is treated as a pure SSR
321
325
  * composition step and does not emit hydration assets for the parent wrapper.
322
326
  *
323
327
  * This preserves DOM shape for global CSS/layout selectors while keeping a
@@ -337,12 +341,11 @@ class ReactRenderer extends IntegrationRenderer {
337
341
  const componentInstanceId = context.componentInstanceId;
338
342
  assets = await this.hydrationAssetService.buildComponentRenderAssets(
339
343
  componentFile,
340
- componentInstanceId,
341
- this.buildHydrationProps(input.props),
342
344
  componentConfig
343
345
  );
344
346
  rootAttributes = {
345
347
  "data-eco-component-id": componentInstanceId,
348
+ "data-eco-component-key": getReactIslandComponentKey(componentFile, componentConfig),
346
349
  "data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
347
350
  };
348
351
  }
@@ -71,6 +71,7 @@ class ReactPlugin extends IntegrationPlugin {
71
71
  if (this.runtimeDependenciesInitialized) {
72
72
  return;
73
73
  }
74
+ this.runtimeBundleService.setRootDir(this.appConfig?.rootDir);
74
75
  this.integrationDependencies.unshift(...this.runtimeBundleService.getDependencies());
75
76
  this.runtimeDependenciesInitialized = true;
76
77
  }
@@ -4,9 +4,9 @@ import {
4
4
  getReactClientGraphAllowSpecifiers,
5
5
  getReactRuntimeExternalSpecifiers
6
6
  } from "../utils/react-runtime-specifier-map.js";
7
- import { createForeignJsxOverridePlugin } from "../utils/foreign-jsx-override-plugin.js";
8
7
  import { createUseSyncExternalStoreShimPlugin } from "../utils/use-sync-external-store-shim-plugin.js";
9
8
  import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
9
+ import { createForeignJsxOverridePlugin } from "@ecopages/core/plugins/foreign-jsx-override-plugin";
10
10
  import { ReactRuntimeBundleService } from "./react-runtime-bundle.service.js";
11
11
  class ReactBundleService {
12
12
  runtimeBundleService;
@@ -14,6 +14,7 @@ class ReactBundleService {
14
14
  constructor(config) {
15
15
  this.config = config;
16
16
  this.runtimeBundleService = new ReactRuntimeBundleService({
17
+ rootDir: config.rootDir,
17
18
  routerAdapter: config.routerAdapter
18
19
  });
19
20
  }
@@ -49,9 +50,10 @@ class ReactBundleService {
49
50
  declaredModules,
50
51
  alwaysAllowSpecifiers: getReactClientGraphAllowSpecifiers([], this.config.routerAdapter)
51
52
  });
52
- const foreignJsxOverridePlugin = createForeignJsxOverridePlugin(this.config.nonReactExtensions ?? [], {
53
+ const foreignJsxOverridePlugin = createForeignJsxOverridePlugin({
53
54
  name: "react-renderer-foreign-jsx-override",
54
- jsxImportSource: this.config.jsxImportSource ?? "react"
55
+ hostJsxImportSource: this.config.jsxImportSource ?? "react",
56
+ foreignExtensions: this.config.nonReactExtensions ?? []
55
57
  });
56
58
  const runtimeAliasPlugin = this.createRuntimeAliasPlugin(runtimeSpecifierMap);
57
59
  const useSyncExternalStoreShimPlugin = createUseSyncExternalStoreShimPlugin({
@@ -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,
@@ -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
@@ -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. */
@@ -252,17 +252,22 @@ const resolveComponent = () => {
252
252
  };
253
253
 
254
254
  const mount = () => {
255
- const target = document.querySelector(${targetSelector});
255
+ const targets = document.querySelectorAll(${targetSelector});
256
256
  const Component = resolveComponent();
257
- if (!target || !Component) {
257
+ if (!Component || targets.length === 0) {
258
258
  return;
259
259
  }
260
- const props = JSON.parse(atob(target.getAttribute("data-eco-props") || "e30="));
261
- const container = document.createElement("eco-island");
262
- container.style.display = "block";
263
- target.replaceWith(container);
264
- const root = createRoot(container);
265
- 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
+ });
266
271
  };
267
272
 
268
273
  if (document.readyState === "loading") {
@@ -272,7 +277,7 @@ if (document.readyState === "loading") {
272
277
  }
273
278
  `.trim();
274
279
  }
275
- 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()`;
276
281
  }
277
282
  export {
278
283
  createHydrationScript,
@@ -1,19 +0,0 @@
1
- import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
2
- interface ForeignJsxOverrideOptions {
3
- jsxImportSource: string;
4
- name?: string;
5
- }
6
- /**
7
- * Esbuild plugin that overrides the JSX import source for non-host integration
8
- * files (`.lit.tsx`, `.kita.tsx`, etc.) when bundled into a host client bundle.
9
- *
10
- * Without this plugin, non-host component files inherit the project-level
11
- * `jsxImportSource` from tsconfig (typically `@kitajs/html`), which produces
12
- * HTML strings from JSX. When the host framework calls those functions during
13
- * hydration, it renders the string as a text node instead of a DOM element.
14
- *
15
- * This plugin prepends the host's `@jsxImportSource` pragma so esbuild compiles
16
- * their JSX to the host framework's element creation calls.
17
- */
18
- export declare function createForeignJsxOverridePlugin(nonReactExtensions: string[], options: ForeignJsxOverrideOptions): EcoBuildPlugin;
19
- export {};
@@ -1,43 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- function createForeignJsxOverridePlugin(nonReactExtensions, options) {
3
- const extensions = nonReactExtensions.filter((ext) => ext.endsWith(".tsx") || ext.endsWith(".jsx"));
4
- if (extensions.length === 0) {
5
- return {
6
- name: options.name ?? "react-foreign-jsx-override",
7
- setup() {
8
- }
9
- };
10
- }
11
- function matchesNonReactExtension(id) {
12
- for (const ext of extensions) {
13
- if (id.endsWith(ext)) {
14
- return true;
15
- }
16
- }
17
- return false;
18
- }
19
- const pragma = `/** @jsxImportSource ${options.jsxImportSource} */
20
- `;
21
- const filter = new RegExp(`(${extensions.map((e) => e.replace(".", "\\.")).join("|")})$`);
22
- return {
23
- name: options.name ?? "react-foreign-jsx-override",
24
- setup(build) {
25
- build.onLoad({ filter }, (args) => {
26
- if (!matchesNonReactExtension(args.path)) {
27
- return void 0;
28
- }
29
- const source = readFileSync(args.path, "utf-8");
30
- if (source.includes("@jsxImportSource")) {
31
- return void 0;
32
- }
33
- return {
34
- contents: pragma + source,
35
- loader: "tsx"
36
- };
37
- });
38
- }
39
- };
40
- }
41
- export {
42
- createForeignJsxOverridePlugin
43
- };