@ecopages/react 0.2.0-alpha.3 → 0.2.0-alpha.30

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 (68) hide show
  1. package/CHANGELOG.md +27 -39
  2. package/README.md +161 -18
  3. package/package.json +6 -6
  4. package/src/react-hmr-strategy.d.ts +42 -32
  5. package/src/react-hmr-strategy.js +99 -123
  6. package/src/react-renderer.d.ts +168 -41
  7. package/src/react-renderer.js +466 -163
  8. package/src/react.constants.d.ts +1 -0
  9. package/src/react.constants.js +4 -0
  10. package/src/react.plugin.d.ts +38 -111
  11. package/src/react.plugin.js +132 -61
  12. package/src/react.types.d.ts +88 -0
  13. package/src/react.types.js +0 -0
  14. package/src/router-adapter.d.ts +7 -14
  15. package/src/services/react-bundle.service.d.ts +15 -26
  16. package/src/services/react-bundle.service.js +44 -92
  17. package/src/services/react-hmr-page-metadata-cache.d.ts +9 -0
  18. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  19. package/src/services/react-hydration-asset.service.d.ts +17 -18
  20. package/src/services/react-hydration-asset.service.js +59 -65
  21. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  22. package/src/services/react-mdx-config-dependency.service.js +122 -0
  23. package/src/services/react-page-module.service.d.ts +10 -2
  24. package/src/services/react-page-module.service.js +44 -37
  25. package/src/services/react-page-payload.service.d.ts +46 -0
  26. package/src/services/react-page-payload.service.js +67 -0
  27. package/src/services/react-runtime-bundle.service.d.ts +15 -13
  28. package/src/services/react-runtime-bundle.service.js +103 -180
  29. package/src/utils/client-graph-boundary-plugin.d.ts +1 -1
  30. package/src/utils/client-graph-boundary-plugin.js +149 -11
  31. package/src/utils/component-config-traversal.d.ts +36 -0
  32. package/src/utils/component-config-traversal.js +54 -0
  33. package/src/utils/declared-modules.d.ts +1 -1
  34. package/src/utils/declared-modules.js +7 -16
  35. package/src/utils/dynamic.test.browser.d.ts +1 -0
  36. package/src/utils/dynamic.test.browser.js +33 -0
  37. package/src/utils/hydration-scripts.d.ts +25 -6
  38. package/src/utils/hydration-scripts.js +150 -44
  39. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  40. package/src/utils/hydration-scripts.test.browser.js +198 -0
  41. package/src/utils/reachability-analyzer.d.ts +12 -1
  42. package/src/utils/reachability-analyzer.js +101 -5
  43. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  44. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  45. package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
  46. package/src/utils/react-mdx-loader-plugin.js +13 -5
  47. package/src/utils/react-runtime-alias-map.d.ts +6 -0
  48. package/src/utils/react-runtime-alias-map.js +33 -0
  49. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  50. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  51. package/src/react-hmr-strategy.ts +0 -444
  52. package/src/react-renderer.ts +0 -403
  53. package/src/react.plugin.ts +0 -241
  54. package/src/router-adapter.ts +0 -95
  55. package/src/services/react-bundle.service.ts +0 -212
  56. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  57. package/src/services/react-hydration-asset.service.ts +0 -260
  58. package/src/services/react-page-module.service.ts +0 -214
  59. package/src/services/react-runtime-bundle.service.ts +0 -271
  60. package/src/utils/client-graph-boundary-plugin.ts +0 -590
  61. package/src/utils/client-only.ts +0 -27
  62. package/src/utils/declared-modules.ts +0 -99
  63. package/src/utils/dynamic.ts +0 -27
  64. package/src/utils/hmr-scripts.ts +0 -47
  65. package/src/utils/html-boundary.ts +0 -66
  66. package/src/utils/hydration-scripts.ts +0 -338
  67. package/src/utils/reachability-analyzer.ts +0 -440
  68. package/src/utils/react-mdx-loader-plugin.ts +0 -40
