@ecopages/react 0.2.0-alpha.9 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +30 -13
  2. package/package.json +23 -12
  3. package/src/eco-embed.d.ts +11 -0
  4. package/src/eco-embed.js +11 -0
  5. package/src/react-hmr-strategy.d.ts +102 -18
  6. package/src/react-hmr-strategy.js +427 -50
  7. package/src/react-renderer.d.ts +100 -92
  8. package/src/react-renderer.js +356 -340
  9. package/src/react.constants.d.ts +1 -0
  10. package/src/react.constants.js +4 -0
  11. package/src/react.plugin.d.ts +25 -107
  12. package/src/react.plugin.js +109 -61
  13. package/src/react.types.d.ts +88 -0
  14. package/src/react.types.js +0 -0
  15. package/src/router-adapter.d.ts +7 -14
  16. package/src/runtime/use-sync-external-store-with-selector.d.ts +3 -0
  17. package/src/runtime/use-sync-external-store-with-selector.js +56 -0
  18. package/src/services/pages-index.d.ts +64 -0
  19. package/src/services/pages-index.js +73 -0
  20. package/src/services/react-bundle.service.d.ts +24 -9
  21. package/src/services/react-bundle.service.js +35 -24
  22. package/src/services/react-hmr-page-metadata-cache.d.ts +10 -1
  23. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  24. package/src/services/react-hydration-asset.service.d.ts +28 -19
  25. package/src/services/react-hydration-asset.service.js +83 -64
  26. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  27. package/src/services/react-mdx-config-dependency.service.js +122 -0
  28. package/src/services/react-page-module.service.d.ts +8 -3
  29. package/src/services/react-page-module.service.js +33 -26
  30. package/src/services/react-page-payload.service.d.ts +46 -0
  31. package/src/services/react-page-payload.service.js +67 -0
  32. package/src/services/react-runtime-bundle.service.d.ts +9 -2
  33. package/src/services/react-runtime-bundle.service.js +77 -16
  34. package/src/utils/client-graph-boundary-cache.d.ts +108 -0
  35. package/src/utils/client-graph-boundary-cache.js +116 -0
  36. package/src/utils/client-graph-boundary-plugin.d.ts +13 -5
  37. package/src/utils/client-graph-boundary-plugin.js +63 -5
  38. package/src/utils/component-config-traversal.d.ts +36 -0
  39. package/src/utils/component-config-traversal.js +54 -0
  40. package/src/utils/declared-modules.d.ts +1 -1
  41. package/src/utils/declared-modules.js +7 -16
  42. package/src/utils/dynamic.test.browser.d.ts +1 -0
  43. package/src/utils/dynamic.test.browser.js +33 -0
  44. package/src/utils/hydration-scripts.d.ts +9 -5
  45. package/src/utils/hydration-scripts.js +119 -34
  46. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  47. package/src/utils/hydration-scripts.test.browser.js +198 -0
  48. package/src/utils/react-dom-runtime-interop-plugin.d.ts +1 -1
  49. package/src/utils/react-dom-runtime-interop-plugin.js +9 -0
  50. package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
  51. package/src/utils/{react-runtime-specifier-map.d.ts → react-runtime-alias-map.d.ts} +3 -1
  52. package/src/utils/react-runtime-alias-map.js +90 -0
  53. package/CHANGELOG.md +0 -27
  54. package/src/react-hmr-strategy.ts +0 -386
  55. package/src/react-renderer.ts +0 -803
  56. package/src/react.plugin.ts +0 -276
  57. package/src/router-adapter.ts +0 -95
  58. package/src/services/react-bundle.service.ts +0 -108
  59. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  60. package/src/services/react-hydration-asset.service.ts +0 -263
  61. package/src/services/react-page-module.service.ts +0 -224
  62. package/src/services/react-runtime-bundle.service.ts +0 -172
  63. package/src/utils/client-graph-boundary-plugin.ts +0 -831
  64. package/src/utils/client-only.ts +0 -27
  65. package/src/utils/declared-modules.ts +0 -99
  66. package/src/utils/dynamic.ts +0 -27
  67. package/src/utils/hmr-scripts.ts +0 -47
  68. package/src/utils/html-boundary.ts +0 -66
  69. package/src/utils/hydration-scripts.ts +0 -459
  70. package/src/utils/reachability-analyzer.ts +0 -593
  71. package/src/utils/react-dom-runtime-interop-plugin.ts +0 -33
  72. package/src/utils/react-mdx-loader-plugin.ts +0 -63
  73. package/src/utils/react-runtime-specifier-map.js +0 -37
  74. package/src/utils/react-runtime-specifier-map.ts +0 -45
  75. package/src/utils/use-sync-external-store-shim-plugin.d.ts +0 -5
  76. package/src/utils/use-sync-external-store-shim-plugin.js +0 -41
  77. package/src/utils/use-sync-external-store-shim-plugin.ts +0 -45
@@ -1,19 +1,19 @@
1
- import { IntegrationRenderer } from "@ecopages/core/route-renderer/integration-renderer";
2
- import { LocalsAccessError } from "@ecopages/core/errors/locals-access-error";
1
+ import {
2
+ IntegrationRenderer
3
+ } from "@ecopages/core/route-renderer/integration-renderer";
3
4
  import { RESOLVED_ASSETS_DIR } from "@ecopages/core/constants";
4
5
  import { getAppBuildExecutor } from "@ecopages/core/build/build-adapter";
