@ecopages/react 0.2.0-alpha.5 → 0.2.0-alpha.7

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 (41) hide show
  1. package/CHANGELOG.md +22 -41
  2. package/README.md +135 -29
  3. package/package.json +3 -3
  4. package/src/react-hmr-strategy.d.ts +22 -30
  5. package/src/react-hmr-strategy.js +57 -120
  6. package/src/react-hmr-strategy.ts +76 -145
  7. package/src/react-renderer.d.ts +130 -11
  8. package/src/react-renderer.js +368 -64
  9. package/src/react-renderer.ts +490 -90
  10. package/src/react.plugin.d.ts +17 -5
  11. package/src/react.plugin.js +44 -13
  12. package/src/react.plugin.ts +49 -14
  13. package/src/router-adapter.d.ts +2 -2
  14. package/src/router-adapter.ts +2 -2
  15. package/src/services/react-bundle.service.d.ts +2 -30
  16. package/src/services/react-bundle.service.js +19 -94
  17. package/src/services/react-bundle.service.ts +20 -129
  18. package/src/services/react-hydration-asset.service.js +3 -3
  19. package/src/services/react-hydration-asset.service.ts +7 -4
  20. package/src/services/react-page-module.service.d.ts +3 -0
  21. package/src/services/react-page-module.service.js +20 -16
  22. package/src/services/react-page-module.service.ts +27 -17
  23. package/src/services/react-runtime-bundle.service.d.ts +12 -12
  24. package/src/services/react-runtime-bundle.service.js +98 -180
  25. package/src/services/react-runtime-bundle.service.ts +112 -211
  26. package/src/utils/client-graph-boundary-plugin.js +78 -1
  27. package/src/utils/client-graph-boundary-plugin.ts +122 -1
  28. package/src/utils/hydration-scripts.d.ts +18 -1
  29. package/src/utils/hydration-scripts.js +83 -32
  30. package/src/utils/hydration-scripts.ts +159 -38
  31. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  32. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  33. package/src/utils/react-dom-runtime-interop-plugin.ts +33 -0
  34. package/src/utils/react-mdx-loader-plugin.js +13 -5
  35. package/src/utils/react-mdx-loader-plugin.ts +28 -5
  36. package/src/utils/react-runtime-specifier-map.d.ts +6 -0
  37. package/src/utils/react-runtime-specifier-map.js +37 -0
  38. package/src/utils/react-runtime-specifier-map.ts +45 -0
  39. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  40. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  41. package/src/utils/use-sync-external-store-shim-plugin.ts +45 -0
@@ -1,7 +1,11 @@
1
1
  import { IntegrationRenderer } from "@ecopages/core/route-renderer/integration-renderer";
2
2
  import { LocalsAccessError } from "@ecopages/core/errors/locals-access-error";
3
3
  import { RESOLVED_ASSETS_DIR } from "@ecopages/core/constants";
4
+ import { getAppBuildExecutor } from "@ecopages/core/build/build-adapter";
4
5
  import { rapidhash } from "@ecopages/core/hash";
6
+ import { AssetFactory } from "@ecopages/core/services/asset-processing-service";
7
+ import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from "@ecopages/core/router/navigation-coordinator";
8
+ import path from "node:path";
5
9
  import { createElement } from "react";
6
10
  import { renderToReadableStream, renderToString } from "react-dom/server";
7
11
  import { PLUGIN_NAME } from "./react.plugin.js";
@@ -17,16 +21,16 @@ 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
32
  name = PLUGIN_NAME;
28
33
  componentDirectory = RESOLVED_ASSETS_DIR;
29
- componentRenderSequence = 0;
30
34
  static routerAdapter;
31
35
  static mdxCompilerOptions;
32
36
  static mdxExtensions = [".mdx"];