@@ -1,15 +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
- import { rapidhash } from "@ecopages/core/hash";
5
- import { createElement } from "react";
6
- import { renderToReadableStream, renderToString } from "react-dom/server";
7
- import { PLUGIN_NAME } from "./react.plugin.js";
5
+ import { getAppBuildExecutor } from "@ecopages/core/build/build-adapter";
6
+ import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from "@ecopages/core/router/navigation-coordinator";
7
+ import { createRequire } from "node:module";
8
+ import path from "node:path";
9
+ import { REACT_PLUGIN_NAME } from "./react.constants.js";
8
10
  import { hasSingleRootElement } from "./utils/html-boundary.js";
9
11
  import { ReactBundleService } from "./services/react-bundle.service.js";
10
12
  import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
13
+ import { ReactMdxConfigDependencyService } from "./services/react-mdx-config-dependency.service.js";
11
14
  import { ReactPageModuleService } from "./services/react-page-module.service.js";
12
- 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";
13
17
  class ReactRenderError extends Error {
14
18
  constructor(message) {
15
19
  super(message);
@@ -17,103 +21,416 @@ class ReactRenderError extends Error {
17
21
  }
18
22
  }
19
23
  class BundleError extends Error {
24
+ logs;
20
25
  constructor(message, logs) {
21
26
  super(message);
22
- this.logs = logs;
23
27
  this.name = "BundleError";
28
+ this.logs = logs;
24
29
  }
25
30
  }
26
31
  class ReactRenderer extends IntegrationRenderer {
27
- name = PLUGIN_NAME;
32
+ name = REACT_PLUGIN_NAME;
28
33
  componentDirectory = RESOLVED_ASSETS_DIR;
29
- componentRenderSequence = 0;
30
- static routerAdapter;
31
- static mdxCompilerOptions;
32
- static mdxExtensions = [".mdx"];
33
- static hmrPageMetadataCache;
34
+ reactRuntimeModules;
35
+ routerAdapter;
36
+ mdxCompilerOptions;
37
+ mdxExtensions;
38
+ hmrPageMetadataCache;
34
39
  /**
35
40
  * Enables explicit graph behavior for React page-entry bundling.
36
41
  *
37
42
  * When true, page-entry bundles disable AST server-only stripping and rely
38
43
  * on explicit dependency declarations for browser graph composition.
39
44
  */
40
- static explicitGraphEnabled = false;
45
+ explicitGraphEnabled;
41
46
  /** @internal */
42
47
  bundleService;
43
48
  /** @internal */
44
49
  pageModuleService;
45
50
  /** @internal */
46
51
  hydrationAssetService;
52
+ /** @internal */
53
+ pagePayloadService;
54
+ /** @internal */
55
+ mdxConfigDependencyService;
47
56
  constructor(options) {
48
- 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;
49
64
  this.bundleService = new ReactBundleService({
50
65
  rootDir: this.appConfig.rootDir,
51
- routerAdapter: ReactRenderer.routerAdapter,
52
- 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)
53
70
  });
54
71
  this.pageModuleService = new ReactPageModuleService({
55
72
  rootDir: this.appConfig.rootDir,
56
73
  distDir: this.appConfig.absolutePaths.distDir,
74
+ workDir: this.appConfig.absolutePaths.workDir,
75
+ buildExecutor: getAppBuildExecutor(this.appConfig),
57
76
  layoutsDir: this.appConfig.absolutePaths.layoutsDir,
58
77
  componentsDir: this.appConfig.absolutePaths.componentsDir,
59
- mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
60
- mdxExtensions: ReactRenderer.mdxExtensions,
78
+ mdxCompilerOptions: this.mdxCompilerOptions,
79
+ mdxExtensions: this.mdxExtensions,
61
80
  integrationName: this.name,
62
- hasRouterAdapter: Boolean(ReactRenderer.routerAdapter)
81
+ hasRouterAdapter: Boolean(this.routerAdapter)
63
82
  });
64
83
  this.hydrationAssetService = new ReactHydrationAssetService({
65
84
  srcDir: this.appConfig.srcDir,
66
- routerAdapter: ReactRenderer.routerAdapter,
85
+ routerAdapter: this.routerAdapter,
67
86
  assetProcessingService: this.assetProcessingService,
68
87
  bundleService: this.bundleService,
69
- 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
70
95
  });
71
96
  }
72
97
  shouldRenderPageComponent() {
73
98
  return false;
74
99
  }
75
100
  /**
76
- * Renders a React component for component-level orchestration.
101
+ * Reads the declared integration name for a component or layout.
77
102
  *
78
- * Behavior:
79
- * - SSR always returns the component's own root HTML (no synthetic wrapper).
80
- * - For single-root output, a stable `data-eco-component-id` attribute is attached
81
- * to the root element so the client island runtime can target it directly.
82
- * - Island client scripts are emitted through `assets` and mounted independently.
103
+ * We honor both the explicit `config.integration` override and injected
104
+ * `config.__eco.integration` metadata because pages can arrive here through
105
+ * authored config as well as build-time component metadata.
106
+ */
107
+ getComponentIntegration(component) {
108
+ return component?.config?.integration ?? component?.config?.__eco?.integration;
109
+ }
110
+ /**
111
+ * Returns whether a component should stay inside the React render lane.
83
112
  *
84
- * This preserves DOM shape for global CSS/layout selectors while keeping a
85
- * deterministic mount target per component instance.
113
+ * Components without explicit integration metadata are treated as React-owned
114
+ * here because this renderer only receives them after the route pipeline has
115
+ * already selected the React integration.
86
116
  */
87
- async renderComponent(input) {
88
- const Component = input.component;
117
+ isReactManagedComponent(component) {
118
+ const integration = this.getComponentIntegration(component);
119
+ return integration === void 0 || integration === this.name;
120
+ }
121
+ getComponentRequires(component) {
122
+ return component?.requires;
123
+ }
124
+ getRouterDocumentAttributes() {
125
+ if (!this.routerAdapter) {
126
+ return void 0;
127
+ }
128
+ return {
129
+ [ECO_DOCUMENT_OWNER_ATTRIBUTE]: "react-router"
130
+ };
131
+ }
132
+ /**
133
+ * Commits a framework-agnostic component to React semantics.
134
+ *
135
+ * This is one of the two real cast boundaries in this file. Core keeps
136
+ * `EcoComponent` broad so integrations can share the same public surface; once
137
+ * the React renderer is executing, `createElement()` needs a concrete React
138
+ * component signature.
139
+ */
140
+ asReactComponent(component) {
141
+ return component;
142
+ }
143
+ /**
144
+ * Commits a mixed-shell component to the string-returning contract required by
145
+ * non-React layouts and HTML templates.
146
+ *
147
+ * This is the second real cast boundary: once we decide a shell is not managed
148
+ * by React, we call it directly and require serialized HTML back.
149
+ */
150
+ asNonReactShellComponent(component) {
151
+ return component;
152
+ }
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;
172
+ }
173
+ /**
174
+ * Appends route hydration assets for a concrete page/view file to the current
175
+ * HTML transformer state.
176
+ */
177
+ async appendHydrationAssetsForFile(filePath) {
178
+ if (!filePath) {
179
+ return;
180
+ }
181
+ const pageBrowserGraph = await this.buildPageBrowserGraph(filePath);
182
+ this.appendProcessedDependencies(pageBrowserGraph.assets);
183
+ }
184
+ /**
185
+ * Renders a non-React layout or HTML template and enforces that mixed shells
186
+ * return serialized HTML.
187
+ *
188
+ * The React renderer can compose through another integration's shell, but only
189
+ * if that shell yields a string that can be inserted into the final document.
190
+ */
191
+ async renderNonReactShellComponent(Component, props, label) {
192
+ const output = await Component(props);
193
+ if (typeof output === "string") {
194
+ return output;
195
+ }
196
+ throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
197
+ }
198
+ /**
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.
206
+ *
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.
210
+ */
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
+ );
217
+ }
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;
223
+ }
224
+ const html = reactDomServer.renderToString(
225
+ react.createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
226
+ );
227
+ return this.normalizeUnresolvedMarkerArtifactHtml(html.split(rawChildrenToken).join(resolvedChildHtml));
228
+ }
229
+ /**
230
+ * Restores raw child HTML that was temporarily replaced by a token during React SSR.
231
+ *
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.
237
+ */
238
+ restoreRuntimeChildHtml(html, runtimeContext) {
239
+ if (!runtimeContext?.rawChildrenToken || runtimeContext.rawChildrenHtml === void 0) {
240
+ return html;
241
+ }
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
265
+ );
266
+ return html;
267
+ }
268
+ /**
269
+ * Resolves queued renderer-owned foreign-subtree tokens produced during React component rendering.
270
+ *
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 the extra document props needed when React renders through a non-React HTML shell.
308
+ *
309
+ * Router-backed React pages still need to publish the canonical page-data script
310
+ * even when the outer document shell belongs to another integration.
311
+ */
312
+ buildNonReactDocumentProps(htmlTemplate, pageProps) {
313
+ if (this.isReactManagedComponent(htmlTemplate) || !this.routerAdapter) {
314
+ return void 0;
315
+ }
316
+ return {
317
+ headContent: this.pagePayloadService.buildRouterPageDataScript(pageProps)
318
+ };
319
+ }
320
+ /**
321
+ * Renders a foreign integration component that participates in React composition.
322
+ *
323
+ * Non-React components must resolve to serialized HTML so React can embed them as
324
+ * mixed-shell children. Any component-owned dependencies still need to flow
325
+ * through the shared dependency resolver before queued foreign-subtree tokens are finalized.
326
+ */
327
+ async renderForeignComponentWithSerializedHtml(input, runtimeContext) {
328
+ let props = input.props;
329
+ if (input.children !== void 0) {
330
+ props = {
331
+ ...input.props,
332
+ children: typeof input.children === "string" ? input.children : String(input.children ?? "")
333
+ };
334
+ }
335
+ const html = await this.renderNonReactShellComponent(
336
+ this.asNonReactShellComponent(input.component),
337
+ props,
338
+ "Component"
339
+ );
340
+ const hasDependencies = Boolean(input.component.config?.dependencies);
341
+ const canResolveAssets = typeof this.assetProcessingService?.processDependencies === "function";
342
+ const assets = hasDependencies && canResolveAssets ? await this.processComponentDependencies([input.component]) : void 0;
343
+ const queuedForeignSubtreeResolution = await this.resolveQueuedForeignSubtreeHtml(html, runtimeContext);
344
+ const mergedAssets = this.htmlTransformer.dedupeProcessedAssets([
345
+ ...assets ?? [],
346
+ ...queuedForeignSubtreeResolution.assets
347
+ ]);
348
+ return {
349
+ html: queuedForeignSubtreeResolution.html,
350
+ canAttachAttributes: true,
351
+ rootTag: this.getRootTagName(queuedForeignSubtreeResolution.html),
352
+ integrationName: this.name,
353
+ assets: mergedAssets.length > 0 ? mergedAssets : void 0
354
+ };
355
+ }
356
+ /**
357
+ * Renders a React-owned component and attaches island hydration metadata when possible.
358
+ *
359
+ * This path keeps React-owned SSR, queued foreign-subtree resolution, and optional
360
+ * island hydration wiring together so the public `renderComponent()` method can
361
+ * read as orchestration rather than implementation detail.
362
+ */
363
+ async renderReactManagedComponent(input, runtimeContext) {
89
364
  const componentConfig = input.component.config;
90
- const element = input.children === void 0 ? createElement(Component, input.props) : createElement(Component, input.props, input.children);
91
- let html = renderToString(element);
92
- let canAttachAttributes = hasSingleRootElement(html);
93
- let rootTag = this.getRootTagName(html);
365
+ const context = {
366
+ componentInstanceId: input.integrationContext?.componentInstanceId
367
+ };
368
+ const hasResolvedChildHtml = input.children !== void 0;
369
+ let html = this.renderComponentHtml(input, context, runtimeContext);
370
+ const queuedForeignSubtreeResolution = await this.resolveQueuedForeignSubtreeHtml(html, runtimeContext);
371
+ html = queuedForeignSubtreeResolution.html;
372
+ const canAttachAttributes = hasSingleRootElement(html);
373
+ const rootTag = this.getRootTagName(html);
94
374
  const componentFile = componentConfig?.__eco?.file;
95
- const context = input.integrationContext ?? {};
96
375
  let rootAttributes;
97
376
  let assets;
98
- if (canAttachAttributes && componentFile && this.assetProcessingService) {
99
- const componentInstanceId = context.componentInstanceId ?? `eco-component-${rapidhash(componentFile)}-${++this.componentRenderSequence}`;
100
- assets = await this.hydrationAssetService.buildComponentRenderAssets(
101
- componentFile,
102
- componentInstanceId,
103
- input.props,
104
- componentConfig
105
- );
106
- rootAttributes = { "data-eco-component-id": componentInstanceId };
377
+ if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
378
+ const componentInstanceId = context.componentInstanceId;
379
+ assets = await this.hydrationAssetService.buildComponentRenderAssets(componentFile, componentConfig);
380
+ rootAttributes = {
381
+ "data-eco-component-id": componentInstanceId,
382
+ "data-eco-component-key": getReactIslandComponentKey(componentFile, componentConfig),
383
+ "data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
384
+ };
107
385
  }
386
+ const mergedAssets = this.htmlTransformer.dedupeProcessedAssets([
387
+ ...assets ?? [],
388
+ ...queuedForeignSubtreeResolution.assets
389
+ ]);
108
390
  return {
109
391
  html,
110
392
  canAttachAttributes,
111
393
  rootTag,
112
394
  integrationName: this.name,
113
395
  rootAttributes,
114
- assets
396
+ assets: mergedAssets.length > 0 ? mergedAssets : void 0
115
397
  };
116
398
  }
