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

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 (50) hide show
  1. package/CHANGELOG.md +9 -43
  2. package/README.md +143 -17
  3. package/package.json +3 -3
  4. package/src/react-hmr-strategy.d.ts +25 -21
  5. package/src/react-hmr-strategy.js +78 -110
  6. package/src/react-renderer.d.ts +135 -12
  7. package/src/react-renderer.js +439 -82
  8. package/src/react.plugin.d.ts +17 -5
  9. package/src/react.plugin.js +45 -13
  10. package/src/router-adapter.d.ts +2 -2
  11. package/src/services/react-bundle.service.d.ts +4 -25
  12. package/src/services/react-bundle.service.js +37 -91
  13. package/src/services/react-hydration-asset.service.js +3 -3
  14. package/src/services/react-page-module.service.d.ts +3 -0
  15. package/src/services/react-page-module.service.js +24 -17
  16. package/src/services/react-runtime-bundle.service.d.ts +12 -12
  17. package/src/services/react-runtime-bundle.service.js +98 -180
  18. package/src/utils/client-graph-boundary-plugin.js +149 -11
  19. package/src/utils/declared-modules.js +4 -1
  20. package/src/utils/foreign-jsx-override-plugin.d.ts +19 -0
  21. package/src/utils/foreign-jsx-override-plugin.js +43 -0
  22. package/src/utils/hydration-scripts.d.ts +18 -1
  23. package/src/utils/hydration-scripts.js +95 -37
  24. package/src/utils/reachability-analyzer.d.ts +12 -1
  25. package/src/utils/reachability-analyzer.js +101 -5
  26. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  27. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  28. package/src/utils/react-mdx-loader-plugin.js +13 -5
  29. package/src/utils/react-runtime-specifier-map.d.ts +6 -0
  30. package/src/utils/react-runtime-specifier-map.js +37 -0
  31. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  32. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  33. package/src/react-hmr-strategy.ts +0 -444
  34. package/src/react-renderer.ts +0 -403
  35. package/src/react.plugin.ts +0 -241
  36. package/src/router-adapter.ts +0 -95
  37. package/src/services/react-bundle.service.ts +0 -212
  38. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  39. package/src/services/react-hydration-asset.service.ts +0 -260
  40. package/src/services/react-page-module.service.ts +0 -214
  41. package/src/services/react-runtime-bundle.service.ts +0 -271
  42. package/src/utils/client-graph-boundary-plugin.ts +0 -590
  43. package/src/utils/client-only.ts +0 -27
  44. package/src/utils/declared-modules.ts +0 -99
  45. package/src/utils/dynamic.ts +0 -27
  46. package/src/utils/hmr-scripts.ts +0 -47
  47. package/src/utils/html-boundary.ts +0 -66
  48. package/src/utils/hydration-scripts.ts +0 -338
  49. package/src/utils/reachability-analyzer.ts +0 -440
  50. package/src/utils/react-mdx-loader-plugin.ts +0 -40
@@ -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";
@@ -10,6 +14,21 @@ import { ReactBundleService } from "./services/react-bundle.service.js";
10
14
  import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
11
15
  import { ReactPageModuleService } from "./services/react-page-module.service.js";
