@ecopages/react 0.2.0-alpha.4 → 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 (44) hide show
  1. package/CHANGELOG.md +23 -37
  2. package/README.md +143 -17
  3. package/package.json +3 -3
  4. package/src/react-hmr-strategy.d.ts +22 -19
  5. package/src/react-hmr-strategy.js +57 -109
  6. package/src/react-hmr-strategy.ts +76 -134
  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 -25
  16. package/src/services/react-bundle.service.js +21 -91
  17. package/src/services/react-bundle.service.ts +22 -126
  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 +147 -9
  27. package/src/utils/client-graph-boundary-plugin.ts +252 -11
  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/reachability-analyzer.d.ts +12 -1
  32. package/src/utils/reachability-analyzer.js +101 -5
  33. package/src/utils/reachability-analyzer.ts +161 -8
  34. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  35. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  36. package/src/utils/react-dom-runtime-interop-plugin.ts +33 -0
  37. package/src/utils/react-mdx-loader-plugin.js +13 -5
  38. package/src/utils/react-mdx-loader-plugin.ts +28 -5
  39. package/src/utils/react-runtime-specifier-map.d.ts +6 -0
  40. package/src/utils/react-runtime-specifier-map.js +37 -0
  41. package/src/utils/react-runtime-specifier-map.ts +45 -0
  42. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  43. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  44. package/src/utils/use-sync-external-store-shim-plugin.ts +45 -0
@@ -4,11 +4,15 @@
4
4
  */
5
5
 