399
+ /**
400
+ * Renders a React component for component-level orchestration.
401
+ *
402
+ * Behavior:
403
+ * - SSR always returns the component's own root HTML (no synthetic wrapper).
404
+ * - When an explicit component instance id is provided, a stable
405
+ * `data-eco-component-id` attribute is attached so island hydration can target it.
406
+ * - Without an explicit instance id, component renders remain plain SSR output.
407
+ * - When resolved child HTML is provided, that foreign subtree is treated as a pure SSR
408
+ * composition step and does not emit hydration assets for the parent wrapper.
409
+ *
410
+ * This preserves DOM shape for global CSS/layout selectors while keeping a
411
+ * deterministic mount target per component instance.
412
+ */
413
+ async renderComponent(input) {
414
+ const runtimeContext = this.getQueuedForeignSubtreeResolutionContext(input);
415
+ if (!this.isReactManagedComponent(input.component)) {
416
+ return this.renderForeignComponentWithSerializedHtml(input, runtimeContext);
417
+ }
418
+ return this.renderReactManagedComponent(input, runtimeContext);
419
+ }
420
+ createForeignChildRuntime(options) {
421
+ return this.createQueuedForeignSubtreeExecutionRuntime({
422
+ renderInput: options.renderInput,
423
+ rendererCache: options.rendererCache,
424
+ createRuntimeContext: (integrationContext, rendererCache) => ({
425
+ rendererCache,
426
+ componentInstanceScope: integrationContext.componentInstanceId,
427
+ nextForeignSubtreeId: 0,
428
+ queuedResolutions: [],
429
+ rawChildrenToken: void 0,
430
+ rawChildrenHtml: void 0
431
+ })
432
+ });
433
+ }
117
434
  /**
118
435
  * Checks if the given file path corresponds to an MDX file based on configured extensions.
119
436
  * @param filePath - The file path to check
@@ -122,47 +439,48 @@ class ReactRenderer extends IntegrationRenderer {
122
439
  isMdxFile(filePath) {
123
440
  return this.pageModuleService.isMdxFile(filePath);
124
441
  }
125
- /**
126
- * Processes MDX-specific configuration dependencies including layout dependencies.
127
- * @param pagePath - Absolute path to the MDX page file
128
- * @returns Processed assets for MDX configuration dependencies
129
- */
130
- async processMdxConfigDependencies(pagePath) {
131
- const { config } = await this.importPageFile(pagePath);
132
- const resolvedLayout = config?.layout;
133
- const components = [];
134
- if (resolvedLayout?.config?.dependencies) {
135
- const layoutConfig = this.pageModuleService.ensureConfigFileMetadata(resolvedLayout.config, pagePath);
136
- components.push({ config: layoutConfig });
137
- }
138
- if (config?.dependencies) {
139
- const configWithMeta = {
140
- ...config,
141
- __eco: { id: rapidhash(pagePath).toString(36), file: pagePath, integration: "react" }
142
- };
143
- components.push({ config: configWithMeta });
442
+ usesIntegrationPageImporter(file) {
443
+ return this.pageModuleService.isMdxFile(file);
444
+ }
445
+ async importIntegrationPageFile(file, options) {
446
+ return await this.pageModuleService.importMdxPageFile(file, options);
447
+ }
448
+ normalizeImportedPageFile(file, pageModule) {
449
+ const reactModule = pageModule;
450
+ const { default: Page, getMetadata, config } = reactModule;
451
+ if (this.pageModuleService.isMdxFile(file) && config) {
452
+ Page.config = config;
144
453
  }
145
- return this.processComponentDependencies(components);
454
+ return {
455
+ ...pageModule,
456
+ default: Page,
457
+ getMetadata,
458
+ config
459
+ };
146
460
  }
147
- async buildRouteRenderAssets(pagePath) {
461
+ async buildPageBrowserGraph(pagePath) {
148
462
  try {
149
463
  const pageModule = await this.importPageFile(pagePath);
150
- const shouldHydrate = ReactRenderer.explicitGraphEnabled ? true : this.pageModuleService.shouldHydratePage(pageModule);
464
+ const shouldHydrate = this.explicitGraphEnabled ? true : this.pageModuleService.shouldHydratePage(pageModule);
151
465
  if (!shouldHydrate) {
152
- return [];
466
+ return { assets: [] };
153
467
  }
154
468
  const isMdx = this.pageModuleService.isMdxFile(pagePath);
155
469
  const declaredModules = this.pageModuleService.collectPageDeclaredModules(pageModule);
156
- const processedAssets = await this.hydrationAssetService.buildRouteRenderAssets(
470
+ const processedAssets = await this.hydrationAssetService.buildPageBrowserGraphAssets(
157
471
  pagePath,
158
472
  isMdx,
159
473
  declaredModules
160
474
  );
161
475
  if (isMdx) {
162
- const mdxConfigAssets = await this.processMdxConfigDependencies(pagePath);
163
- return [...processedAssets, ...mdxConfigAssets];
476
+ const mdxConfigAssets = await this.mdxConfigDependencyService.processMdxConfigDependencies({
477
+ pagePath,
478
+ config: pageModule.config,
479
+ processComponentDependencies: async (components) => await this.processComponentDependencies(components)
480
+ });
481
+ return { assets: [...processedAssets, ...mdxConfigAssets] };
164
482
  }
165
- return processedAssets;
483
+ return { assets: processedAssets };
166
484
  } catch (error) {
167
485
  if (error instanceof BundleError) {
168
486
  console.error("[ecopages] Bundle errors:", error.logs);
@@ -172,18 +490,14 @@ class ReactRenderer extends IntegrationRenderer {
172
490
  );
173
491
  }
174
492
  }
175
- async importPageFile(file) {
176
- const module = this.pageModuleService.isMdxFile(file) ? await this.pageModuleService.importMdxPageFile(file) : await super.importPageFile(file);
177
- const { default: Page, getMetadata, config } = module;
178
- if (this.pageModuleService.isMdxFile(file) && config) {
179
- Page.config = config;
180
- }
181
- return {
182
- default: Page,
183
- getMetadata,
184
- config
185
- };
186
- }
493
+ /**
494
+ * Renders a full route response for the filesystem page pipeline.
495
+ *
496
+ * This path receives already-resolved route metadata, layout, locals, and HTML
497
+ * template instances from the shared renderer orchestration. Its main job is to
498
+ * serialize only the browser-safe page payload, compose the mixed React/non-
499
+ * React shell tree, and hand the result back as a document body.
500
+ */
187
501
  async render({
188
502
  params,
189
503
  query,
@@ -197,99 +511,88 @@ class ReactRenderer extends IntegrationRenderer {
197
511
  pageProps
198
512
  }) {
199
513
  try {
200
- const pageElement = createElement(Page, { params, query, ...props, locals: pageLocals });
201
- const contentElement = Layout ? createElement(Layout, { locals }, pageElement) : pageElement;
202
- const safeLocals = this.getSerializableLocals(locals);
203
- const allPageProps = {
204
- ...pageProps,
514
+ const safeLocals = this.pagePayloadService.getSerializableLocals(locals, this.getComponentRequires(Page));
515
+ const allPageProps = this.pagePayloadService.buildSerializedPageProps({
516
+ pageProps,
205
517
  params,
206
518
  query,
207
- ...safeLocals && { locals: safeLocals }
208
- };
209
- return await renderToReadableStream(
210
- createElement(
211
- HtmlTemplate,
212
- {
213
- metadata,
214
- pageProps: allPageProps
215
- },
216
- contentElement
217
- )
218
- );
519
+ safeLocals
520
+ });
521
+ return await this.renderPageWithDocumentShell({
522
+ page: {
523
+ component: Page,
524
+ props: { params, query, ...props, locals: pageLocals }
525
+ },
526
+ layout: Layout ? {
527
+ component: Layout,
528
+ props: locals ? { locals } : {}
529
+ } : void 0,
530
+ htmlTemplate: HtmlTemplate,
531
+ metadata,
532
+ pageProps: allPageProps,
533
+ documentProps: this.buildNonReactDocumentProps(HtmlTemplate, allPageProps)
534
+ });
219
535
  } catch (error) {
220
536
  throw this.createRenderError("Failed to render component", error);
221
537
  }
222
538
  }
539
+ getDocumentAttributes() {
540
+ return this.getRouterDocumentAttributes();
541
+ }
223
542
  /**
224
- * Safely extracts locals for client-side hydration.
225
- *
226
- * On dynamic pages with `cache: 'dynamic'`, middleware populates `locals` with
227
- * request-scoped data (e.g., session). This data needs to be serialized to the
228
- * client for hydration to match the server-rendered output.
229
- *
230
- * On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
231
- * to prevent accidental use. This method safely detects that case and returns
232
- * `undefined` instead of throwing.
543
+ * Renders an arbitrary React view through the application's HTML shell.
233
544
  *
234
- * @param locals - The locals object from the render context
235
- * @returns The locals object if serializable, undefined otherwise
545
+ * Unlike route rendering, this path starts from a single component rather than a
546
+ * page module discovered by the router. It still needs to resolve metadata,
547
+ * layout dependencies, and hydration assets so direct `ctx.render()` calls match
548
+ * normal page responses.
236
549
  */
237
- getSerializableLocals(locals) {
238
- try {
239
- if (locals && Object.keys(locals).length > 0) {
240
- return locals;
241
- }
242
- return void 0;
243
- } catch (e) {
244
- if (e instanceof LocalsAccessError) {
245
- return void 0;
246
- }
247
- throw e;
248
- }
249
- }
250
550
  async renderToResponse(view, props, ctx) {
251
551
  try {
552
+ const { react, reactDomServer } = this.getReactRuntimeModules();
252
553
  const viewConfig = view.config;
253
554
  const Layout = viewConfig?.layout;
254
- const ViewComponent = view;
255
- const pageElement = createElement(ViewComponent, props || {});
555
+ const ViewComponent = this.asReactComponent(view);
556
+ const normalizedProps = props ?? {};
256
557
  if (ctx.partial) {
257
- const stream = await renderToReadableStream(pageElement);
258
- return this.createHtmlResponse(stream, ctx);
558
+ return this.renderPartialViewResponse({
559
+ view,
560
+ props,
561
+ ctx,
562
+ renderInline: async () => await reactDomServer.renderToReadableStream(
563
+ react.createElement(ViewComponent, normalizedProps)
564
+ )
565
+ });
259
566
  }
260
- const contentElement = Layout ? createElement(Layout, {}, pageElement) : pageElement;
261
567
  const HtmlTemplate = await this.getHtmlTemplate();
262
- const metadata = view.metadata ? await view.metadata({
263
- params: {},
264
- query: {},
265
- props,
266
- appConfig: this.appConfig
267
- }) : this.appConfig.defaultMetadata;
568
+ const metadata = await this.resolveViewMetadata(view, props);
268
569
  await this.prepareViewDependencies(view, Layout);
269
- const viewFilePath = viewConfig?.__eco?.file;
270
- if (viewFilePath) {
271
- const hydrationAssets = await this.buildRouteRenderAssets(viewFilePath);
272
- this.htmlTransformer.setProcessedDependencies([
273
- ...this.htmlTransformer.getProcessedDependencies(),
274
- ...hydrationAssets
275
- ]);
276
- }
277
- const streamBody = await renderToReadableStream(
278
- createElement(
279
- HtmlTemplate,
280
- {
281
- metadata,
282
- pageProps: props
283
- },
284
- contentElement
285
- )
286
- );
287
- const transformedResponse = await this.htmlTransformer.transform(
288
- new Response(streamBody, {
289
- headers: { "Content-Type": "text/html" }
290
- })
291
- );
292
- return this.createHtmlResponse(transformedResponse.body ?? "", ctx);
570
+ await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
571
+ const viewRender = await this.renderComponentWithForeignChildren({
572
+ component: view,
573
+ props: normalizedProps
574
+ });
575
+ const layoutRender = Layout ? await this.renderComponentWithForeignChildren({
576
+ component: Layout,
577
+ props: {},
578
+ children: viewRender.html
579
+ }) : void 0;
580
+ const documentRender = await this.renderComponentWithForeignChildren({
581
+ component: HtmlTemplate,
582
+ props: {
583
+ metadata,
584
+ pageProps: normalizedProps,
585
+ ...this.buildNonReactDocumentProps(HtmlTemplate, normalizedProps) ?? {}
586
+ },
587
+ children: layoutRender?.html ?? viewRender.html
588
+ });
589
+ this.appendProcessedDependencies(viewRender.assets, layoutRender?.assets, documentRender.assets);
590
+ const transformedHtml = await this.finalizeResolvedHtml({
591
+ html: `${this.DOC_TYPE}${documentRender.html}`,
592
+ partial: false,
593
+ documentAttributes: this.getRouterDocumentAttributes()
594
+ });
595
+ return this.createHtmlResponse(transformedHtml, ctx);
293
596
  } catch (error) {
294
597
  throw this.createRenderError("Failed to render view", error);
295
598
  }