12
16
  import { ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
17
+ function decodeHtmlEntities(value) {
18
+ let decoded = value;
19
+ let previous;
20
+ do {
21
+ previous = decoded;
22
+ decoded = decoded.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&#x27;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
23
+ } while (decoded !== previous);
24
+ return decoded;
25
+ }
26
+ function restoreEscapedComponentMarkers(html) {
27
+ return html.replace(
28
+ /&(?:amp;)?lt;eco-marker\b[\s\S]*?&(?:amp;)?gt;&(?:amp;)?lt;\/eco-marker&(?:amp;)?gt;/g,
29
+ (marker) => decodeHtmlEntities(marker)
30
+ );
31
+ }
13
32
  class ReactRenderError extends Error {
14
33
  constructor(message) {
15
34
  super(message);
@@ -17,16 +36,16 @@ class ReactRenderError extends Error {
17
36
  }
18
37
  }
19
38
  class BundleError extends Error {
39
+ logs;
20
40
  constructor(message, logs) {
21
41
  super(message);
22
- this.logs = logs;
23
42
  this.name = "BundleError";
43
+ this.logs = logs;
24
44
  }
25
45
  }
26
46
  class ReactRenderer extends IntegrationRenderer {
27
47
  name = PLUGIN_NAME;
28
48
  componentDirectory = RESOLVED_ASSETS_DIR;
29
- componentRenderSequence = 0;
30
49
  static routerAdapter;
31
50
  static mdxCompilerOptions;
32
51
  static mdxExtensions = [".mdx"];
@@ -49,11 +68,15 @@ class ReactRenderer extends IntegrationRenderer {
49
68
  this.bundleService = new ReactBundleService({
50
69
  rootDir: this.appConfig.rootDir,
51
70
  routerAdapter: ReactRenderer.routerAdapter,
52
- mdxCompilerOptions: ReactRenderer.mdxCompilerOptions
71
+ mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
72
+ jsxImportSource: (this.appConfig.integrations ?? []).find((integration) => integration.name === this.name)?.jsxImportSource,
73
+ nonReactExtensions: (this.appConfig.integrations ?? []).filter((integration) => integration.name !== this.name).flatMap((integration) => integration.extensions)
53
74
  });
54
75
  this.pageModuleService = new ReactPageModuleService({
55
76
  rootDir: this.appConfig.rootDir,
56
77
  distDir: this.appConfig.absolutePaths.distDir,
78
+ workDir: this.appConfig.absolutePaths.workDir,
79
+ buildExecutor: getAppBuildExecutor(this.appConfig),
57
80
  layoutsDir: this.appConfig.absolutePaths.layoutsDir,
58
81
  componentsDir: this.appConfig.absolutePaths.componentsDir,
59
82
  mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
@@ -72,38 +95,256 @@ class ReactRenderer extends IntegrationRenderer {
72
95
  shouldRenderPageComponent() {
73
96
  return false;
74
97
  }
98
+ /**
99
+ * Reads the declared integration name for a component or layout.
100
+ *
101
+ * We honor both the explicit `config.integration` override and injected
102
+ * `config.__eco.integration` metadata because pages can arrive here through
103
+ * authored config as well as build-time component metadata.
104
+ */
105
+ getComponentIntegration(component) {
106
+ return component?.config?.integration ?? component?.config?.__eco?.integration;
107
+ }
108
+ /**
109
+ * Returns whether a component should stay inside the React render lane.
110
+ *
111
+ * Components without explicit integration metadata are treated as React-owned
112
+ * here because this renderer only receives them after the route pipeline has
113
+ * already selected the React integration.
114
+ */
115
+ isReactManagedComponent(component) {
116
+ const integration = this.getComponentIntegration(component);
117
+ return integration === void 0 || integration === this.name;
118
+ }
119
+ /**
120
+ * Creates the canonical page-props payload used by router hydration.
121
+ *
122
+ * React pages embedded in a non-React HTML shell still need to expose the same
123
+ * page-data contract as fully React-owned documents so navigation and hydration
124
+ * can read one marker consistently.
125
+ */
126
+ buildRouterPageDataScript(pageProps) {
127
+ const safeJson = JSON.stringify(pageProps || {}).replace(/</g, "\\u003c");
128
+ return `<script id="__ECO_PAGE_DATA__" type="application/json">${safeJson}<\/script>`;
129
+ }
130
+ getRouterDocumentAttributes() {
131
+ if (!ReactRenderer.routerAdapter) {
132
+ return void 0;
133
+ }
134
+ return {
135
+ [ECO_DOCUMENT_OWNER_ATTRIBUTE]: "react-router"
136
+ };
137
+ }
138
+ /**
139
+ * Commits a framework-agnostic component to React semantics.
140
+ *
141
+ * This is one of the two real cast boundaries in this file. Core keeps
142
+ * `EcoComponent` broad so integrations can share the same public surface; once
143
+ * the React renderer is executing, `createElement()` needs a concrete React
144
+ * component signature.
145
+ */
146
+ asReactComponent(component) {
147
+ return component;
148
+ }
149
+ /**
150
+ * Commits a mixed-shell component to the string-returning contract required by
151
+ * non-React layouts and HTML templates.
152
+ *
153
+ * This is the second real cast boundary: once we decide a shell is not managed
154
+ * by React, we call it directly and require serialized HTML back.
155
+ */
156
+ asNonReactShellComponent(component) {
157
+ return component;
158
+ }
159
+ /**
160
+ * Builds the serialized page-props payload embedded into the final HTML.
161
+ *
162
+ * The document payload is intentionally narrower than the full server render
163
+ * input: only routing data, public page props, and explicitly allowed locals are
164
+ * exposed to the browser.
165
+ */
166
+ buildSerializedPageProps(options) {
167
+ return {
168
+ ...options.pageProps,
169
+ params: options.params,
170
+ query: options.query,
171
+ ...options.safeLocals && { locals: options.safeLocals }
172
+ };
173
+ }
174
+ /**
175
+ * Appends route hydration assets for a concrete page/view file to the current
176
+ * HTML transformer state.
177
+ */
178
+ async appendHydrationAssetsForFile(filePath) {
179
+ if (!filePath) {
180
+ return;
181
+ }
182
+ const hydrationAssets = await this.buildRouteRenderAssets(filePath);
183
+ this.htmlTransformer.setProcessedDependencies([
184
+ ...this.htmlTransformer.getProcessedDependencies(),
185
+ ...hydrationAssets
186
+ ]);
187
+ }
188
+ /**
189
+ * Resolves metadata for direct `renderToResponse()` calls.
190
+ *
191
+ * View rendering bypasses the normal route-file pipeline, so metadata has to be
192
+ * evaluated here from either the component-level generator or the application
193
+ * default.
194
+ */
195
+ async resolveViewMetadata(view, props) {
196
+ return view.metadata ? await view.metadata({
197
+ params: {},
198
+ query: {},
199
+ props,
200
+ appConfig: this.appConfig
201
+ }) : this.appConfig.defaultMetadata;
202
+ }
203
+ /**
204
+ * Renders a non-React layout or HTML template and enforces that mixed shells
205
+ * return serialized HTML.
206
+ *
207
+ * The React renderer can compose through another integration's shell, but only
208
+ * if that shell yields a string that can be inserted into the final document.
209
+ */
210
+ async renderNonReactShellComponent(Component, props, label) {
211
+ const output = await Component(props);
212
+ if (typeof output === "string") {
213
+ return output;
214
+ }
215
+ throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
216
+ }
217
+ /**
218
+ * Renders one React component boundary for marker-graph orchestration.
219
+ *
220
+ * When the marker resolver has already stitched child HTML for this boundary,
221
+ * the child payload must remain raw SSR output rather than a React string
222
+ * child, otherwise React would escape it. This helper renders a unique token
223
+ * through React and swaps that token back to the stitched HTML afterward.
224
+ *
225
+ * @param input Component render input reconstructed from marker metadata.
226
+ * @param context React-specific render context for stable token generation.
227
+ * @returns Serialized component HTML with stitched child markup preserved.
228
+ */
229
+ renderComponentHtml(input, context) {
230
+ if (input.children === void 0) {
231
+ return restoreEscapedComponentMarkers(
232
+ renderToString(createElement(this.asReactComponent(input.component), input.props))
233
+ );
234
+ }
235
+ const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
236
+ const html = renderToString(
237
+ createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
238
+ );
239
+ return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(input.children));
240
+ }
241
+ buildHydrationProps(props) {
242
+ if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
243
+ return props ?? {};
244
+ }
245
+ const { locals: _locals, ...hydrationProps } = props;
246
+ return hydrationProps;
247
+ }
248
+ /**
249
+ * Produces the page body before the final HTML template is applied.
250
+ *
251
+ * This method owns the React/non-React layout split. React-managed layouts stay
252
+ * as React elements so they can stream normally; non-React layouts are rendered
253
+ * to HTML first and then passed through as serialized content.
254
+ */
255
+ async composePageContent(options) {
256
+ const pageElement = createElement(options.Page, options.pageProps);
257
+ const pageHtml = restoreEscapedComponentMarkers(renderToString(pageElement));
258
+ const layoutProps = options.locals ? { locals: options.locals } : {};
259
+ if (!options.Layout) {
260
+ return { contentNode: pageElement, contentHtml: pageHtml };
261
+ }
262
+ if (this.isReactManagedComponent(options.Layout)) {
263
+ const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
264
+ return {
265
+ contentNode: layoutElement,
266
+ contentHtml: restoreEscapedComponentMarkers(renderToString(layoutElement))
267
+ };
268
+ }
269
+ const layoutHtml = await this.renderNonReactShellComponent(
270
+ this.asNonReactShellComponent(options.Layout),
271
+ { ...layoutProps, children: pageHtml },
272
+ "Layout"
273
+ );
274
+ return { contentNode: layoutHtml, contentHtml: layoutHtml };
275
+ }
276
+ /**
277
+ * Wraps composed page content in the final document template.
278
+ *
279
+ * React-owned HTML templates stream directly. Non-React templates receive
280
+ * pre-rendered page HTML plus the canonical React page-data payload so the
281
+ * client runtime can recover page data after cross-integration handoff.
282
+ */
283
+ async renderDocument(options) {
284
+ if (this.isReactManagedComponent(options.HtmlTemplate)) {
285
+ const rawChildrenToken = "__ECO_RAW_HTML_DOCUMENT_CHILD__";
286
+ const html = restoreEscapedComponentMarkers(
287
+ renderToString(
288
+ createElement(
289
+ this.asReactComponent(options.HtmlTemplate),
290
+ {
291
+ metadata: options.metadata,
292
+ pageProps: options.pageProps
293
+ },
294
+ rawChildrenToken
295
+ )
296
+ )
297
+ );
298
+ return html.split(rawChildrenToken).join(options.contentHtml);
299
+ }
300
+ const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
301
+ return this.renderNonReactShellComponent(
302
+ this.asNonReactShellComponent(options.HtmlTemplate),
303
+ {
304
+ metadata: options.metadata,
305
+ pageProps: options.pageProps,
306
+ children: options.contentHtml,
307
+ headContent
308
+ },
309
+ "HtmlTemplate"
310
+ );
311
+ }
75
312
  /**
76
313
  * Renders a React component for component-level orchestration.
77
314
  *
78
315
  * Behavior:
79
316
  * - 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.
317
+ * - When an explicit component instance id is provided, a stable
318
+ * `data-eco-component-id` attribute is attached so island hydration can target it.
319
+ * - Without an explicit instance id, component renders remain plain SSR output.
320
+ * - When stitched child HTML is provided, that boundary is treated as a pure SSR
321
+ * composition step and does not emit hydration assets for the parent wrapper.
83
322
  *
84
323
  * This preserves DOM shape for global CSS/layout selectors while keeping a
85
324
  * deterministic mount target per component instance.
86
325
  */
87
326
  async renderComponent(input) {
88
- const Component = input.component;
89
327
  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);
328
+ const context = input.integrationContext ?? {};
329
+ const hasResolvedChildHtml = input.children !== void 0;
330
+ let html = this.renderComponentHtml(input, context);
92
331
  let canAttachAttributes = hasSingleRootElement(html);
93
332
  let rootTag = this.getRootTagName(html);
94
333
  const componentFile = componentConfig?.__eco?.file;
95
- const context = input.integrationContext ?? {};
96
334
  let rootAttributes;
97
335
  let assets;
98
- if (canAttachAttributes && componentFile && this.assetProcessingService) {
99
- const componentInstanceId = context.componentInstanceId ?? `eco-component-${rapidhash(componentFile)}-${++this.componentRenderSequence}`;
336
+ if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
337
+ const componentInstanceId = context.componentInstanceId;
100
338
  assets = await this.hydrationAssetService.buildComponentRenderAssets(
101
339
  componentFile,
102
340
  componentInstanceId,
103
- input.props,
341
+ this.buildHydrationProps(input.props),
104
342
  componentConfig
105
343
  );
106
- rootAttributes = { "data-eco-component-id": componentInstanceId };
344
+ rootAttributes = {
345
+ "data-eco-component-id": componentInstanceId,
346
+ "data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
347
+ };
107
348
  }
108
349
  return {
109
350
  html,
@@ -122,13 +363,33 @@ class ReactRenderer extends IntegrationRenderer {
122
363
  isMdxFile(filePath) {
123
364
  return this.pageModuleService.isMdxFile(filePath);
124
365
  }
366
+ usesIntegrationPageImporter(file) {
367
+ return this.pageModuleService.isMdxFile(file);
368
+ }
369
+ async importIntegrationPageFile(file) {
370
+ return await this.pageModuleService.importMdxPageFile(file);
371
+ }
372
+ normalizeImportedPageFile(file, pageModule) {
373
+ const reactModule = pageModule;
374
+ const { default: Page, getMetadata, config } = reactModule;
375
+ if (this.pageModuleService.isMdxFile(file) && config) {
376
+ Page.config = config;
377
+ }
378
+ return {
379
+ ...pageModule,
380
+ default: Page,
381
+ getMetadata,
382
+ config
383
+ };
384
+ }
125
385
  /**
126
386
  * Processes MDX-specific configuration dependencies including layout dependencies.
127
387
  * @param pagePath - Absolute path to the MDX page file
128
388
  * @returns Processed assets for MDX configuration dependencies
129
389
  */
130
390
  async processMdxConfigDependencies(pagePath) {
131
- const { config } = await this.importPageFile(pagePath);
391
+ const pageModule = await this.importPageFile(pagePath);
392
+ const config = pageModule.config;
132
393
  const resolvedLayout = config?.layout;
133
394
  const components = [];
134
395
  if (resolvedLayout?.config?.dependencies) {
@@ -142,7 +403,86 @@ class ReactRenderer extends IntegrationRenderer {
142
403
  };
143
404
  components.push({ config: configWithMeta });
144
405
  }
145
- return this.processComponentDependencies(components);
406
+ const processedDependencies = await this.processComponentDependencies(components);
407
+ const eagerSsrLazyDependencies = await this.processDeclaredMdxSsrLazyDependencies(components, pagePath);
408
+ return [...processedDependencies, ...eagerSsrLazyDependencies];
409
+ }
410
+ async processDeclaredMdxSsrLazyDependencies(components, pagePath) {
411
+ if (!this.assetProcessingService?.processDependencies) {
412
+ return [];
413
+ }
414
+ const dependencies = this.collectDeclaredMdxSsrLazyDependencies(components);
415
+ if (dependencies.length === 0) {
416
+ return [];
417
+ }
418
+ return this.assetProcessingService.processDependencies(dependencies, `react-mdx-ssr-lazy:${pagePath}`);
419
+ }
420
+ collectDeclaredMdxSsrLazyDependencies(components) {
421
+ const dependencies = [];
422
+ const visitedConfigs = /* @__PURE__ */ new Set();
423
+ const seenKeys = /* @__PURE__ */ new Set();
424
+ const normalizeAttributes = (attributes) => ({
425
+ type: "module",
426
+ defer: "",
427
+ ...attributes ?? {}
428
+ });
429
+ const collect = (config) => {
430
+ if (!config || visitedConfigs.has(config)) {
431
+ return;
432
+ }
433
+ visitedConfigs.add(config);
434
+ const componentFile = config.__eco?.file;
435
+ if (componentFile) {
436
+ const componentDir = path.dirname(componentFile);
437
+ for (const script of config.dependencies?.scripts ?? []) {
438
+ if (typeof script === "string" || !script.lazy || script.ssr !== true) {
439
+ continue;
440
+ }
441
+ const attributes = normalizeAttributes(script.attributes);
442
+ if (script.content) {
443
+ const key2 = `content:${script.content}:${JSON.stringify(attributes)}`;
444
+ if (seenKeys.has(key2)) {
445
+ continue;
446
+ }
447
+ seenKeys.add(key2);
448
+ dependencies.push(
449
+ AssetFactory.createContentScript({
450
+ position: "head",
451
+ content: script.content,
452
+ attributes
453
+ })
454
+ );
455
+ continue;
456
+ }
457
+ if (!script.src) {
458
+ continue;
459
+ }
460
+ const resolvedPath = path.resolve(componentDir, script.src);
461
+ const key = `file:${resolvedPath}:${JSON.stringify(attributes)}`;
462
+ if (seenKeys.has(key)) {
463
+ continue;
464
+ }
465
+ seenKeys.add(key);
466
+ dependencies.push(
467
+ AssetFactory.createFileScript({
468
+ filepath: resolvedPath,
469
+ position: "head",
470
+ attributes
471
+ })
472
+ );
473
+ }
474
+ }
475
+ if (config.layout?.config) {
476
+ collect(config.layout.config);
477
+ }
478
+ for (const nestedComponent of config.dependencies?.components ?? []) {
479
+ collect(nestedComponent?.config);
480
+ }
481
+ };
482
+ for (const component of components) {
483
+ collect(component.config);
484
+ }
485
+ return dependencies;
146
486
  }
147
487
  async buildRouteRenderAssets(pagePath) {
148
488
  try {
@@ -172,18 +512,14 @@ class ReactRenderer extends IntegrationRenderer {
172
512
  );
173
513
  }
174
514
  }
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
- }
515
+ /**
516
+ * Renders a full route response for the filesystem page pipeline.
517
+ *
518
+ * This path receives already-resolved route metadata, layout, locals, and HTML
519
+ * template instances from the shared renderer orchestration. Its main job is to
520
+ * serialize only the browser-safe page payload, compose the mixed React/non-
521
+ * React shell tree, and hand the result back as a document body.
522
+ */
187
523
  async render({
188
524
  params,
189
525
  query,
@@ -197,47 +533,63 @@ class ReactRenderer extends IntegrationRenderer {
197
533
  pageProps
198
534
  }) {
199
535
  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,
536
+ const safeLocals = this.getSerializableLocals(locals, Page.requires);
537
+ const allPageProps = this.buildSerializedPageProps({
538
+ pageProps,
205
539
  params,
206
540
  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
- );
541
+ safeLocals
542
+ });
543
+ const { contentNode, contentHtml } = await this.composePageContent({
544
+ Page: this.asReactComponent(Page),
545
+ Layout,
546
+ pageProps: { params, query, ...props, locals: pageLocals },
547
+ locals
548
+ });
549
+ return await this.renderDocument({
550
+ HtmlTemplate,
551
+ metadata,
552
+ pageProps: allPageProps,
553
+ contentNode,
554
+ contentHtml
555
+ });
219
556
  } catch (error) {
220
557
  throw this.createRenderError("Failed to render component", error);
221
558
  }
222
559
  }
560
+ getDocumentAttributes() {
561
+ return this.getRouterDocumentAttributes();
562
+ }
223
563
  /**
224
- * Safely extracts locals for client-side hydration.
564
+ * Safely extracts the declared subset of locals for client-side hydration.
225
565
  *
226
566
  * 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.
567
+ * request-scoped data (e.g., session). Only keys explicitly declared via
568
+ * `Page.requires` are serialized to the client so sensitive request-only data
569
+ * is not leaked into hydration payloads by default.
229
570
  *
230
571
  * On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
231
572
  * to prevent accidental use. This method safely detects that case and returns
232
573
  * `undefined` instead of throwing.
233
574
  *
234
575
  * @param locals - The locals object from the render context
235
- * @returns The locals object if serializable, undefined otherwise
576
+ * @param requiredLocals - Keys explicitly requested for client hydration
577
+ * @returns The filtered locals object if serializable, undefined otherwise
236
578
  */
237
- getSerializableLocals(locals) {
579
+ getSerializableLocals(locals, requiredLocals) {
238
580
  try {
239
- if (locals && Object.keys(locals).length > 0) {
240
- return locals;
581
+ if (!locals) {
582
+ return void 0;
583
+ }
584
+ const requiredKeys = requiredLocals ? Array.isArray(requiredLocals) ? requiredLocals : [requiredLocals] : [];
585
+ if (requiredKeys.length === 0) {
586
+ return void 0;
587
+ }
588
+ const serializedLocals = Object.fromEntries(
589
+ requiredKeys.filter((key) => Object.prototype.hasOwnProperty.call(locals, key)).map((key) => [key, locals[key]])
590
+ );
591
+ if (Object.keys(serializedLocals).length > 0) {
592
+ return serializedLocals;
241
593
  }
242
594
  return void 0;
243
595
  } catch (e) {
@@ -247,49 +599,54 @@ class ReactRenderer extends IntegrationRenderer {
247
599
  throw e;
248
600
  }
249
601
  }
602
+ /**
603
+ * Renders an arbitrary React view through the application's HTML shell.
604
+ *
605
+ * Unlike route rendering, this path starts from a single component rather than a
606
+ * page module discovered by the router. It still needs to resolve metadata,
607
+ * layout dependencies, and hydration assets so direct `ctx.render()` calls match
608
+ * normal page responses.
609
+ */
250
610
  async renderToResponse(view, props, ctx) {
251
611
  try {
252
612
  const viewConfig = view.config;
253
613
  const Layout = viewConfig?.layout;
254
- const ViewComponent = view;
255
- const pageElement = createElement(ViewComponent, props || {});
614
+ const ViewComponent = this.asReactComponent(view);
615
+ const normalizedProps = props ?? {};
256
616
  if (ctx.partial) {
257
- const stream = await renderToReadableStream(pageElement);
617
+ const stream = await renderToReadableStream(createElement(ViewComponent, normalizedProps));
258
618
  return this.createHtmlResponse(stream, ctx);
259
619
  }
260
- const contentElement = Layout ? createElement(Layout, {}, pageElement) : pageElement;
261
620
  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;
621
+ const metadata = await this.resolveViewMetadata(view, props);
268
622
  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
- );
623
+ await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
624
+ const { contentNode, contentHtml } = await this.composePageContent({
625
+ Page: ViewComponent,
626
+ Layout,
627
+ pageProps: normalizedProps
628
+ });
629
+ const body = await this.renderDocument({
630
+ HtmlTemplate,
631
+ metadata,
632
+ pageProps: normalizedProps,
633
+ contentNode,
634
+ contentHtml
635
+ });
287
636
  const transformedResponse = await this.htmlTransformer.transform(
288
- new Response(streamBody, {
637
+ new Response(body, {
289
638
  headers: { "Content-Type": "text/html" }
290
639
  })
291
640
  );
292
- return this.createHtmlResponse(transformedResponse.body ?? "", ctx);
641
+ let transformedHtml = await transformedResponse.text();
642
+ const documentAttributes = this.getRouterDocumentAttributes();
643
+ if (documentAttributes) {
644
+ transformedHtml = this.htmlTransformer.applyAttributesToHtmlElement(
645
+ transformedHtml,
646
+ documentAttributes
647
+ );
648
+ }
649
+ return this.createHtmlResponse(transformedHtml, ctx);
293
650
  } catch (error) {
294
651
  throw this.createRenderError("Failed to render view", error);
295
652
  }