6
6
  import type {
7
+ DependencyAttributes,
7
8
  ComponentRenderInput,
8
9
  ComponentRenderResult,
9
10
  EcoComponent,
10
11
  EcoComponentConfig,
12
+ EcoHtmlComponent,
11
13
  EcoPageFile,
14
+ EcoPageLayoutComponent,
15
+ EcoPagesElement,
12
16
  HtmlTemplateProps,
13
17
  IntegrationRendererRenderOptions,
14
18
  PageMetadataProps,
@@ -18,8 +22,12 @@ import type {
18
22
  import { IntegrationRenderer, type RenderToResponseContext } from '@ecopages/core/route-renderer/integration-renderer';
19
23
  import { LocalsAccessError } from '@ecopages/core/errors/locals-access-error';
20
24
  import { RESOLVED_ASSETS_DIR } from '@ecopages/core/constants';
25
+ import { getAppBuildExecutor } from '@ecopages/core/build/build-adapter';
21
26
  import { rapidhash } from '@ecopages/core/hash';
22
27
  import type { ProcessedAsset } from '@ecopages/core/services/asset-processing-service';
28
+ import { AssetFactory, type AssetDefinition } from '@ecopages/core/services/asset-processing-service';
29
+ import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from '@ecopages/core/router/navigation-coordinator';
30
+ import path from 'node:path';
23
31
  import { createElement, type ReactNode } from 'react';
24
32
  import { renderToReadableStream, renderToString } from 'react-dom/server';
25
33
  import type { CompileOptions } from '@mdx-js/mdx';
@@ -35,6 +43,33 @@ type ReactComponentRenderContext = {
35
43
  componentInstanceId?: string;
36
44
  };
37
45
 
46
+ type SerializableProps = Record<string, unknown>;
47
+
48
+ type ReactRenderableComponent<P extends SerializableProps = SerializableProps> = React.FunctionComponent<P> & {
49
+ config?: EcoComponentConfig;
50
+ requires?: string | readonly string[];
51
+ };
52
+
53
+ type NonReactLayoutProps = {
54
+ children: string;
55
+ locals?: RequestLocals;
56
+ };
57
+
58
+ type NonReactHtmlTemplateProps = {
59
+ metadata: PageMetadataProps;
60
+ pageProps: HtmlTemplateProps['pageProps'];
61
+ children: string;
62
+ headContent?: string;
63
+ };
64
+
65
+ type ReactPageModule = EcoPageFile<{ config?: EcoComponentConfig }> & {
66
+ config?: EcoComponentConfig;
67
+ };
68
+
69
+ type RequiresAwareComponent = {
70
+ requires?: string | readonly string[];
71
+ };
72
+
38
73
  /**
39
74
  * Error thrown when an error occurs while rendering a React component.
40
75
  */
@@ -49,12 +84,12 @@ export class ReactRenderError extends Error {
49
84
  * Error thrown when an error occurs while bundling a React component.
50
85
  */
51
86
  export class BundleError extends Error {
52
- constructor(
53
- message: string,
54
- public readonly logs: string[],
55
- ) {
87
+ public readonly logs: string[];
88
+
89
+ constructor(message: string, logs: string[]) {
56
90
  super(message);
57
91
  this.name = 'BundleError';
92
+ this.logs = logs;
58
93
  }
59
94
  }
60
95
 
@@ -65,7 +100,6 @@ export class BundleError extends Error {
65
100
  export class ReactRenderer extends IntegrationRenderer<ReactNode> {
66
101
  name = PLUGIN_NAME;
67
102
  componentDirectory = RESOLVED_ASSETS_DIR;
68
- private componentRenderSequence = 0;
69
103
  static routerAdapter: ReactRouterAdapter | undefined;
70
104
  static mdxCompilerOptions: CompileOptions | undefined;
71
105
  static mdxExtensions: string[] = ['.mdx'];
@@ -102,6 +136,8 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
102
136
  this.pageModuleService = new ReactPageModuleService({
103
137
  rootDir: this.appConfig.rootDir,
104
138
  distDir: this.appConfig.absolutePaths.distDir,
139
+ workDir: this.appConfig.absolutePaths.workDir,
140
+ buildExecutor: getAppBuildExecutor(this.appConfig),
105
141
  layoutsDir: this.appConfig.absolutePaths.layoutsDir,
106
142
  componentsDir: this.appConfig.absolutePaths.componentsDir,
107
143
  mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
@@ -123,20 +159,244 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
123
159
  return false;
124
160
  }
125
161
 
162
+ /**
163
+ * Reads the declared integration name for a component or layout.
164
+ *
165
+ * We honor both the explicit `config.integration` override and injected
166
+ * `config.__eco.integration` metadata because pages can arrive here through
167
+ * authored config as well as build-time component metadata.
168
+ */
169
+ private getComponentIntegration(component?: { config?: EcoComponentConfig } | null): string | undefined {
170
+ return component?.config?.integration ?? component?.config?.__eco?.integration;
171
+ }
172
+
173
+ /**
174
+ * Returns whether a component should stay inside the React render lane.
175
+ *
176
+ * Components without explicit integration metadata are treated as React-owned
177
+ * here because this renderer only receives them after the route pipeline has
178
+ * already selected the React integration.
179
+ */
180
+ private isReactManagedComponent(component?: { config?: EcoComponentConfig } | null): boolean {
181
+ const integration = this.getComponentIntegration(component);
182
+ return integration === undefined || integration === this.name;
183
+ }
184
+
185
+ /**
186
+ * Creates the canonical page-props payload used by router hydration.
187
+ *
188
+ * React pages embedded in a non-React HTML shell still need to expose the same
189
+ * page-data contract as fully React-owned documents so navigation and hydration
190
+ * can read one marker consistently.
191
+ */
192
+ private buildRouterPageDataScript(pageProps: HtmlTemplateProps['pageProps'] | undefined): string {
193
+ const safeJson = JSON.stringify(pageProps || {}).replace(/</g, '\\u003c');
194
+ return `<script id="__ECO_PAGE_DATA__" type="application/json">${safeJson}</script>`;
195
+ }
196
+
197
+ private getRouterDocumentAttributes(): Record<string, string> | undefined {
198
+ if (!ReactRenderer.routerAdapter) {
199
+ return undefined;
200
+ }
201
+
202
+ return {
203
+ [ECO_DOCUMENT_OWNER_ATTRIBUTE]: 'react-router',
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Commits a framework-agnostic component to React semantics.
209
+ *
210
+ * This is one of the two real cast boundaries in this file. Core keeps
211
+ * `EcoComponent` broad so integrations can share the same public surface; once
212
+ * the React renderer is executing, `createElement()` needs a concrete React
213
+ * component signature.
214
+ */
215
+ private asReactComponent<P extends SerializableProps>(component: unknown): ReactRenderableComponent<P> {
216
+ return component as ReactRenderableComponent<P>;
217
+ }
218
+
219
+ /**
220
+ * Commits a mixed-shell component to the string-returning contract required by
221
+ * non-React layouts and HTML templates.
222
+ *
223
+ * This is the second real cast boundary: once we decide a shell is not managed
224
+ * by React, we call it directly and require serialized HTML back.
225
+ */
226
+ private asNonReactShellComponent<P extends SerializableProps>(
227
+ component: unknown,
228
+ ): (props: P) => EcoPagesElement | Promise<EcoPagesElement> {
229
+ return component as (props: P) => EcoPagesElement | Promise<EcoPagesElement>;
230
+ }
231
+
232
+ /**
233
+ * Builds the serialized page-props payload embedded into the final HTML.
234
+ *
235
+ * The document payload is intentionally narrower than the full server render
236
+ * input: only routing data, public page props, and explicitly allowed locals are
237
+ * exposed to the browser.
238
+ */
239
+ private buildSerializedPageProps(options: {
240
+ pageProps?: HtmlTemplateProps['pageProps'];
241
+ params: IntegrationRendererRenderOptions<ReactNode>['params'];
242
+ query: IntegrationRendererRenderOptions<ReactNode>['query'];
243
+ safeLocals?: RequestLocals;
244
+ }): HtmlTemplateProps['pageProps'] {
245
+ return {
246
+ ...options.pageProps,
247
+ params: options.params,
248
+ query: options.query,
249
+ ...(options.safeLocals && { locals: options.safeLocals }),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Appends route hydration assets for a concrete page/view file to the current
255
+ * HTML transformer state.
256
+ */
257
+ private async appendHydrationAssetsForFile(filePath?: string): Promise<void> {
258
+ if (!filePath) {
259
+ return;
260
+ }
261
+
262
+ const hydrationAssets = await this.buildRouteRenderAssets(filePath);
263
+ this.htmlTransformer.setProcessedDependencies([
264
+ ...this.htmlTransformer.getProcessedDependencies(),
265
+ ...hydrationAssets,
266
+ ]);
267
+ }
268
+
269
+ /**
270
+ * Resolves metadata for direct `renderToResponse()` calls.
271
+ *
272
+ * View rendering bypasses the normal route-file pipeline, so metadata has to be
273
+ * evaluated here from either the component-level generator or the application
274
+ * default.
275
+ */
276
+ private async resolveViewMetadata<P>(view: EcoComponent<P>, props: P): Promise<PageMetadataProps> {
277
+ return view.metadata
278
+ ? await view.metadata({
279
+ params: {},
280
+ query: {},
281
+ props,
282
+ appConfig: this.appConfig,
283
+ })
284
+ : this.appConfig.defaultMetadata;
285
+ }
286
+
287
+ /**
288
+ * Renders a non-React layout or HTML template and enforces that mixed shells
289
+ * return serialized HTML.
290
+ *
291
+ * The React renderer can compose through another integration's shell, but only
292
+ * if that shell yields a string that can be inserted into the final document.
293
+ */
294
+ private async renderNonReactShellComponent<P extends SerializableProps>(
295
+ Component: (props: P) => EcoPagesElement | Promise<EcoPagesElement>,
296
+ props: P,
297
+ label: 'Layout' | 'HtmlTemplate',
298
+ ): Promise<string> {
299
+ const output = await Component(props);
300
+ if (typeof output === 'string') {
301
+ return output;
302
+ }
303
+
304
+ throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
305
+ }
306
+
307
+ /**
308
+ * Produces the page body before the final HTML template is applied.
309
+ *
310
+ * This method owns the React/non-React layout split. React-managed layouts stay
311
+ * as React elements so they can stream normally; non-React layouts are rendered
312
+ * to HTML first and then passed through as serialized content.
313
+ */
314
+ private async composePageContent(options: {
315
+ Page: ReactRenderableComponent<SerializableProps>;
316
+ Layout?: EcoPageLayoutComponent<any>;
317
+ pageProps: SerializableProps;
318
+ locals?: RequestLocals;
319
+ }): Promise<{ contentNode: ReactNode; contentHtml: string }> {
320
+ const pageElement = createElement(options.Page, options.pageProps);
321
+ const pageHtml = renderToString(pageElement);
322
+ const layoutProps = options.locals ? { locals: options.locals } : {};
323
+
324
+ if (!options.Layout) {
325
+ return { contentNode: pageElement, contentHtml: pageHtml };
326
+ }
327
+
328
+ if (this.isReactManagedComponent(options.Layout)) {
329
+ const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
330
+ return {
331
+ contentNode: layoutElement,
332
+ contentHtml: renderToString(layoutElement),
333
+ };
334
+ }
335
+
336
+ const layoutHtml = await this.renderNonReactShellComponent(
337
+ this.asNonReactShellComponent<NonReactLayoutProps>(options.Layout),
338
+ { ...layoutProps, children: pageHtml },
339
+ 'Layout',
340
+ );
341
+
342
+ return { contentNode: layoutHtml, contentHtml: layoutHtml };
343
+ }
344
+
345
+ /**
346
+ * Wraps composed page content in the final document template.
347
+ *
348
+ * React-owned HTML templates stream directly. Non-React templates receive
349
+ * pre-rendered page HTML plus the canonical React page-data payload so the
350
+ * client runtime can recover page data after cross-integration handoff.
351
+ */
352
+ private async renderDocument(options: {
353
+ HtmlTemplate: EcoHtmlComponent<ReactNode>;
354
+ metadata: PageMetadataProps;
355
+ pageProps: HtmlTemplateProps['pageProps'];
356
+ contentNode: ReactNode;
357
+ contentHtml: string;
358
+ }): Promise<RouteRendererBody> {
359
+ if (this.isReactManagedComponent(options.HtmlTemplate)) {
360
+ return renderToReadableStream(
361
+ createElement(
362
+ this.asReactComponent(options.HtmlTemplate),
363
+ {
364
+ metadata: options.metadata,
365
+ pageProps: options.pageProps,
366
+ },
367
+ options.contentNode,
368
+ ),
369
+ );
370
+ }
371
+
372
+ const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : undefined;
373
+
374
+ return this.renderNonReactShellComponent(
375
+ this.asNonReactShellComponent<NonReactHtmlTemplateProps>(options.HtmlTemplate),
376
+ {
377
+ metadata: options.metadata,
378
+ pageProps: options.pageProps,
379
+ children: options.contentHtml,
380
+ headContent,
381
+ },
382
+ 'HtmlTemplate',
383
+ );
384
+ }
385
+
126
386
  /**
127
387
  * Renders a React component for component-level orchestration.
128
388
  *
129
389
  * Behavior:
130
390
  * - SSR always returns the component's own root HTML (no synthetic wrapper).
131
- * - For single-root output, a stable `data-eco-component-id` attribute is attached
132
- * to the root element so the client island runtime can target it directly.
133
- * - Island client scripts are emitted through `assets` and mounted independently.
391
+ * - When an explicit component instance id is provided, a stable
392
+ * `data-eco-component-id` attribute is attached so island hydration can target it.
393
+ * - Without an explicit instance id, component renders remain plain SSR output.
134
394
  *
135
395
  * This preserves DOM shape for global CSS/layout selectors while keeping a
136
396
  * deterministic mount target per component instance.
137
397
  */
138
398
  override async renderComponent(input: ComponentRenderInput): Promise<ComponentRenderResult> {
139
- const Component = input.component as unknown as React.FunctionComponent;
399
+ const Component = this.asReactComponent(input.component);
140
400
  const componentConfig = input.component.config;
141
401
  const element =
142
402
  input.children === undefined
@@ -151,17 +411,18 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
151
411
  let rootAttributes: Record<string, string> | undefined;
152
412
  let assets: ProcessedAsset[] | undefined;
153
413
 
154
- if (canAttachAttributes && componentFile && this.assetProcessingService) {
155
- const componentInstanceId =
156
- context.componentInstanceId ??
157
- `eco-component-${rapidhash(componentFile)}-${++this.componentRenderSequence}`;
414
+ if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService) {
415
+ const componentInstanceId = context.componentInstanceId;
158
416
  assets = await this.hydrationAssetService.buildComponentRenderAssets(
159
417
  componentFile,
160
418
  componentInstanceId,
161
419
  input.props,
162
420
  componentConfig,
163
421
  );
164
- rootAttributes = { 'data-eco-component-id': componentInstanceId };
422
+ rootAttributes = {
423
+ 'data-eco-component-id': componentInstanceId,
424
+ 'data-eco-props': btoa(JSON.stringify(input.props ?? {})),
425
+ };
165
426
  }
166
427
 
167
428
  return {
@@ -206,14 +467,113 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
206
467
  components.push({ config: configWithMeta });
207
468
  }
208
469
 
209
- return this.processComponentDependencies(components);
470
+ const processedDependencies = await this.processComponentDependencies(components);
471
+ const eagerSsrLazyDependencies = await this.processDeclaredMdxSsrLazyDependencies(components, pagePath);
472
+
473
+ return [...processedDependencies, ...eagerSsrLazyDependencies];
474
+ }
475
+
476
+ private async processDeclaredMdxSsrLazyDependencies(
477
+ components: Partial<EcoComponent>[],
478
+ pagePath: string,
479
+ ): Promise<ProcessedAsset[]> {
480
+ if (!this.assetProcessingService?.processDependencies) {
481
+ return [];
482
+ }
483
+
484
+ const dependencies = this.collectDeclaredMdxSsrLazyDependencies(components);
485
+ if (dependencies.length === 0) {
486
+ return [];
487
+ }
488
+
489
+ return this.assetProcessingService.processDependencies(dependencies, `react-mdx-ssr-lazy:${pagePath}`);
490
+ }
491
+
492
+ private collectDeclaredMdxSsrLazyDependencies(components: Partial<EcoComponent>[]): AssetDefinition[] {
493
+ const dependencies: AssetDefinition[] = [];
494
+ const visitedConfigs = new Set<EcoComponentConfig>();
495
+ const seenKeys = new Set<string>();
496
+
497
+ const normalizeAttributes = (attributes?: DependencyAttributes) => ({
498
+ type: 'module',
499
+ defer: '',
500
+ ...(attributes ?? {}),
501
+ });
502
+
503
+ const collect = (config?: EcoComponentConfig) => {
504
+ if (!config || visitedConfigs.has(config)) {
505
+ return;
506
+ }
507
+
508
+ visitedConfigs.add(config);
509
+
510
+ const componentFile = config.__eco?.file;
511
+ if (componentFile) {
512
+ const componentDir = path.dirname(componentFile);
513
+ for (const script of config.dependencies?.scripts ?? []) {
514
+ if (typeof script === 'string' || !script.lazy || script.ssr !== true) {
515
+ continue;
516
+ }
517
+
518
+ const attributes = normalizeAttributes(script.attributes);
519
+
520
+ if (script.content) {
521
+ const key = `content:${script.content}:${JSON.stringify(attributes)}`;
522
+ if (seenKeys.has(key)) {
523
+ continue;
524
+ }
525
+
526
+ seenKeys.add(key);
527
+ dependencies.push(
528
+ AssetFactory.createContentScript({
529
+ position: 'head',
530
+ content: script.content,
531
+ attributes,
532
+ }),
533
+ );
534
+ continue;
535
+ }
536
+
537
+ if (!script.src) {
538
+ continue;
539
+ }
540
+
541
+ const resolvedPath = path.resolve(componentDir, script.src);
542
+ const key = `file:${resolvedPath}:${JSON.stringify(attributes)}`;
543
+ if (seenKeys.has(key)) {
544
+ continue;
545
+ }
546
+
547
+ seenKeys.add(key);
548
+ dependencies.push(
549
+ AssetFactory.createFileScript({
550
+ filepath: resolvedPath,
551
+ position: 'head',
552
+ attributes,
553
+ }),
554
+ );
555
+ }
556
+ }
557
+
558
+ if (config.layout?.config) {
559
+ collect(config.layout.config);
560
+ }
561
+
562
+ for (const nestedComponent of config.dependencies?.components ?? []) {
563
+ collect(nestedComponent?.config);
564
+ }
565
+ };
566
+
567
+ for (const component of components) {
568
+ collect(component.config);
569
+ }
570
+
571
+ return dependencies;
210
572
  }
211
573
 
212
574
  override async buildRouteRenderAssets(pagePath: string): Promise<ProcessedAsset[]> {
213
575
  try {
214
- const pageModule = (await this.importPageFile(pagePath)) as EcoPageFile<{ config?: EcoComponentConfig }> & {
215
- config?: EcoComponentConfig;
216
- };
576
+ const pageModule = await this.importPageFile(pagePath);
217
577
  const shouldHydrate = ReactRenderer.explicitGraphEnabled
218
578
  ? true
219
579
  : this.pageModuleService.shouldHydratePage(pageModule);
@@ -246,14 +606,20 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
246
606
  }
247
607
  }
248
608
 
249
- protected override async importPageFile(file: string): Promise<EcoPageFile<{ config?: EcoComponentConfig }>> {
609
+ /**
610
+ * Imports a page module while normalizing React MDX modules to the same shape
611
+ * as ordinary React page files.
612
+ *
613
+ * MDX page imports can expose `config` separately from the default export. The
614
+ * React renderer reattaches that config to the page component so downstream
615
+ * layout, dependency, and hydration logic can treat MDX and TSX pages the same.
616
+ */
617
+ protected override async importPageFile(file: string): Promise<ReactPageModule> {
250
618
  const module = (
251
619
  this.pageModuleService.isMdxFile(file)
252
620
  ? await this.pageModuleService.importMdxPageFile(file)
253
621
  : await super.importPageFile(file)
254
- ) as EcoPageFile<{ config?: EcoComponentConfig }> & {
255
- config?: EcoComponentConfig;
256
- };
622
+ ) as ReactPageModule;
257
623
  const { default: Page, getMetadata, config } = module;
258
624
 
259
625
  if (this.pageModuleService.isMdxFile(file) && config) {
@@ -267,6 +633,14 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
267
633
  };
268
634
  }
269
635
 
636
+ /**
637
+ * Renders a full route response for the filesystem page pipeline.
638
+ *
639
+ * This path receives already-resolved route metadata, layout, locals, and HTML
640
+ * template instances from the shared renderer orchestration. Its main job is to
641
+ * serialize only the browser-safe page payload, compose the mixed React/non-
642
+ * React shell tree, and hand the result back as a document body.
643
+ */
270
644
  async render({
271
645
  params,
272
646
  query,
@@ -280,52 +654,79 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
280
654
  pageProps,
281
655
  }: IntegrationRendererRenderOptions<ReactNode>): Promise<RouteRendererBody> {
282
656
  try {
283
- const pageElement = createElement(Page, { params, query, ...props, locals: pageLocals });
284
- const contentElement = Layout
285
- ? createElement(Layout as React.FunctionComponent, { locals } as object, pageElement)
286
- : pageElement;
287
-
288
- const safeLocals = this.getSerializableLocals(locals as RequestLocals);
289
- const allPageProps: HtmlTemplateProps['pageProps'] = {
290
- ...pageProps,
657
+ const safeLocals = this.getSerializableLocals(locals, (Page as RequiresAwareComponent).requires);
658
+ const allPageProps = this.buildSerializedPageProps({
659
+ pageProps,
291
660
  params,
292
661
  query,
293
- ...(safeLocals && { locals: safeLocals }),
294
- };
295
-
296
- return await renderToReadableStream(
297
- createElement(
298
- HtmlTemplate,
299
- {
300
- metadata,
301
- pageProps: allPageProps,
302
- } as HtmlTemplateProps,
303
- contentElement,
304
- ),
305
- );
662
+ safeLocals,
663
+ });
664
+ const { contentNode, contentHtml } = await this.composePageContent({
665
+ Page: this.asReactComponent(Page),
666
+ Layout,
667
+ pageProps: { params, query, ...props, locals: pageLocals },
668
+ locals,
669
+ });
670
+
671
+ return await this.renderDocument({
672
+ HtmlTemplate,
673
+ metadata,
674
+ pageProps: allPageProps,
675
+ contentNode,
676
+ contentHtml,
677
+ });
306
678
  } catch (error) {
307
679
  throw this.createRenderError('Failed to render component', error);
308
680
  }
309
681
  }
310
682
 
683
+ protected override getDocumentAttributes(): Record<string, string> | undefined {
684
+ return this.getRouterDocumentAttributes();
685
+ }
686
+
311
687
  /**
312
- * Safely extracts locals for client-side hydration.
688
+ * Safely extracts the declared subset of locals for client-side hydration.
313
689
  *
314
690
  * On dynamic pages with `cache: 'dynamic'`, middleware populates `locals` with
315
- * request-scoped data (e.g., session). This data needs to be serialized to the
316
- * client for hydration to match the server-rendered output.
691
+ * request-scoped data (e.g., session). Only keys explicitly declared via
692
+ * `Page.requires` are serialized to the client so sensitive request-only data
693
+ * is not leaked into hydration payloads by default.
317
694
  *
318
695
  * On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
319
696
  * to prevent accidental use. This method safely detects that case and returns
320
697
  * `undefined` instead of throwing.
321
698
  *
322
699
  * @param locals - The locals object from the render context
323
- * @returns The locals object if serializable, undefined otherwise
700
+ * @param requiredLocals - Keys explicitly requested for client hydration
701
+ * @returns The filtered locals object if serializable, undefined otherwise
324
702
  */
325
- private getSerializableLocals(locals: RequestLocals): RequestLocals | undefined {
703
+ private getSerializableLocals(
704
+ locals: RequestLocals | undefined,
705
+ requiredLocals?: string | readonly string[],
706
+ ): RequestLocals | undefined {
326
707
  try {
327
- if (locals && Object.keys(locals).length > 0) {
328
- return locals;
708
+ if (!locals) {
709
+ return undefined;
710
+ }
711
+
712
+ const requiredKeys = requiredLocals
713
+ ? Array.isArray(requiredLocals)
714
+ ? requiredLocals
715
+ : [requiredLocals]
716
+ : [];
717
+
718
+ if (requiredKeys.length === 0) {
719
+ return undefined;
720
+ }
721
+
722
+ const serializedLocals = Object.fromEntries(
723
+ requiredKeys
724
+ .filter((key) => Object.prototype.hasOwnProperty.call(locals, key))
725
+ .map((key) => [key, locals[key as keyof RequestLocals]]),
726
+ ) as RequestLocals;
727
+
728
+ if (Object.keys(serializedLocals).length > 0) {
729
+ return serializedLocals;
329
730
  }
330
731
  return undefined;
331
732
  } catch (e) {
@@ -336,6 +737,14 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
336
737
  }
337
738
  }
338
739
 
740
+ /**
741
+ * Renders an arbitrary React view through the application's HTML shell.
742
+ *
743
+ * Unlike route rendering, this path starts from a single component rather than a
744
+ * page module discovered by the router. It still needs to resolve metadata,
745
+ * layout dependencies, and hydration assets so direct `ctx.render()` calls match
746
+ * normal page responses.
747
+ */
339
748
  async renderToResponse<P = Record<string, unknown>>(
340
749
  view: EcoComponent<P>,
341
750
  props: P,
@@ -343,59 +752,50 @@ export class ReactRenderer extends IntegrationRenderer<ReactNode> {
343
752
  ): Promise<Response> {
344
753
  try {
345
754
  const viewConfig = view.config;
346
- const Layout = viewConfig?.layout as React.FunctionComponent | undefined;
347
-
348
- const ViewComponent = view as unknown as React.FunctionComponent;
349
- const pageElement = createElement(ViewComponent, props || {});
755
+ const Layout = viewConfig?.layout;
756
+ const ViewComponent = this.asReactComponent(view);
757
+ const normalizedProps = (props ?? {}) as SerializableProps;
350
758
 
351
759
  if (ctx.partial) {
352
- const stream = await renderToReadableStream(pageElement);
760
+ const stream = await renderToReadableStream(createElement(ViewComponent, normalizedProps));
353
761
  return this.createHtmlResponse(stream, ctx);
354
762
  }
355
763
 
356
- const contentElement = Layout
357
- ? createElement(Layout as React.FunctionComponent, {}, pageElement)
358
- : pageElement;
359
-
360
764
  const HtmlTemplate = await this.getHtmlTemplate();
361
- const metadata: PageMetadataProps = view.metadata
362
- ? await view.metadata({
363
- params: {},
364
- query: {},
365
- props,
366
- appConfig: this.appConfig,
367
- })
368
- : this.appConfig.defaultMetadata;
369
-
370
- await this.prepareViewDependencies(view, Layout as unknown as EcoComponent | undefined);
371
-
372
- const viewFilePath = viewConfig?.__eco?.file;
373
- if (viewFilePath) {
374
- const hydrationAssets = await this.buildRouteRenderAssets(viewFilePath);
375
- this.htmlTransformer.setProcessedDependencies([
376
- ...this.htmlTransformer.getProcessedDependencies(),
377
- ...hydrationAssets,
378
- ]);
379
- }
765
+ const metadata = await this.resolveViewMetadata(view, props);
380
766
 
381
- const streamBody = await renderToReadableStream(
382
- createElement(
383
- HtmlTemplate,
384
- {
385
- metadata,
386
- pageProps: props,
387
- } as HtmlTemplateProps,
388
- contentElement,
389
- ),
390
- );
767
+ await this.prepareViewDependencies(view, Layout);
768
+ await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
769
+
770
+ const { contentNode, contentHtml } = await this.composePageContent({
771
+ Page: ViewComponent,
772
+ Layout,
773
+ pageProps: normalizedProps,
774
+ });
775
+
776
+ const body = await this.renderDocument({
777
+ HtmlTemplate,
778
+ metadata,
779
+ pageProps: normalizedProps,
780
+ contentNode,
781
+ contentHtml,
782
+ });
391
783
 
392
784
  const transformedResponse = await this.htmlTransformer.transform(
393
- new Response(streamBody, {
785
+ new Response(body as BodyInit, {
394
786
  headers: { 'Content-Type': 'text/html' },
395
787
  }),
396
788
  );
789
+ let transformedHtml = await transformedResponse.text();
790
+ const documentAttributes = this.getRouterDocumentAttributes();
791
+ if (documentAttributes) {
792
+ transformedHtml = this.htmlTransformer.applyAttributesToHtmlElement(
793
+ transformedHtml,
794
+ documentAttributes,
795
+ );
796
+ }
397
797
 
398
- return this.createHtmlResponse(transformedResponse.body ?? '', ctx);
798
+ return this.createHtmlResponse(transformedHtml, ctx);
399
799
  } catch (error) {
400
800
  throw this.createRenderError('Failed to render view', error);
401
801
  }