@ecopages/react 0.2.0-alpha.11 → 0.2.0-alpha.13

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,8 +8,7 @@ All notable changes to `@ecopages/react` are documented here.
8
8
 
9
9
  ### Bug Fixes
10
10
 
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.
11
+ - Fixed React hydration, Fast Refresh, module loading, doctype handling, island asset reuse, and mixed-renderer boundary resolution across Bun, Vite, and Nitro flows.
13
12
 
14
13
  ### Features
15
14
 
@@ -19,10 +18,12 @@ All notable changes to `@ecopages/react` are documented here.
19
18
 
20
19
  - Consolidated React bundling, hydration, and runtime state behind shared service boundaries and `window.__ECO_PAGES__`.
21
20
 
21
+ ### Documentation
22
+
23
+ - Updated the README to document React-owned mixed boundaries and React MDX setup.
24
+
22
25
  ---
23
26
 
24
27
  ## Migration Notes
25
28
 
26
- - The React integration now requires explicit client boundary declarations for client-rendered components.
27
29
  - React MDX support is built in and no longer requires installing `@ecopages/mdx` just to enable React MDX routes.
28
- - The internal service layer is not part of the public API and may change between releases.
package/README.md CHANGED
@@ -60,6 +60,16 @@ const config = await new ConfigBuilder()
60
60
  export default config;
