@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.
- package/CHANGELOG.md +22 -41
- package/README.md +135 -29
- package/package.json +3 -3
- package/src/react-hmr-strategy.d.ts +22 -30
- package/src/react-hmr-strategy.js +57 -120
- package/src/react-hmr-strategy.ts +76 -145
- package/src/react-renderer.d.ts +130 -11
- package/src/react-renderer.js +368 -64
- package/src/react-renderer.ts +490 -90
- package/src/react.plugin.d.ts +17 -5
- package/src/react.plugin.js +44 -13
- package/src/react.plugin.ts +49 -14
- package/src/router-adapter.d.ts +2 -2
- package/src/router-adapter.ts +2 -2
- package/src/services/react-bundle.service.d.ts +2 -30
- package/src/services/react-bundle.service.js +19 -94
- package/src/services/react-bundle.service.ts +20 -129
- package/src/services/react-hydration-asset.service.js +3 -3
- package/src/services/react-hydration-asset.service.ts +7 -4
- package/src/services/react-page-module.service.d.ts +3 -0
- package/src/services/react-page-module.service.js +20 -16
- package/src/services/react-page-module.service.ts +27 -17
- package/src/services/react-runtime-bundle.service.d.ts +12 -12
- package/src/services/react-runtime-bundle.service.js +98 -180
- package/src/services/react-runtime-bundle.service.ts +112 -211
- package/src/utils/client-graph-boundary-plugin.js +78 -1
- package/src/utils/client-graph-boundary-plugin.ts +122 -1
- package/src/utils/hydration-scripts.d.ts +18 -1
- package/src/utils/hydration-scripts.js +83 -32
- package/src/utils/hydration-scripts.ts +159 -38
- package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
- package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
- package/src/utils/react-dom-runtime-interop-plugin.ts +33 -0
- package/src/utils/react-mdx-loader-plugin.js +13 -5
- package/src/utils/react-mdx-loader-plugin.ts +28 -5
- package/src/utils/react-runtime-specifier-map.d.ts +6 -0
- package/src/utils/react-runtime-specifier-map.js +37 -0
- package/src/utils/react-runtime-specifier-map.ts +45 -0
- package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
- package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
- package/src/utils/use-sync-external-store-shim-plugin.ts +45 -0
package/src/react-renderer.ts
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
* -
|
|
132
|
-
*
|
|
133
|
-
* -
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
284
|
-
const
|
|
285
|
-
|
|
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
|
-
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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).
|
|
316
|
-
*
|
|
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
|
-
* @
|
|
700
|
+
* @param requiredLocals - Keys explicitly requested for client hydration
|
|
701
|
+
* @returns The filtered locals object if serializable, undefined otherwise
|
|
324
702
|
*/
|
|
325
|
-
private getSerializableLocals(
|
|
703
|
+
private getSerializableLocals(
|
|
704
|
+
locals: RequestLocals | undefined,
|
|
705
|
+
requiredLocals?: string | readonly string[],
|
|
706
|
+
): RequestLocals | undefined {
|
|
326
707
|
try {
|
|
327
|
-
if (locals
|
|
328
|
-
return
|
|
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
|
|
347
|
-
|
|
348
|
-
const
|
|
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(
|
|
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
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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(
|
|
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(
|
|
798
|
+
return this.createHtmlResponse(transformedHtml, ctx);
|
|
399
799
|
} catch (error) {
|
|
400
800
|
throw this.createRenderError('Failed to render view', error);
|
|
401
801
|
}
|