5
- import { rapidhash } from "@ecopages/core/hash";
6
- import { AssetFactory } from "@ecopages/core/services/asset-processing-service";
7
6
  import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from "@ecopages/core/router/navigation-coordinator";
7
+ import { createRequire } from "node:module";
8
8
  import path from "node:path";
9
- import { createElement } from "react";
10
- import { renderToReadableStream, renderToString } from "react-dom/server";
11
- import { PLUGIN_NAME } from "./react.plugin.js";
9
+ import { REACT_PLUGIN_NAME } from "./react.constants.js";
12
10
  import { hasSingleRootElement } from "./utils/html-boundary.js";
13
11
  import { ReactBundleService } from "./services/react-bundle.service.js";
14
12
  import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
13
+ import { ReactMdxConfigDependencyService } from "./services/react-mdx-config-dependency.service.js";
15
14
  import { ReactPageModuleService } from "./services/react-page-module.service.js";
16
- import { ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
15
+ import { ReactPagePayloadService } from "./services/react-page-payload.service.js";
16
+ import { getReactIslandComponentKey, ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
17
17
  class ReactRenderError extends Error {
18
18
  constructor(message) {
19
19
  super(message);
@@ -29,31 +29,44 @@ class BundleError extends Error {
29
29
  }
30
30
  }
31
31
  class ReactRenderer extends IntegrationRenderer {
32
- name = PLUGIN_NAME;
32
+ name = REACT_PLUGIN_NAME;
33
33
  componentDirectory = RESOLVED_ASSETS_DIR;
34
- static routerAdapter;
35
- static mdxCompilerOptions;
36
- static mdxExtensions = [".mdx"];
37
- static hmrPageMetadataCache;
34
+ reactRuntimeModules;
35
+ routerAdapter;
36
+ mdxCompilerOptions;
37
+ mdxExtensions;
38
+ hmrPageMetadataCache;
38
39
  /**
39
40
  * Enables explicit graph behavior for React page-entry bundling.
40
41
  *
41
42
  * When true, page-entry bundles disable AST server-only stripping and rely
42
43
  * on explicit dependency declarations for browser graph composition.
43
44
  */
44
- static explicitGraphEnabled = false;
45
+ explicitGraphEnabled;
45
46
  /** @internal */
46
47
  bundleService;
47
48
  /** @internal */
48
49
  pageModuleService;
49
50
  /** @internal */
50
51
  hydrationAssetService;
52
+ /** @internal */
53
+ pagePayloadService;
54
+ /** @internal */
55
+ mdxConfigDependencyService;
51
56
  constructor(options) {
52
- super(options);
57
+ const { reactConfig, ...rendererOptions } = options;
58
+ super(rendererOptions);
59
+ this.routerAdapter = reactConfig?.routerAdapter;
60
+ this.mdxCompilerOptions = reactConfig?.mdxCompilerOptions;
61
+ this.mdxExtensions = reactConfig?.mdxExtensions ?? [".mdx"];
62
+ this.hmrPageMetadataCache = reactConfig?.hmrPageMetadataCache;
63
+ this.explicitGraphEnabled = reactConfig?.explicitGraphEnabled ?? false;
53
64
  this.bundleService = new ReactBundleService({
54
65
  rootDir: this.appConfig.rootDir,
55
- routerAdapter: ReactRenderer.routerAdapter,
56
- mdxCompilerOptions: ReactRenderer.mdxCompilerOptions
66
+ routerAdapter: this.routerAdapter,
67
+ mdxCompilerOptions: this.mdxCompilerOptions,
68
+ jsxImportSource: (this.appConfig.integrations ?? []).find((integration) => integration.name === this.name)?.jsxImportSource,
69
+ nonReactExtensions: (this.appConfig.integrations ?? []).filter((integration) => integration.name !== this.name).flatMap((integration) => integration.extensions)
57
70
  });
58
71
  this.pageModuleService = new ReactPageModuleService({
59
72
  rootDir: this.appConfig.rootDir,
@@ -62,17 +75,23 @@ class ReactRenderer extends IntegrationRenderer {
62
75
  buildExecutor: getAppBuildExecutor(this.appConfig),
63
76
  layoutsDir: this.appConfig.absolutePaths.layoutsDir,
64
77
  componentsDir: this.appConfig.absolutePaths.componentsDir,
65
- mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
66
- mdxExtensions: ReactRenderer.mdxExtensions,
78
+ mdxCompilerOptions: this.mdxCompilerOptions,
79
+ mdxExtensions: this.mdxExtensions,
67
80
  integrationName: this.name,
68
- hasRouterAdapter: Boolean(ReactRenderer.routerAdapter)
81
+ hasRouterAdapter: Boolean(this.routerAdapter)
69
82
  });
70
83
  this.hydrationAssetService = new ReactHydrationAssetService({
71
84
  srcDir: this.appConfig.srcDir,
72
- routerAdapter: ReactRenderer.routerAdapter,
85
+ routerAdapter: this.routerAdapter,
73
86
  assetProcessingService: this.assetProcessingService,
74
87
  bundleService: this.bundleService,
75
- hmrPageMetadataCache: ReactRenderer.hmrPageMetadataCache
88
+ hmrPageMetadataCache: this.hmrPageMetadataCache
89
+ });
90
+ this.pagePayloadService = new ReactPagePayloadService();
91
+ this.mdxConfigDependencyService = new ReactMdxConfigDependencyService({
92
+ integrationName: this.name,
93
+ pageModuleService: this.pageModuleService,
94
+ assetProcessingService: this.assetProcessingService
76
95
  });
77
96
  }
78
97
  shouldRenderPageComponent() {
@@ -99,19 +118,11 @@ class ReactRenderer extends IntegrationRenderer {
99
118
  const integration = this.getComponentIntegration(component);
100
119
  return integration === void 0 || integration === this.name;
101
120
  }
102
- /**
103
- * Creates the canonical page-props payload used by router hydration.
104
- *
105
- * React pages embedded in a non-React HTML shell still need to expose the same
106
- * page-data contract as fully React-owned documents so navigation and hydration
107
- * can read one marker consistently.
108
- */
109
- buildRouterPageDataScript(pageProps) {
110
- const safeJson = JSON.stringify(pageProps || {}).replace(/</g, "\\u003c");
111
- return `<script id="__ECO_PAGE_DATA__" type="application/json">${safeJson}<\/script>`;
121
+ getComponentRequires(component) {
122
+ return component?.requires;
112
123
  }
113
124
  getRouterDocumentAttributes() {
114
- if (!ReactRenderer.routerAdapter) {
125
+ if (!this.routerAdapter) {
115
126
  return void 0;
116
127
  }
117
128
  return {
@@ -139,20 +150,25 @@ class ReactRenderer extends IntegrationRenderer {
139
150
  asNonReactShellComponent(component) {
140
151
  return component;
141
152
  }
142
- /**
143
- * Builds the serialized page-props payload embedded into the final HTML.
144
- *
145
- * The document payload is intentionally narrower than the full server render
146
- * input: only routing data, public page props, and explicitly allowed locals are
147
- * exposed to the browser.
148
- */
149
- buildSerializedPageProps(options) {
150
- return {
151
- ...options.pageProps,
152
- params: options.params,
153
- query: options.query,
154
- ...options.safeLocals && { locals: options.safeLocals }
155
- };
153
+ resolveReactRuntimeModules() {
154
+ const appPackageJsonPath = path.resolve(this.appConfig.rootDir || process.cwd(), "package.json");
155
+ try {
156
+ const requireFromApp = createRequire(appPackageJsonPath);
157
+ return {
158
+ react: requireFromApp("react"),
159
+ reactDomServer: requireFromApp("react-dom/server")
160
+ };
161
+ } catch {
162
+ const requireFromIntegration = createRequire(import.meta.url);
163
+ return {
164
+ react: requireFromIntegration("react"),
165
+ reactDomServer: requireFromIntegration("react-dom/server")
166
+ };
167
+ }
168
+ }
169
+ getReactRuntimeModules() {
170
+ this.reactRuntimeModules ??= this.resolveReactRuntimeModules();
171
+ return this.reactRuntimeModules;
156
172
  }
157
173
  /**
158
174
  * Appends route hydration assets for a concrete page/view file to the current
@@ -162,26 +178,8 @@ class ReactRenderer extends IntegrationRenderer {
162
178
  if (!filePath) {
163
179
  return;
164
180
  }
165
- const hydrationAssets = await this.buildRouteRenderAssets(filePath);
166
- this.htmlTransformer.setProcessedDependencies([
167
- ...this.htmlTransformer.getProcessedDependencies(),
168
- ...hydrationAssets
169
- ]);
170
- }
171
- /**
172
- * Resolves metadata for direct `renderToResponse()` calls.
173
- *
174
- * View rendering bypasses the normal route-file pipeline, so metadata has to be
175
- * evaluated here from either the component-level generator or the application
176
- * default.
177
- */
178
- async resolveViewMetadata(view, props) {
179
- return view.metadata ? await view.metadata({
180
- params: {},
181
- query: {},
182
- props,
183
- appConfig: this.appConfig
184
- }) : this.appConfig.defaultMetadata;
181
+ const pageBrowserGraph = await this.resolvePageBrowserGraphForFile(filePath);
182
+ this.mergePageBrowserGraphIntoPagePackage(pageBrowserGraph);
185
183
  }
186
184
  /**
187
185
  * Renders a non-React layout or HTML template and enforces that mixed shells
@@ -198,110 +196,242 @@ class ReactRenderer extends IntegrationRenderer {
198
196
  throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
199
197
  }
200
198
  /**
201
- * Produces the page body before the final HTML template is applied.
199
+ * Renders one React component while preserving already-resolved child HTML.
200
+ *
201
+ * When nested foreign-subtree resolution has already produced child HTML for this
202
+ * component, the child payload must remain raw SSR output rather than a React
203
+ * string child, otherwise React would escape it. This helper renders a unique
204
+ * token through React and swaps that token back to the resolved HTML
205
+ * afterward.
202
206
  *
203
- * This method owns the React/non-React layout split. React-managed layouts stay
204
- * as React elements so they can stream normally; non-React layouts are rendered
205
- * to HTML first and then passed through as serialized content.
207
+ * @param input Component render input for the current render step.
208
+ * @param context React-specific render context for stable token generation.
209
+ * @returns Serialized component HTML with resolved child markup preserved.
206
210
  */
207
- async composePageContent(options) {
208
- const pageElement = createElement(options.Page, options.pageProps);
209
- const pageHtml = renderToString(pageElement);
210
- const layoutProps = options.locals ? { locals: options.locals } : {};
211
- if (!options.Layout) {
212
- return { contentNode: pageElement, contentHtml: pageHtml };
211
+ renderComponentHtml(input, context, runtimeContext) {
212
+ const { react, reactDomServer } = this.getReactRuntimeModules();
213
+ if (input.children === void 0) {
214
+ return this.normalizeUnresolvedMarkerArtifactHtml(
215
+ reactDomServer.renderToString(react.createElement(this.asReactComponent(input.component), input.props))
216
+ );
213
217
  }
214
- if (this.isReactManagedComponent(options.Layout)) {
215
- const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
216
- return {
217
- contentNode: layoutElement,
218
- contentHtml: renderToString(layoutElement)
219
- };
218
+ const resolvedChildHtml = typeof input.children === "string" ? input.children : String(input.children ?? "");
219
+ const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
220
+ if (runtimeContext) {
221
+ runtimeContext.rawChildrenToken = rawChildrenToken;
222
+ runtimeContext.rawChildrenHtml = resolvedChildHtml;
220
223
  }
221
- const layoutHtml = await this.renderNonReactShellComponent(
222
- this.asNonReactShellComponent(options.Layout),
223
- { ...layoutProps, children: pageHtml },
224
- "Layout"
224
+ const html = reactDomServer.renderToString(
225
+ react.createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
225
226
  );
226
- return { contentNode: layoutHtml, contentHtml: layoutHtml };
227
+ return this.normalizeUnresolvedMarkerArtifactHtml(html.split(rawChildrenToken).join(resolvedChildHtml));
227
228
  }
228
229
  /**
229
- * Wraps composed page content in the final document template.
230
+ * Restores raw child HTML that was temporarily replaced by a token during React SSR.
230
231
  *
231
- * React-owned HTML templates stream directly. Non-React templates receive
232
- * pre-rendered page HTML plus the canonical React page-data payload so the
233
- * client runtime can recover page data after cross-integration handoff.
232
+ * Queued foreign-subtree resolution may render children through a fragment path before all
233
+ * nested integration tokens are resolved. When that happens, React must never see
234
+ * the resolved child HTML as a normal string child or it would escape it. The
235
+ * runtime context stores the placeholder token and the raw child HTML so the
236
+ * fragment render path can reinsert it before foreign-subtree tokens are handled.
234
237
  */
235
- async renderDocument(options) {
236
- 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
245
- )
246
- );
238
+ restoreRuntimeChildHtml(html, runtimeContext) {
239
+ if (!runtimeContext?.rawChildrenToken || runtimeContext.rawChildrenHtml === void 0) {
240
+ return html;
247
241
  }
248
- const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
249
- return this.renderNonReactShellComponent(
250
- this.asNonReactShellComponent(options.HtmlTemplate),
251
- {
252
- metadata: options.metadata,
253
- pageProps: options.pageProps,
254
- children: options.contentHtml,
255
- headContent
256
- },
257
- "HtmlTemplate"
242
+ return html.split(runtimeContext.rawChildrenToken).join(runtimeContext.rawChildrenHtml);
243
+ }
244
+ /**
245
+ * Renders queued child content through React and then resolves nested foreign-subtree tokens.
246
+ *
247
+ * This path is only used for children that were deferred while React rendered the
248
+ * parent component. It first restores any raw child HTML placeholders owned by the
249
+ * current runtime context, then asks the shared queued foreign-subtree resolver to swap
250
+ * foreign integration tokens with their resolved HTML.
251
+ */
252
+ async renderQueuedChildrenToHtml(children, runtimeContext, queuedResolutionsByToken, resolveToken) {
253
+ if (children === void 0) {
254
+ return void 0;
255
+ }
256
+ const { react, reactDomServer } = this.getReactRuntimeModules();
257
+ let html = this.normalizeUnresolvedMarkerArtifactHtml(
258
+ reactDomServer.renderToString(react.createElement(react.Fragment, null, children))
259
+ );
260
+ html = this.restoreRuntimeChildHtml(html, runtimeContext);
261
+ html = await this.foreignSubtreeExecutionService.resolveQueuedTokens(
262
+ html,
263
+ queuedResolutionsByToken,
264
+ resolveToken
258
265
  );
266
+ return html;
259
267
  }
260
268
  /**
261
- * Renders a React component for component-level orchestration.
269
+ * Resolves queued renderer-owned foreign-subtree tokens produced during React component rendering.
262
270
  *
263
- * Behavior:
264
- * - SSR always returns the component's own root HTML (no synthetic wrapper).
265
- * - When an explicit component instance id is provided, a stable
266
- * `data-eco-component-id` attribute is attached so island hydration can target it.
267
- * - Without an explicit instance id, component renders remain plain SSR output.
271
+ * React components can enqueue nested foreign subtrees while the parent HTML is being
272
+ * rendered. This delegates to the shared renderer-owned queue resolver but keeps
273
+ * the React-specific child rendering behavior local so raw child HTML and React's
274
+ * fragment rendering semantics stay coordinated.
275
+ */
276
+ async resolveQueuedForeignSubtreeHtml(html, runtimeContext) {
277
+ return this.foreignSubtreeExecutionService.resolveQueuedHtml({
278
+ currentIntegrationName: this.name,
279
+ html,
280
+ runtimeContext,
281
+ queueLabel: "React",
282
+ getOwningRenderer: (integrationName, rendererCache) => this.getIntegrationRendererForName(integrationName, rendererCache),
283
+ applyAttributesToFirstElement: (resolvedHtml, attributes) => this.htmlTransformer.applyAttributesToFirstElement(resolvedHtml, attributes),
284
+ dedupeProcessedAssets: (assets) => this.htmlTransformer.dedupeProcessedAssets(assets),
285
+ renderQueuedChildren: async (children, currentRuntimeContext, queuedResolutionsByToken, resolveToken) => {
286
+ const renderedHtml = await this.renderQueuedChildrenToHtml(
287
+ children,
288
+ currentRuntimeContext,
289
+ queuedResolutionsByToken,
290
+ resolveToken
291
+ );
292
+ return {
293
+ assets: [],
294
+ html: renderedHtml
295
+ };
296
+ }
297
+ });
298
+ }
299
+ buildHydrationProps(props) {
300
+ if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
301
+ return props ?? {};
302
+ }
303
+ const { locals: _locals, ...hydrationProps } = props;
304
+ return hydrationProps;
305
+ }
306
+ /**
307
+ * Builds shared document html contributions for router-backed React pages rendered
308
+ * through a non-React HTML shell.
309
+ */
310
+ buildNonReactDocumentContributions(htmlTemplate, pageProps) {
311
+ if (this.isReactManagedComponent(htmlTemplate) || !this.routerAdapter) {
312
+ return void 0;
313
+ }
314
+ return [
315
+ {
316
+ placement: "head-append",
317
+ html: this.pagePayloadService.buildRouterPageDataScript(pageProps)
318
+ }
319
+ ];
320
+ }
321
+ /**
322
+ * Renders a foreign integration component that participates in React composition.
268
323
  *
269
- * This preserves DOM shape for global CSS/layout selectors while keeping a
270
- * deterministic mount target per component instance.
324
+ * Non-React components must resolve to serialized HTML so React can embed them as
325
+ * mixed-shell children. Any component-owned dependencies still need to flow
326
+ * through the shared dependency resolver before queued foreign-subtree tokens are finalized.
271
327
  */
272
- async renderComponent(input) {
273
- const Component = this.asReactComponent(input.component);
328
+ async renderForeignComponentWithSerializedHtml(input, runtimeContext) {
329
+ let props = input.props;
330
+ if (input.children !== void 0) {
331
+ props = {
332
+ ...input.props,
333
+ children: typeof input.children === "string" ? input.children : String(input.children ?? "")
334
+ };
335
+ }
336
+ const html = await this.renderNonReactShellComponent(
337
+ this.asNonReactShellComponent(input.component),
338
+ props,
339
+ "Component"
340
+ );
341
+ const hasDependencies = Boolean(input.component.config?.dependencies);
342
+ const canResolveAssets = typeof this.assetProcessingService?.processDependencies === "function";
343
+ const assets = hasDependencies && canResolveAssets ? await this.processComponentDependencies([input.component]) : void 0;
344
+ const queuedForeignSubtreeResolution = await this.resolveQueuedForeignSubtreeHtml(html, runtimeContext);
345
+ const mergedAssets = this.htmlTransformer.dedupeProcessedAssets([
346
+ ...assets ?? [],
347
+ ...queuedForeignSubtreeResolution.assets
348
+ ]);
349
+ return {
350
+ html: queuedForeignSubtreeResolution.html,
351
+ canAttachAttributes: true,
352
+ rootTag: this.getRootTagName(queuedForeignSubtreeResolution.html),
353
+ integrationName: this.name,
354
+ assets: mergedAssets.length > 0 ? mergedAssets : void 0
355
+ };
356
+ }
357
+ /**
358
+ * Renders a React-owned component and attaches island hydration metadata when possible.
359
+ *
360
+ * This path keeps React-owned SSR, queued foreign-subtree resolution, and optional
361
+ * island hydration wiring together so the public `renderComponent()` method can
362
+ * read as orchestration rather than implementation detail.
363
+ */
364
+ async renderReactManagedComponent(input, runtimeContext) {
274
365
  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);
277
- let canAttachAttributes = hasSingleRootElement(html);
278
- let rootTag = this.getRootTagName(html);
366
+ const context = {
367
+ componentInstanceId: input.integrationContext?.componentInstanceId
368
+ };
369
+ const hasResolvedChildHtml = input.children !== void 0;
370
+ let html = this.renderComponentHtml(input, context, runtimeContext);
371
+ const queuedForeignSubtreeResolution = await this.resolveQueuedForeignSubtreeHtml(html, runtimeContext);
372
+ html = queuedForeignSubtreeResolution.html;
373
+ const canAttachAttributes = hasSingleRootElement(html);
374
+ const rootTag = this.getRootTagName(html);
279
375
  const componentFile = componentConfig?.__eco?.file;
280
- const context = input.integrationContext ?? {};
281
376
  let rootAttributes;
282
377
  let assets;
283
- if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService) {
378
+ if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
284
379
  const componentInstanceId = context.componentInstanceId;
285
- assets = await this.hydrationAssetService.buildComponentRenderAssets(
286
- componentFile,
287
- componentInstanceId,
288
- input.props,
289
- componentConfig
290
- );
380
+ assets = await this.hydrationAssetService.buildComponentRenderAssets(componentFile, componentConfig);
291
381
  rootAttributes = {
292
382
  "data-eco-component-id": componentInstanceId,
293
- "data-eco-props": btoa(JSON.stringify(input.props ?? {}))
383
+ "data-eco-component-key": getReactIslandComponentKey(componentFile, componentConfig),
384
+ "data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
294
385
  };
295
386
  }
387
+ const mergedAssets = this.htmlTransformer.dedupeProcessedAssets([
388
+ ...assets ?? [],
389
+ ...queuedForeignSubtreeResolution.assets
390
+ ]);
296
391
  return {
297
392
  html,
298
393
  canAttachAttributes,
299
394
  rootTag,
300
395
  integrationName: this.name,
301
396
  rootAttributes,
302
- assets
397
+ assets: mergedAssets.length > 0 ? mergedAssets : void 0
303
398
  };
304
399
  }
400
+ /**
401
+ * Renders a React component for component-level orchestration.
402
+ *
403
+ * Behavior:
404
+ * - SSR always returns the component's own root HTML (no synthetic wrapper).
405
+ * - When an explicit component instance id is provided, a stable
406
+ * `data-eco-component-id` attribute is attached so island hydration can target it.
407
+ * - Without an explicit instance id, component renders remain plain SSR output.
408
+ * - When resolved child HTML is provided, that foreign subtree is treated as a pure SSR
409
+ * composition step and does not emit hydration assets for the parent wrapper.
410
+ *
411
+ * This preserves DOM shape for global CSS/layout selectors while keeping a
412
+ * deterministic mount target per component instance.
413
+ */
414
+ async renderComponent(input) {
415
+ const runtimeContext = this.getQueuedForeignSubtreeResolutionContext(input);
416
+ if (!this.isReactManagedComponent(input.component)) {
417
+ return this.renderForeignComponentWithSerializedHtml(input, runtimeContext);
418
+ }
419
+ return this.renderReactManagedComponent(input, runtimeContext);
420
+ }
421
+ createForeignChildRuntime(options) {
422
+ return this.createQueuedForeignSubtreeExecutionRuntime({
423
+ renderInput: options.renderInput,
424
+ rendererCache: options.rendererCache,
425
+ createRuntimeContext: (integrationContext, rendererCache) => ({
426
+ rendererCache,
427
+ componentInstanceScope: integrationContext.componentInstanceId,
428
+ nextForeignSubtreeId: 0,
429
+ queuedResolutions: [],
430
+ rawChildrenToken: void 0,
431
+ rawChildrenHtml: void 0
432
+ })
433
+ });
434
+ }
305
435
  /**
306
436
  * Checks if the given file path corresponds to an MDX file based on configured extensions.
307
437
  * @param filePath - The file path to check
@@ -310,126 +440,49 @@ class ReactRenderer extends IntegrationRenderer {
310
440
  isMdxFile(filePath) {
311
441
  return this.pageModuleService.isMdxFile(filePath);
312
442
  }
313
- /**
314
- * Processes MDX-specific configuration dependencies including layout dependencies.
315
- * @param pagePath - Absolute path to the MDX page file
316
- * @returns Processed assets for MDX configuration dependencies
317
- */
318
- async processMdxConfigDependencies(pagePath) {
319
- const { config } = await this.importPageFile(pagePath);
320
- const resolvedLayout = config?.layout;
321
- const components = [];
322
- if (resolvedLayout?.config?.dependencies) {
323
- const layoutConfig = this.pageModuleService.ensureConfigFileMetadata(resolvedLayout.config, pagePath);
324
- components.push({ config: layoutConfig });
325
- }
326
- if (config?.dependencies) {
327
- const configWithMeta = {
328
- ...config,
329
- __eco: { id: rapidhash(pagePath).toString(36), file: pagePath, integration: "react" }
330
- };
331
- components.push({ config: configWithMeta });
332
- }
333
- const processedDependencies = await this.processComponentDependencies(components);
334
- const eagerSsrLazyDependencies = await this.processDeclaredMdxSsrLazyDependencies(components, pagePath);
335
- return [...processedDependencies, ...eagerSsrLazyDependencies];
443
+ usesIntegrationPageImporter(file) {
444
+ return this.pageModuleService.isMdxFile(file);
336
445
  }
337
- async processDeclaredMdxSsrLazyDependencies(components, pagePath) {
338
- if (!this.assetProcessingService?.processDependencies) {
339
- return [];
340
- }
341
- const dependencies = this.collectDeclaredMdxSsrLazyDependencies(components);
342
- if (dependencies.length === 0) {
343
- return [];
446
+ async importIntegrationPageFile(file, options) {
447
+ return await this.pageModuleService.importMdxPageFile(file, options);
448
+ }
449
+ normalizeImportedPageFile(file, pageModule) {
450
+ const reactModule = pageModule;
451
+ const { default: Page, getMetadata, config } = reactModule;
452
+ if (this.pageModuleService.isMdxFile(file) && config) {
453
+ Page.config = config;
344
454
  }
345
- return this.assetProcessingService.processDependencies(dependencies, `react-mdx-ssr-lazy:${pagePath}`);
346
- }
347
- collectDeclaredMdxSsrLazyDependencies(components) {
348
- const dependencies = [];
349
- const visitedConfigs = /* @__PURE__ */ new Set();
350
- const seenKeys = /* @__PURE__ */ new Set();
351
- const normalizeAttributes = (attributes) => ({
352
- type: "module",
353
- defer: "",
354
- ...attributes ?? {}
355
- });
356
- const collect = (config) => {
357
- if (!config || visitedConfigs.has(config)) {
358
- return;
359
- }
360
- visitedConfigs.add(config);
361
- const componentFile = config.__eco?.file;
362
- if (componentFile) {
363
- const componentDir = path.dirname(componentFile);
364
- for (const script of config.dependencies?.scripts ?? []) {
365
- if (typeof script === "string" || !script.lazy || script.ssr !== true) {
366
- continue;
367
- }
368
- const attributes = normalizeAttributes(script.attributes);
369
- if (script.content) {
370
- const key2 = `content:${script.content}:${JSON.stringify(attributes)}`;
371
- if (seenKeys.has(key2)) {
372
- continue;
373
- }
374
- seenKeys.add(key2);
375
- dependencies.push(
376
- AssetFactory.createContentScript({
377
- position: "head",
378
- content: script.content,
379
- attributes
380
- })
381
- );
382
- continue;
383
- }
384
- if (!script.src) {
385
- continue;
386
- }
387
- const resolvedPath = path.resolve(componentDir, script.src);
388
- const key = `file:${resolvedPath}:${JSON.stringify(attributes)}`;
389
- if (seenKeys.has(key)) {
390
- continue;
391
- }
392
- seenKeys.add(key);
393
- dependencies.push(
394
- AssetFactory.createFileScript({
395
- filepath: resolvedPath,
396
- position: "head",
397
- attributes
398
- })
399
- );
400
- }
401
- }
402
- if (config.layout?.config) {
403
- collect(config.layout.config);
404
- }
405
- for (const nestedComponent of config.dependencies?.components ?? []) {
406
- collect(nestedComponent?.config);
407
- }
455
+ return {
456
+ ...pageModule,
457
+ default: Page,
458
+ getMetadata,
459
+ config
408
460
  };
409
- for (const component of components) {
410
- collect(component.config);
411
- }
412
- return dependencies;
413
461
  }
414
- async buildRouteRenderAssets(pagePath) {
462
+ async collectPageBrowserGraphContribution(context) {
415
463
  try {
416
- const pageModule = await this.importPageFile(pagePath);
417
- const shouldHydrate = ReactRenderer.explicitGraphEnabled ? true : this.pageModuleService.shouldHydratePage(pageModule);
464
+ const { file: pagePath, pageModule } = context;
465
+ const shouldHydrate = this.explicitGraphEnabled ? true : this.pageModuleService.shouldHydratePage(pageModule);
418
466
  if (!shouldHydrate) {
419
- return [];
467
+ return { assets: [] };
420
468
  }
421
469
  const isMdx = this.pageModuleService.isMdxFile(pagePath);
422
470
  const declaredModules = this.pageModuleService.collectPageDeclaredModules(pageModule);
423
- const processedAssets = await this.hydrationAssetService.buildRouteRenderAssets(
471
+ const dependencies = await this.hydrationAssetService.createPageBrowserGraphDependencies(
424
472
  pagePath,
425
473
  isMdx,
426
474
  declaredModules
427
475
  );
476
+ const assets = [];
428
477
  if (isMdx) {
429
- const mdxConfigAssets = await this.processMdxConfigDependencies(pagePath);
430
- return [...processedAssets, ...mdxConfigAssets];
478
+ const mdxConfigAssets = await this.mdxConfigDependencyService.processMdxConfigDependencies({
479
+ pagePath,
480
+ config: pageModule.config,
481
+ processComponentDependencies: async (components) => await this.processComponentDependencies(components)
482
+ });
483
+ assets.push(...mdxConfigAssets);
431
484
  }
432
- return processedAssets;
485
+ return { dependencies, assets };
433
486
  } catch (error) {
434
487
  if (error instanceof BundleError) {
435
488
  console.error("[ecopages] Bundle errors:", error.logs);
@@ -439,26 +492,6 @@ class ReactRenderer extends IntegrationRenderer {
439
492
  );
440
493
  }
441
494
  }
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
495
  /**
463
496
  * Renders a full route response for the filesystem page pipeline.
464
497
  *
@@ -480,71 +513,48 @@ class ReactRenderer extends IntegrationRenderer {
480
513
  pageProps
481
514
  }) {
482
515
  try {
483
- const safeLocals = this.getSerializableLocals(locals, Page.requires);
484
- const allPageProps = this.buildSerializedPageProps({
516
+ const safeLocals = this.pagePayloadService.getSerializableLocals(locals, this.getComponentRequires(Page));
517
+ const allPageProps = this.pagePayloadService.buildSerializedPageProps({
485
518
  pageProps,
486
519
  params,
487
520
  query,
488
521
  safeLocals
489
522
  });
490
- const { contentNode, contentHtml } = await this.composePageContent({
491
- Page: this.asReactComponent(Page),
492
- Layout,
493
- pageProps: { params, query, ...props, locals: pageLocals },
494
- locals
495
- });
496
- return await this.renderDocument({
497
- HtmlTemplate,
523
+ return await this.renderPageWithDocumentShell({
524
+ page: {
525
+ component: Page,
526
+ props: { params, query, ...props, locals: pageLocals }
527
+ },
528
+ layout: Layout ? {
529
+ component: Layout,
530
+ props: locals ? { locals } : {}
531
+ } : void 0,
532
+ htmlTemplate: HtmlTemplate,
498
533
  metadata,
499
- pageProps: allPageProps,
500
- contentNode,
501
- contentHtml
534
+ pageProps: allPageProps
502
535
  });
503
536
  } catch (error) {
504
537
  throw this.createRenderError("Failed to render component", error);
505
538
  }
506
539
  }
507
- getDocumentAttributes() {
508
- return this.getRouterDocumentAttributes();
509
- }
510
- /**
511
- * Safely extracts the declared subset of locals for client-side hydration.
512
- *
513
- * On dynamic pages with `cache: 'dynamic'`, middleware populates `locals` with
514
- * request-scoped data (e.g., session). Only keys explicitly declared via
515
- * `Page.requires` are serialized to the client so sensitive request-only data
516
- * is not leaked into hydration payloads by default.
517
- *
518
- * On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
519
- * to prevent accidental use. This method safely detects that case and returns
520
- * `undefined` instead of throwing.
521
- *
522
- * @param locals - The locals object from the render context
523
- * @param requiredLocals - Keys explicitly requested for client hydration
524
- * @returns The filtered locals object if serializable, undefined otherwise
525
- */
526
- getSerializableLocals(locals, requiredLocals) {
527
- try {
528
- if (!locals) {
529
- return void 0;
530
- }
531
- const requiredKeys = requiredLocals ? Array.isArray(requiredLocals) ? requiredLocals : [requiredLocals] : [];
532
- if (requiredKeys.length === 0) {
533
- return void 0;
534
- }
535
- const serializedLocals = Object.fromEntries(
536
- requiredKeys.filter((key) => Object.prototype.hasOwnProperty.call(locals, key)).map((key) => [key, locals[key]])
537
- );
538
- if (Object.keys(serializedLocals).length > 0) {
539
- return serializedLocals;
540
- }
540
+ getHtmlDocumentContributions(options) {
541
+ if (options.partial || !options.renderOptions) {
541
542
  return void 0;
542
- } catch (e) {
543
- if (e instanceof LocalsAccessError) {
544
- return void 0;
545
- }
546
- throw e;
547
543
  }
544
+ const safeLocals = this.pagePayloadService.getSerializableLocals(
545
+ options.renderOptions.locals,
546
+ this.getComponentRequires(options.renderOptions.Page)
547
+ );
548
+ const allPageProps = this.pagePayloadService.buildSerializedPageProps({
549
+ pageProps: options.renderOptions.pageProps,
550
+ params: options.renderOptions.params,
551
+ query: options.renderOptions.query,
552
+ safeLocals
553
+ });
554
+ return this.buildNonReactDocumentContributions(options.renderOptions.HtmlTemplate, allPageProps);
555
+ }
556
+ getDocumentAttributes() {
557
+ return this.getRouterDocumentAttributes();
548
558
  }
549
559
  /**
550
560
  * Renders an arbitrary React view through the application's HTML shell.
@@ -556,43 +566,49 @@ class ReactRenderer extends IntegrationRenderer {
556
566
  */
557
567
  async renderToResponse(view, props, ctx) {
558
568
  try {
569
+ const { react, reactDomServer } = this.getReactRuntimeModules();
559
570
  const viewConfig = view.config;
560
571
  const Layout = viewConfig?.layout;
561
572
  const ViewComponent = this.asReactComponent(view);
562
573
  const normalizedProps = props ?? {};
563
574
  if (ctx.partial) {
564
- const stream = await renderToReadableStream(createElement(ViewComponent, normalizedProps));
565
- return this.createHtmlResponse(stream, ctx);
575
+ return this.renderPartialViewResponse({
576
+ view,
577
+ props,
578
+ ctx,
579
+ renderInline: async () => await reactDomServer.renderToReadableStream(
580
+ react.createElement(ViewComponent, normalizedProps)
581
+ )
582
+ });
566
583
  }
567
584
  const HtmlTemplate = await this.getHtmlTemplate();
568
585
  const metadata = await this.resolveViewMetadata(view, props);
569
586
  await this.prepareViewDependencies(view, Layout);
570
587
  await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
571
- const { contentNode, contentHtml } = await this.composePageContent({
572
- Page: ViewComponent,
573
- Layout,
574
- pageProps: normalizedProps
588
+ const viewRender = await this.renderComponentWithForeignChildren({
589
+ component: view,
590
+ props: normalizedProps
575
591
  });
576
- const body = await this.renderDocument({
577
- HtmlTemplate,
578
- metadata,
579
- pageProps: normalizedProps,
580
- contentNode,
581
- contentHtml
592
+ const layoutRender = Layout ? await this.renderComponentWithForeignChildren({
593
+ component: Layout,
594
+ props: {},
595
+ children: viewRender.html
596
+ }) : void 0;
597
+ const documentRender = await this.renderComponentWithForeignChildren({
598
+ component: HtmlTemplate,
599
+ props: {
600
+ metadata,
601
+ pageProps: normalizedProps
602
+ },
603
+ children: layoutRender?.html ?? viewRender.html
604
+ });
605
+ this.appendProcessedDependencies(viewRender.assets, layoutRender?.assets, documentRender.assets);
606
+ const transformedHtml = await this.finalizeResolvedHtml({
607
+ html: `${this.DOC_TYPE}${documentRender.html}`,
608
+ partial: false,
609
+ documentAttributes: this.getRouterDocumentAttributes(),
610
+ htmlContributions: this.buildNonReactDocumentContributions(HtmlTemplate, normalizedProps)
582
611
  });
583
- const transformedResponse = await this.htmlTransformer.transform(
584
- new Response(body, {
585
- headers: { "Content-Type": "text/html" }
586
- })
587
- );
588
- let transformedHtml = await transformedResponse.text();
589
- const documentAttributes = this.getRouterDocumentAttributes();
590
- if (documentAttributes) {
591
- transformedHtml = this.htmlTransformer.applyAttributesToHtmlElement(
592
- transformedHtml,
593
- documentAttributes
594
- );
595
- }
596
612
  return this.createHtmlResponse(transformedHtml, ctx);
597
613
  } catch (error) {
598
614
  throw this.createRenderError("Failed to render view", error);