@@ -54,6 +58,8 @@ class ReactRenderer extends IntegrationRenderer {
54
58
  this.pageModuleService = new ReactPageModuleService({
55
59
  rootDir: this.appConfig.rootDir,
56
60
  distDir: this.appConfig.absolutePaths.distDir,
61
+ workDir: this.appConfig.absolutePaths.workDir,
62
+ buildExecutor: getAppBuildExecutor(this.appConfig),
57
63
  layoutsDir: this.appConfig.absolutePaths.layoutsDir,
58
64
  componentsDir: this.appConfig.absolutePaths.componentsDir,
59
65
  mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
@@ -72,20 +78,199 @@ class ReactRenderer extends IntegrationRenderer {
72
78
  shouldRenderPageComponent() {
73
79
  return false;
74
80
  }
81
+ /**
82
+ * Reads the declared integration name for a component or layout.
83
+ *
84
+ * We honor both the explicit `config.integration` override and injected
85
+ * `config.__eco.integration` metadata because pages can arrive here through
86
+ * authored config as well as build-time component metadata.
87
+ */
88
+ getComponentIntegration(component) {
89
+ return component?.config?.integration ?? component?.config?.__eco?.integration;
90
+ }
91
+ /**
92
+ * Returns whether a component should stay inside the React render lane.
93
+ *
94
+ * Components without explicit integration metadata are treated as React-owned
95
+ * here because this renderer only receives them after the route pipeline has
96
+ * already selected the React integration.
97
+ */
98
+ isReactManagedComponent(component) {
99
+ const integration = this.getComponentIntegration(component);
100
+ return integration === void 0 || integration === this.name;
101
+ }
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>`;
112
+ }
113
+ getRouterDocumentAttributes() {
114
+ if (!ReactRenderer.routerAdapter) {
115
+ return void 0;
116
+ }
117
+ return {
118
+ [ECO_DOCUMENT_OWNER_ATTRIBUTE]: "react-router"
119
+ };
120
+ }
121
+ /**
122
+ * Commits a framework-agnostic component to React semantics.
123
+ *
124
+ * This is one of the two real cast boundaries in this file. Core keeps
125
+ * `EcoComponent` broad so integrations can share the same public surface; once
126
+ * the React renderer is executing, `createElement()` needs a concrete React
127
+ * component signature.
128
+ */
129
+ asReactComponent(component) {
130
+ return component;
131
+ }
132
+ /**
133
+ * Commits a mixed-shell component to the string-returning contract required by
134
+ * non-React layouts and HTML templates.
135
+ *
136
+ * This is the second real cast boundary: once we decide a shell is not managed
137
+ * by React, we call it directly and require serialized HTML back.
138
+ */
139
+ asNonReactShellComponent(component) {
140
+ return component;
141
+ }
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
+ };
156
+ }
157
+ /**
158
+ * Appends route hydration assets for a concrete page/view file to the current
159
+ * HTML transformer state.
160
+ */
161
+ async appendHydrationAssetsForFile(filePath) {
162
+ if (!filePath) {
163
+ return;
164
+ }
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;
185
+ }
186
+ /**
187
+ * Renders a non-React layout or HTML template and enforces that mixed shells
188
+ * return serialized HTML.
189
+ *
190
+ * The React renderer can compose through another integration's shell, but only
191
+ * if that shell yields a string that can be inserted into the final document.
192
+ */
193
+ async renderNonReactShellComponent(Component, props, label) {
194
+ const output = await Component(props);
195
+ if (typeof output === "string") {
196
+ return output;
197
+ }
198
+ throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
199
+ }
200
+ /**
201
+ * Produces the page body before the final HTML template is applied.
202
+ *
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.
206
+ */
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 };
213
+ }
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
+ };
220
+ }
221
+ const layoutHtml = await this.renderNonReactShellComponent(
222
+ this.asNonReactShellComponent(options.Layout),
223
+ { ...layoutProps, children: pageHtml },
224
+ "Layout"
225
+ );
226
+ return { contentNode: layoutHtml, contentHtml: layoutHtml };
227
+ }
228
+ /**
229
+ * Wraps composed page content in the final document template.
230
+ *
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.
234
+ */
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
+ );
247
+ }
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"
258
+ );
259
+ }
75
260
  /**
76
261
  * Renders a React component for component-level orchestration.
77
262
  *
78
263
  * Behavior:
79
264
  * - 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.
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.
83
268
  *
84
269
  * This preserves DOM shape for global CSS/layout selectors while keeping a
85
270
  * deterministic mount target per component instance.
86
271
  */
87
272
  async renderComponent(input) {
88
- const Component = input.component;
273
+ const Component = this.asReactComponent(input.component);
89
274
  const componentConfig = input.component.config;
90
275
  const element = input.children === void 0 ? createElement(Component, input.props) : createElement(Component, input.props, input.children);
91
276
  let html = renderToString(element);
@@ -95,15 +280,18 @@ class ReactRenderer extends IntegrationRenderer {
95
280
  const context = input.integrationContext ?? {};
96
281
  let rootAttributes;
97
282
  let assets;
98
- if (canAttachAttributes && componentFile && this.assetProcessingService) {
99
- const componentInstanceId = context.componentInstanceId ?? `eco-component-${rapidhash(componentFile)}-${++this.componentRenderSequence}`;
283
+ if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService) {
284
+ const componentInstanceId = context.componentInstanceId;
100
285
  assets = await this.hydrationAssetService.buildComponentRenderAssets(
101
286
  componentFile,
102
287
  componentInstanceId,
103
288
  input.props,
104
289
  componentConfig
105
290
  );
106
- rootAttributes = { "data-eco-component-id": componentInstanceId };
291
+ rootAttributes = {
292
+ "data-eco-component-id": componentInstanceId,
293
+ "data-eco-props": btoa(JSON.stringify(input.props ?? {}))
294
+ };
107
295
  }
108
296
  return {
109
297
  html,
@@ -142,7 +330,86 @@ class ReactRenderer extends IntegrationRenderer {
142
330
  };
143
331
  components.push({ config: configWithMeta });
144
332
  }
145
- return this.processComponentDependencies(components);
333
+ const processedDependencies = await this.processComponentDependencies(components);
334
+ const eagerSsrLazyDependencies = await this.processDeclaredMdxSsrLazyDependencies(components, pagePath);
335
+ return [...processedDependencies, ...eagerSsrLazyDependencies];
336
+ }
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 [];
344
+ }
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
+ }
408
+ };
409
+ for (const component of components) {
410
+ collect(component.config);
411
+ }
412
+ return dependencies;
146
413
  }
147
414
  async buildRouteRenderAssets(pagePath) {
148
415
  try {
@@ -172,6 +439,14 @@ class ReactRenderer extends IntegrationRenderer {
172
439
  );
173
440
  }
174
441
  }
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
+ */
175
450
  async importPageFile(file) {
176
451
  const module = this.pageModuleService.isMdxFile(file) ? await this.pageModuleService.importMdxPageFile(file) : await super.importPageFile(file);
177
452
  const { default: Page, getMetadata, config } = module;
@@ -184,6 +459,14 @@ class ReactRenderer extends IntegrationRenderer {
184
459
  config
185
460
  };
186
461
  }
462
+ /**
463
+ * Renders a full route response for the filesystem page pipeline.
464
+ *
465
+ * This path receives already-resolved route metadata, layout, locals, and HTML
466
+ * template instances from the shared renderer orchestration. Its main job is to
467
+ * serialize only the browser-safe page payload, compose the mixed React/non-
468
+ * React shell tree, and hand the result back as a document body.
469
+ */
187
470
  async render({
188
471
  params,
189
472
  query,
@@ -197,47 +480,63 @@ class ReactRenderer extends IntegrationRenderer {
197
480
  pageProps
198
481
  }) {
199
482
  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,
483
+ const safeLocals = this.getSerializableLocals(locals, Page.requires);
484
+ const allPageProps = this.buildSerializedPageProps({
485
+ pageProps,
205
486
  params,
206
487
  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
- );
488
+ safeLocals
489
+ });
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,
498
+ metadata,
499
+ pageProps: allPageProps,
500
+ contentNode,
501
+ contentHtml
502
+ });
219
503
  } catch (error) {
220
504
  throw this.createRenderError("Failed to render component", error);
221
505
  }
222
506
  }
507
+ getDocumentAttributes() {
508
+ return this.getRouterDocumentAttributes();
509
+ }
223
510
  /**
224
- * Safely extracts locals for client-side hydration.
511
+ * Safely extracts the declared subset of locals for client-side hydration.
225
512
  *
226
513
  * 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.
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.
229
517
  *
230
518
  * On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
231
519
  * to prevent accidental use. This method safely detects that case and returns
232
520
  * `undefined` instead of throwing.
233
521
  *
234
522
  * @param locals - The locals object from the render context
235
- * @returns The locals object if serializable, undefined otherwise
523
+ * @param requiredLocals - Keys explicitly requested for client hydration
524
+ * @returns The filtered locals object if serializable, undefined otherwise
236
525
  */
237
- getSerializableLocals(locals) {
526
+ getSerializableLocals(locals, requiredLocals) {
238
527
  try {
239
- if (locals && Object.keys(locals).length > 0) {
240
- return locals;
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;
241
540
  }
242
541
  return void 0;
243
542
  } catch (e) {
@@ -247,49 +546,54 @@ class ReactRenderer extends IntegrationRenderer {
247
546
  throw e;
248
547
  }
249
548
  }
549
+ /**
550
+ * Renders an arbitrary React view through the application's HTML shell.
551
+ *
552
+ * Unlike route rendering, this path starts from a single component rather than a
553
+ * page module discovered by the router. It still needs to resolve metadata,
554
+ * layout dependencies, and hydration assets so direct `ctx.render()` calls match
555
+ * normal page responses.
556
+ */
250
557
  async renderToResponse(view, props, ctx) {
251
558
  try {
252
559
  const viewConfig = view.config;
253
560
  const Layout = viewConfig?.layout;
254
- const ViewComponent = view;
255
- const pageElement = createElement(ViewComponent, props || {});
561
+ const ViewComponent = this.asReactComponent(view);
562
+ const normalizedProps = props ?? {};
256
563
  if (ctx.partial) {
257
- const stream = await renderToReadableStream(pageElement);
564
+ const stream = await renderToReadableStream(createElement(ViewComponent, normalizedProps));
258
565
  return this.createHtmlResponse(stream, ctx);
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
- );
570
+ await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
571
+ const { contentNode, contentHtml } = await this.composePageContent({
572
+ Page: ViewComponent,
573
+ Layout,
574
+ pageProps: normalizedProps
575
+ });
576
+ const body = await this.renderDocument({
577
+ HtmlTemplate,
578
+ metadata,
579
+ pageProps: normalizedProps,
580
+ contentNode,
581
+ contentHtml
582
+ });
287
583
  const transformedResponse = await this.htmlTransformer.transform(
288
- new Response(streamBody, {
584
+ new Response(body, {
289
585
  headers: { "Content-Type": "text/html" }
290
586
  })
291
587
  );
292
- return this.createHtmlResponse(transformedResponse.body ?? "", ctx);
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
+ return this.createHtmlResponse(transformedHtml, ctx);
293
597
  } catch (error) {
294
598
  throw this.createRenderError("Failed to render view", error);
295
599
  }