61
61
  ```
62
62
 
63
+ ## Mixed Rendering
64
+
65
+ The React integration can participate in mixed-renderer apps in three ways:
66
+
67
+ - React can own the page or view directly.
68
+ - React can render nested component boundaries inside pages owned by another integration.
69
+ - React can render through non-React page, layout, or document shells when those shell components return strings.
70
+
71
+ When a non-React render pass enters a React-owned boundary, Ecopages hands that boundary back to the React renderer. When React renders through a non-React shell, that shell must serialize to HTML so React can insert the result into the final response without escaping it.
72
+
63
73
  ## Server and Client Graph Contract
64
74
 
65
75
  The React integration supports Node.js modules and server-only code **only on the server execution graph**.
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.13",
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.13",
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.13",
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(() => {
@@ -77,7 +77,7 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
77
77
  *
78
78
  * React pages embedded in a non-React HTML shell still need to expose the same
79
79
  * page-data contract as fully React-owned documents so navigation and hydration
80
- * can read one marker consistently.
80
+ * can read one shared document payload consistently.
81
81
  */
82
82
  private buildRouterPageDataScript;
83
83
  private getRouterDocumentAttributes;
@@ -111,14 +111,6 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
111
111
  * HTML transformer state.
112
112
  */
113
113
  private appendHydrationAssetsForFile;
114
- /**
115
- * Resolves metadata for direct `renderToResponse()` calls.
116
- *
117
- * View rendering bypasses the normal route-file pipeline, so metadata has to be
118
- * evaluated here from either the component-level generator or the application
119
- * default.
120
- */
121
- private resolveViewMetadata;
122
114
  /**
123
115
  * Renders a non-React layout or HTML template and enforces that mixed shells
124
116
  * return serialized HTML.
@@ -128,35 +120,23 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
128
120
  */
129
121
  private renderNonReactShellComponent;
130
122
  /**
131
- * Renders one React component boundary for marker-graph orchestration.
123
+ * Renders one React component boundary while preserving already-resolved child HTML.
132
124
  *
133
- * When the marker resolver has already stitched 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 stitched HTML afterward.
125
+ * When nested boundary resolution has already produced child HTML for this
126
+ * boundary, the child payload must remain raw SSR output rather than a React
127
+ * string child, otherwise React would escape it. This helper renders a unique
128
+ * token through React and swaps that token back to the resolved HTML
129
+ * afterward.
137
130
  *
138
- * @param input Component render input reconstructed from marker metadata.
131
+ * @param input Component render input for the current boundary.
139
132
  * @param context React-specific render context for stable token generation.
140
- * @returns Serialized component HTML with stitched child markup preserved.
133
+ * @returns Serialized component HTML with resolved child markup preserved.
141
134
  */
142
135
  private renderComponentHtml;
136
+ private restoreRuntimeChildHtml;
137
+ private renderQueuedChildrenToHtml;
138
+ private resolveQueuedBoundaryHtml;
143
139
  private buildHydrationProps;
144
- /**
145
- * Produces the page body before the final HTML template is applied.
146
- *
147
- * This method owns the React/non-React layout split. React-managed layouts stay
148
- * as React elements so they can stream normally; non-React layouts are rendered
149
- * to HTML first and then passed through as serialized content.
150
- */
151
- private composePageContent;
152
- /**
153
- * Wraps composed page content in the final document template.
154
- *
155
- * React-owned HTML templates stream directly. Non-React templates receive
156
- * pre-rendered page HTML plus the canonical React page-data payload so the
157
- * client runtime can recover page data after cross-integration handoff.
158
- */
159
- private renderDocument;
160
140
  /**
161
141
  * Renders a React component for component-level orchestration.
162
142
  *
@@ -165,13 +145,17 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
165
145
  * - When an explicit component instance id is provided, a stable
166
146
  * `data-eco-component-id` attribute is attached so island hydration can target it.
167
147
  * - 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
148
+ * - When resolved child HTML is provided, that boundary is treated as a pure SSR
169
149
  * composition step and does not emit hydration assets for the parent wrapper.
170
150
  *
171
151
  * This preserves DOM shape for global CSS/layout selectors while keeping a
172
152
  * deterministic mount target per component instance.
173
153
  */
174
154
  renderComponent(input: ComponentRenderInput): Promise<ComponentRenderResult>;
155
+ protected createComponentBoundaryRuntime(options: {
156
+ boundaryInput: ComponentRenderInput;
157
+ rendererCache: Map<string, IntegrationRenderer<any>>;
158
+ }): import("@ecopages/core").ComponentBoundaryRuntime;
175
159
  /**
176
160
  * Checks if the given file path corresponds to an MDX file based on configured extensions.
177
161
  * @param filePath - The file path to check
@@ -6,29 +6,14 @@ import { rapidhash } from "@ecopages/core/hash";
6
6
  import { AssetFactory } from "@ecopages/core/services/asset-processing-service";
7
7
  import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from "@ecopages/core/router/navigation-coordinator";
8
8
  import path from "node:path";
9
- import { createElement } from "react";
9
+ import { createElement, Fragment } from "react";
10
10
  import { renderToReadableStream, renderToString } from "react-dom/server";
11
11
  import { PLUGIN_NAME } from "./react.plugin.js";
12
12
  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";
17
- function decodeHtmlEntities(value) {
18
- let decoded = value;
19
- let previous;
20
- do {
21
- previous = decoded;
22
- decoded = decoded.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&#x27;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
23
- } while (decoded !== previous);
24
- return decoded;
25
- }
26
- function restoreEscapedComponentMarkers(html) {
27
- return html.replace(
28
- /&(?:amp;)?lt;eco-marker\b[\s\S]*?&(?:amp;)?gt;&(?:amp;)?lt;\/eco-marker&(?:amp;)?gt;/g,
29
- (marker) => decodeHtmlEntities(marker)
30
- );
31
- }
16
+ import { getReactIslandComponentKey, ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
32
17
  class ReactRenderError extends Error {
33
18
  constructor(message) {
34
19
  super(message);
@@ -121,7 +106,7 @@ class ReactRenderer extends IntegrationRenderer {
121
106
  *
122
107
  * React pages embedded in a non-React HTML shell still need to expose the same
123
108
  * page-data contract as fully React-owned documents so navigation and hydration
124
- * can read one marker consistently.
109
+ * can read one shared document payload consistently.
125
110
  */
126
111
  buildRouterPageDataScript(pageProps) {
127
112
  const safeJson = JSON.stringify(pageProps || {}).replace(/</g, "\\u003c");
@@ -180,25 +165,7 @@ class ReactRenderer extends IntegrationRenderer {
180
165
  return;
181
166
  }
182
167
  const hydrationAssets = await this.buildRouteRenderAssets(filePath);
183
- this.htmlTransformer.setProcessedDependencies([
184
- ...this.htmlTransformer.getProcessedDependencies(),
185
- ...hydrationAssets
186
- ]);
187
- }
188
- /**
189
- * Resolves metadata for direct `renderToResponse()` calls.
190
- *
191
- * View rendering bypasses the normal route-file pipeline, so metadata has to be
192
- * evaluated here from either the component-level generator or the application
193
- * default.
194
- */
195
- async resolveViewMetadata(view, props) {
196
- return view.metadata ? await view.metadata({
197
- params: {},
198
- query: {},
199
- props,
200
- appConfig: this.appConfig
201
- }) : this.appConfig.defaultMetadata;
168
+ this.appendProcessedDependencies(hydrationAssets);
202
169
  }
203
170
  /**
204
171
  * Renders a non-React layout or HTML template and enforces that mixed shells
@@ -215,99 +182,77 @@ class ReactRenderer extends IntegrationRenderer {
215
182
  throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
216
183
  }
217
184
  /**
218
- * Renders one React component boundary for marker-graph orchestration.
185
+ * Renders one React component boundary while preserving already-resolved child HTML.
219
186
  *
220
- * When the marker resolver has already stitched child HTML for this boundary,
221
- * the child payload must remain raw SSR output rather than a React string
222
- * 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.
187
+ * When nested boundary resolution has already produced child HTML for this
188
+ * boundary, the child payload must remain raw SSR output rather than a React
189
+ * string child, otherwise React would escape it. This helper renders a unique
190
+ * token through React and swaps that token back to the resolved HTML
191
+ * afterward.
224
192
  *
225
- * @param input Component render input reconstructed from marker metadata.
193
+ * @param input Component render input for the current boundary.
226
194
  * @param context React-specific render context for stable token generation.
227
- * @returns Serialized component HTML with stitched child markup preserved.
195
+ * @returns Serialized component HTML with resolved child markup preserved.
228
196
  */
229
- renderComponentHtml(input, context) {
197
+ renderComponentHtml(input, context, runtimeContext) {
230
198
  if (input.children === void 0) {
231
- return restoreEscapedComponentMarkers(
199
+ return this.normalizeBoundaryArtifactHtml(
232
200
  renderToString(createElement(this.asReactComponent(input.component), input.props))
233
201
  );
234
202
  }
203
+ const resolvedChildHtml = typeof input.children === "string" ? input.children : String(input.children ?? "");
235
204
  const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
205
+ if (runtimeContext) {
206
+ runtimeContext.rawChildrenToken = rawChildrenToken;
207
+ runtimeContext.rawChildrenHtml = resolvedChildHtml;
208
+ }
236
209
  const html = renderToString(
237
210
  createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
238
211
  );
239
- return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(input.children));
212
+ return this.normalizeBoundaryArtifactHtml(html.split(rawChildrenToken).join(resolvedChildHtml));
240
213
  }
241
- buildHydrationProps(props) {
242
- if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
243
- return props ?? {};
214
+ restoreRuntimeChildHtml(html, runtimeContext) {
215
+ if (!runtimeContext?.rawChildrenToken || runtimeContext.rawChildrenHtml === void 0) {
216
+ return html;
244
217
  }
245
- const { locals: _locals, ...hydrationProps } = props;
246
- return hydrationProps;
218
+ return html.split(runtimeContext.rawChildrenToken).join(runtimeContext.rawChildrenHtml);
247
219
  }
248
- /**
249
- * Produces the page body before the final HTML template is applied.
250
- *
251
- * This method owns the React/non-React layout split. React-managed layouts stay
252
- * as React elements so they can stream normally; non-React layouts are rendered
253
- * to HTML first and then passed through as serialized content.
254
- */
255
- async composePageContent(options) {
256
- const pageElement = createElement(options.Page, options.pageProps);
257
- const pageHtml = restoreEscapedComponentMarkers(renderToString(pageElement));
258
- const layoutProps = options.locals ? { locals: options.locals } : {};
259
- if (!options.Layout) {
260
- return { contentNode: pageElement, contentHtml: pageHtml };
261
- }
262
- if (this.isReactManagedComponent(options.Layout)) {
263
- const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
264
- return {
265
- contentNode: layoutElement,
266
- contentHtml: restoreEscapedComponentMarkers(renderToString(layoutElement))
267
- };
220
+ async renderQueuedChildrenToHtml(children, runtimeContext, queuedResolutionsByToken, resolveToken) {
221
+ if (children === void 0) {
222
+ return void 0;
268
223
  }
269
- const layoutHtml = await this.renderNonReactShellComponent(
270
- this.asNonReactShellComponent(options.Layout),
271
- { ...layoutProps, children: pageHtml },
272
- "Layout"
224
+ let html = this.normalizeBoundaryArtifactHtml(
225
+ renderToString(createElement(Fragment, null, children))
273
226
  );
274
- return { contentNode: layoutHtml, contentHtml: layoutHtml };
227
+ html = this.restoreRuntimeChildHtml(html, runtimeContext);
228
+ html = await this.resolveQueuedBoundaryTokens(html, queuedResolutionsByToken, resolveToken);
229
+ return html;
275
230
  }
276
- /**
277
- * Wraps composed page content in the final document template.
278
- *
279
- * React-owned HTML templates stream directly. Non-React templates receive
280
- * pre-rendered page HTML plus the canonical React page-data payload so the
281
- * client runtime can recover page data after cross-integration handoff.
282
- */
283
- async renderDocument(options) {
284
- if (this.isReactManagedComponent(options.HtmlTemplate)) {
285
- const rawChildrenToken = "__ECO_RAW_HTML_DOCUMENT_CHILD__";
286
- const html = restoreEscapedComponentMarkers(
287
- renderToString(
288
- createElement(
289
- this.asReactComponent(options.HtmlTemplate),
290
- {
291
- metadata: options.metadata,
292
- pageProps: options.pageProps
293
- },
294
- rawChildrenToken
295
- )
296
- )
297
- );
298
- return html.split(rawChildrenToken).join(options.contentHtml);
231
+ async resolveQueuedBoundaryHtml(html, runtimeContext) {
232
+ return this.resolveRendererOwnedQueuedBoundaryHtml({
233
+ html,
234
+ runtimeContext,
235
+ queueLabel: "React",
236
+ renderQueuedChildren: async (children, currentRuntimeContext, queuedResolutionsByToken, resolveToken) => {
237
+ const renderedHtml = await this.renderQueuedChildrenToHtml(
238
+ children,
239
+ currentRuntimeContext,
240
+ queuedResolutionsByToken,
241
+ resolveToken
242
+ );
243
+ return {
244
+ assets: [],
245
+ html: renderedHtml
246
+ };
247
+ }
248
+ });
249
+ }
250
+ buildHydrationProps(props) {
251
+ if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
252
+ return props ?? {};
299
253
  }
300
- const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
301
- return this.renderNonReactShellComponent(
302
- this.asNonReactShellComponent(options.HtmlTemplate),
303
- {
304
- metadata: options.metadata,
305
- pageProps: options.pageProps,
306
- children: options.contentHtml,
307
- headContent
308
- },
309
- "HtmlTemplate"
310
- );
254
+ const { locals: _locals, ...hydrationProps } = props;
255
+ return hydrationProps;
311
256
  }
312
257
  /**
313
258
  * Renders a React component for component-level orchestration.
@@ -317,17 +262,49 @@ class ReactRenderer extends IntegrationRenderer {
317
262
  * - When an explicit component instance id is provided, a stable
318
263
  * `data-eco-component-id` attribute is attached so island hydration can target it.
319
264
  * - 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
265
+ * - When resolved child HTML is provided, that boundary is treated as a pure SSR
321
266
  * composition step and does not emit hydration assets for the parent wrapper.
322
267
  *
323
268
  * This preserves DOM shape for global CSS/layout selectors while keeping a
324
269
  * deterministic mount target per component instance.
325
270
  */
326
271
  async renderComponent(input) {
272
+ const runtimeContext = this.getQueuedBoundaryRuntime(input);
273
+ if (!this.isReactManagedComponent(input.component)) {
274
+ let props = input.props;
275
+ if (input.children !== void 0) {
276
+ props = {
277
+ ...input.props,
278
+ children: typeof input.children === "string" ? input.children : String(input.children ?? "")
279
+ };
280
+ }
281
+ const html2 = await this.renderNonReactShellComponent(
282
+ this.asNonReactShellComponent(input.component),
283
+ props,
284
+ "Component"
285
+ );
286
+ const hasDependencies = Boolean(input.component.config?.dependencies);
287
+ const canResolveAssets = typeof this.assetProcessingService?.processDependencies === "function";
288
+ const assets2 = hasDependencies && canResolveAssets ? await this.processComponentDependencies([input.component]) : void 0;
289
+ const queuedBoundaryResolution2 = await this.resolveQueuedBoundaryHtml(html2, runtimeContext);
290
+ const mergedAssets2 = this.htmlTransformer.dedupeProcessedAssets([
291
+ ...assets2 ?? [],
292
+ ...queuedBoundaryResolution2.assets
293
+ ]);
294
+ return {
295
+ html: queuedBoundaryResolution2.html,
296
+ canAttachAttributes: true,
297
+ rootTag: this.getRootTagName(queuedBoundaryResolution2.html),
298
+ integrationName: this.name,
299
+ assets: mergedAssets2.length > 0 ? mergedAssets2 : void 0
300
+ };
301
+ }
327
302
  const componentConfig = input.component.config;
328
303
  const context = input.integrationContext ?? {};
329
304
  const hasResolvedChildHtml = input.children !== void 0;
330
- let html = this.renderComponentHtml(input, context);
305
+ let html = this.renderComponentHtml(input, context, runtimeContext);
306
+ const queuedBoundaryResolution = await this.resolveQueuedBoundaryHtml(html, runtimeContext);
307
+ html = queuedBoundaryResolution.html;
331
308
  let canAttachAttributes = hasSingleRootElement(html);
332
309
  let rootTag = this.getRootTagName(html);
333
310
  const componentFile = componentConfig?.__eco?.file;
@@ -335,26 +312,40 @@ class ReactRenderer extends IntegrationRenderer {
335
312
  let assets;
336
313
  if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
337
314
  const componentInstanceId = context.componentInstanceId;
338
- assets = await this.hydrationAssetService.buildComponentRenderAssets(
339
- componentFile,
340
- componentInstanceId,
341
- this.buildHydrationProps(input.props),
342
- componentConfig
343
- );
315
+ assets = await this.hydrationAssetService.buildComponentRenderAssets(componentFile, componentConfig);
344
316
  rootAttributes = {
345
317
  "data-eco-component-id": componentInstanceId,
318
+ "data-eco-component-key": getReactIslandComponentKey(componentFile, componentConfig),
346
319
  "data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
347
320
  };
348
321
  }
322
+ const mergedAssets = this.htmlTransformer.dedupeProcessedAssets([
323
+ ...assets ?? [],
324
+ ...queuedBoundaryResolution.assets
325
+ ]);
349
326
  return {
350
327
  html,
351
328
  canAttachAttributes,
352
329
  rootTag,
353
330
  integrationName: this.name,
354
331
  rootAttributes,
355
- assets
332
+ assets: mergedAssets.length > 0 ? mergedAssets : void 0
356
333
  };
357
334
  }
335
+ createComponentBoundaryRuntime(options) {
336
+ return this.createQueuedBoundaryRuntime({
337
+ boundaryInput: options.boundaryInput,
338
+ rendererCache: options.rendererCache,
339
+ createRuntimeContext: (integrationContext, rendererCache) => ({
340
+ rendererCache,
341
+ componentInstanceScope: integrationContext.componentInstanceId,
342
+ nextBoundaryId: 0,
343
+ queuedResolutions: [],
344
+ rawChildrenToken: void 0,
345
+ rawChildrenHtml: void 0
346
+ })
347
+ });
348
+ }
358
349
  /**
359
350
  * Checks if the given file path corresponds to an MDX file based on configured extensions.
360
351
  * @param filePath - The file path to check
@@ -540,18 +531,19 @@ class ReactRenderer extends IntegrationRenderer {
540
531
  query,
541
532
  safeLocals
542
533
  });
543
- const { contentNode, contentHtml } = await this.composePageContent({
544
- Page: this.asReactComponent(Page),
545
- Layout,
546
- pageProps: { params, query, ...props, locals: pageLocals },
547
- locals
548
- });
549
- return await this.renderDocument({
550
- HtmlTemplate,
534
+ return await this.renderPageWithDocumentShell({
535
+ page: {
536
+ component: Page,
537
+ props: { params, query, ...props, locals: pageLocals }
538
+ },
539
+ layout: Layout ? {
540
+ component: Layout,
541
+ props: locals ? { locals } : {}
542
+ } : void 0,
543
+ htmlTemplate: HtmlTemplate,
551
544
  metadata,
552
545
  pageProps: allPageProps,
553
- contentNode,
554
- contentHtml
546
+ documentProps: !this.isReactManagedComponent(HtmlTemplate) && ReactRenderer.routerAdapter ? { headContent: this.buildRouterPageDataScript(allPageProps) } : void 0
555
547
  });
556
548
  } catch (error) {
557
549
  throw this.createRenderError("Failed to render component", error);
@@ -614,38 +606,41 @@ class ReactRenderer extends IntegrationRenderer {
614
606
  const ViewComponent = this.asReactComponent(view);
615
607
  const normalizedProps = props ?? {};
616
608
  if (ctx.partial) {
617
- const stream = await renderToReadableStream(createElement(ViewComponent, normalizedProps));
618
- return this.createHtmlResponse(stream, ctx);
609
+ return this.renderPartialViewResponse({
610
+ view,
611
+ props,
612
+ ctx,
613
+ renderInline: async () => await renderToReadableStream(createElement(ViewComponent, normalizedProps))
614
+ });
619
615
  }
620
616
  const HtmlTemplate = await this.getHtmlTemplate();
621
617
  const metadata = await this.resolveViewMetadata(view, props);
622
618
  await this.prepareViewDependencies(view, Layout);
623
619
  await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
624
- const { contentNode, contentHtml } = await this.composePageContent({
625
- Page: ViewComponent,
626
- Layout,
627
- pageProps: normalizedProps
620
+ const viewRender = await this.renderComponentBoundary({
621
+ component: view,
622
+ props: normalizedProps
628
623
  });
629
- const body = await this.renderDocument({
630
- HtmlTemplate,
631
- metadata,
632
- pageProps: normalizedProps,
633
- contentNode,
634
- contentHtml
624
+ const layoutRender = Layout ? await this.renderComponentBoundary({
625
+ component: Layout,
626
+ props: {},
627
+ children: viewRender.html
628
+ }) : void 0;
629
+ const documentRender = await this.renderComponentBoundary({
630
+ component: HtmlTemplate,
631
+ props: {
632
+ metadata,
633
+ pageProps: normalizedProps,
634
+ ...!this.isReactManagedComponent(HtmlTemplate) && ReactRenderer.routerAdapter ? { headContent: this.buildRouterPageDataScript(normalizedProps) } : {}
635
+ },
636
+ children: layoutRender?.html ?? viewRender.html
637
+ });
638
+ this.appendProcessedDependencies(viewRender.assets, layoutRender?.assets, documentRender.assets);
639
+ const transformedHtml = await this.finalizeResolvedHtml({
640
+ html: `${this.DOC_TYPE}${documentRender.html}`,
641
+ partial: false,
642
+ documentAttributes: this.getRouterDocumentAttributes()
635
643
  });
636
- const transformedResponse = await this.htmlTransformer.transform(
637
- new Response(body, {
638
- headers: { "Content-Type": "text/html" }
639
- })
640
- );
641
- let transformedHtml = await transformedResponse.text();
642
- const documentAttributes = this.getRouterDocumentAttributes();
643
- if (documentAttributes) {
644
- transformedHtml = this.htmlTransformer.applyAttributesToHtmlElement(
645
- transformedHtml,
646
- documentAttributes
647
- );
648
- }
649
644
  return this.createHtmlResponse(transformedHtml, ctx);
650
645
  } catch (error) {
651
646
  throw this.createRenderError("Failed to render view", error);
@@ -10,7 +10,6 @@ import type { CompileOptions } from '@mdx-js/mdx';
10
10
  import type React from 'react';
11
11
  import { ReactRenderer } from './react-renderer.js';
12
12
  import type { ReactRouterAdapter } from './router-adapter.js';
13
- import type { ComponentBoundaryPolicyInput } from '@ecopages/core/plugins/integration-plugin';
14
13
  /**
15
14
  * MDX configuration options for the React plugin
16
15
  */
@@ -139,17 +138,6 @@ export declare class ReactPlugin extends IntegrationPlugin<React.JSX.Element> {
139
138
  */
140
139
  getHmrStrategy(): HmrStrategy | undefined;
141
140
  getRuntimeSpecifierMap(): Record<string, string>;
142
- /**
143
- * Declares React's boundary deferral rule for cross-integration rendering.
144
- *
145
- * React defers when a render pass owned by another integration enters a React
146
- * component boundary. That boundary is then resolved later through the marker
147
- * graph stage using the React renderer.
148
- *
149
- * @param input Boundary metadata for the active render pass.
150
- * @returns `true` when the boundary should be deferred into the marker pass.
151
- */
152
- shouldDeferComponentBoundary(input: ComponentBoundaryPolicyInput): boolean;
153
141
  }
154
142
  /**
155
143
  * Factory function to create a React plugin instance
@@ -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
  }
@@ -134,19 +135,6 @@ class ReactPlugin extends IntegrationPlugin {
134
135
  getRuntimeSpecifierMap() {
135
136
  return this.runtimeBundleService.getSpecifierMap();
136
137
  }
137
- /**
138
- * Declares React's boundary deferral rule for cross-integration rendering.
139
- *
140
- * React defers when a render pass owned by another integration enters a React
141
- * component boundary. That boundary is then resolved later through the marker
142
- * graph stage using the React renderer.
143
- *
144
- * @param input Boundary metadata for the active render pass.
145
- * @returns `true` when the boundary should be deferred into the marker pass.
146
- */
147
- shouldDeferComponentBoundary(input) {
148
- return input.targetIntegration === this.name && input.currentIntegration !== this.name;
149
- }
150
138
  }
151
139
  function reactPlugin(options) {
152
140
  return new ReactPlugin(options);
@@ -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
- };