@ecopages/react 0.2.0-alpha.10 → 0.2.0-alpha.12
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 +12 -8
- package/package.json +6 -6
- package/src/react-hmr-strategy.d.ts +4 -2
- package/src/react-hmr-strategy.js +36 -3
- package/src/react-renderer.d.ts +20 -16
- package/src/react-renderer.js +98 -42
- package/src/react.plugin.js +2 -0
- package/src/services/react-bundle.service.d.ts +3 -1
- package/src/services/react-bundle.service.js +20 -2
- package/src/services/react-hmr-page-metadata-cache.d.ts +9 -0
- package/src/services/react-hmr-page-metadata-cache.js +18 -2
- package/src/services/react-hydration-asset.service.d.ts +7 -6
- package/src/services/react-hydration-asset.service.js +26 -14
- package/src/services/react-page-module.service.js +5 -2
- package/src/services/react-runtime-bundle.service.d.ts +2 -0
- package/src/services/react-runtime-bundle.service.js +5 -0
- package/src/utils/client-graph-boundary-plugin.js +2 -2
- package/src/utils/declared-modules.js +4 -1
- package/src/utils/hydration-scripts.d.ts +1 -3
- package/src/utils/hydration-scripts.js +31 -19
package/CHANGELOG.md
CHANGED
|
@@ -6,17 +6,21 @@ All notable changes to `@ecopages/react` are documented here.
|
|
|
6
6
|
|
|
7
7
|
## [UNRELEASED] — TBD
|
|
8
8
|
|
|
9
|
-
###
|
|
9
|
+
### Bug Fixes
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
11
|
+
- Fixed development hydration, router HMR ownership, and page bootstraps across Bun, Vite, and Nitro flows.
|
|
12
|
+
- Fixed React page and MDX module loading to use host-provided loaders on Vite or Nitro and a lightweight browser `eco` shim in preview and build output.
|
|
13
|
+
- Fixed React Fast Refresh to keep React-owned island entrypoints on the React HMR path while ignoring non-React watched script entrypoints.
|
|
14
|
+
- Fixed `renderDocument` to prepend `<!DOCTYPE html>` for both React-managed and non-React HTML templates, matching the behavior of all other integrations.
|
|
15
|
+
- Fixed React island asset generation to share both bundled component modules and hydration bootstraps across repeated island instances of the same component.
|
|
14
16
|
|
|
15
|
-
###
|
|
17
|
+
### Features
|
|
16
18
|
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
- Added built-in React MDX support and reachability-based hydration analysis for React page bundles.
|
|
20
|
+
|
|
21
|
+
### Refactoring
|
|
22
|
+
|
|
23
|
+
- Consolidated React bundling, hydration, and runtime state behind shared service boundaries and `window.__ECO_PAGES__`.
|
|
20
24
|
|
|
21
25
|
---
|
|
22
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/react",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.12",
|
|
4
4
|
"description": "React integration for Ecopages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -53,19 +53,19 @@
|
|
|
53
53
|
"directory": "packages/integrations/react"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@ecopages/core": "0.2.0-alpha.
|
|
56
|
+
"@ecopages/core": "0.2.0-alpha.12",
|
|
57
57
|
"@types/react": "^19",
|
|
58
58
|
"@types/react-dom": "^19",
|
|
59
59
|
"react": "^19",
|
|
60
60
|
"react-dom": "^19"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@ecopages/file-system": "0.2.0-alpha.
|
|
64
|
-
"@ecopages/logger": "
|
|
63
|
+
"@ecopages/file-system": "0.2.0-alpha.12",
|
|
64
|
+
"@ecopages/logger": "^0.2.3",
|
|
65
65
|
"@mdx-js/esbuild": "^3.0.1",
|
|
66
66
|
"@mdx-js/mdx": "^3.1.0",
|
|
67
|
-
"oxc-parser": "^0.
|
|
68
|
-
"oxc-transform": "^0.
|
|
67
|
+
"oxc-parser": "^0.124.0",
|
|
68
|
+
"oxc-transform": "^0.124.0",
|
|
69
69
|
"source-map": "^0.7.6",
|
|
70
70
|
"vfile": "^6.0.3"
|
|
71
71
|
},
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { HmrStrategy,
|
|
9
|
+
import { HmrStrategy, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
|
|
10
10
|
import type { DefaultHmrContext } from '@ecopages/core';
|
|
11
11
|
import type { CompileOptions } from '@mdx-js/mdx';
|
|
12
12
|
import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.js';
|
|
@@ -49,7 +49,7 @@ import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metada
|
|
|
49
49
|
* ```
|
|
50
50
|
*/
|
|
51
51
|
export declare class ReactHmrStrategy extends HmrStrategy {
|
|
52
|
-
readonly type
|
|
52
|
+
readonly type: 100;
|
|
53
53
|
private mdxCompilerOptions?;
|
|
54
54
|
private readonly ownedTemplateExtensions;
|
|
55
55
|
private readonly allTemplateExtensions;
|
|
@@ -89,6 +89,7 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
89
89
|
*/
|
|
90
90
|
private isRouteTemplate;
|
|
91
91
|
private resolveTemplateExtension;
|
|
92
|
+
private ownsWatchedEntrypoint;
|
|
92
93
|
/**
|
|
93
94
|
* Determines if the file is a React/MDX entrypoint that's registered for HMR.
|
|
94
95
|
*
|
|
@@ -128,6 +129,7 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
128
129
|
* @returns True if bundling was successful
|
|
129
130
|
*/
|
|
130
131
|
private bundleReactEntrypoint;
|
|
132
|
+
private resolveTempOutputPath;
|
|
131
133
|
/**
|
|
132
134
|
* Encodes dynamic route segments (brackets) in file paths.
|
|
133
135
|
* Converts `[slug]` to `_slug_` to avoid filesystem issues.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
|
|
3
|
+
import { rewriteRuntimeSpecifierAliases } from "@ecopages/core/build/runtime-specifier-aliases";
|
|
3
4
|
import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
|
|
4
5
|
import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
|
|
5
6
|
import { Logger } from "@ecopages/logger";
|
|
@@ -49,8 +50,9 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
49
50
|
* (including `node:*`) from breaking the browser bundle.
|
|
50
51
|
*/
|
|
51
52
|
getBuildPlugins(declaredModules) {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
53
|
+
const runtimeSpecifierMap = this.context.getSpecifierMap();
|
|
54
|
+
const allowSpecifiers = getReactClientGraphAllowSpecifiers(runtimeSpecifierMap.keys());
|
|
55
|
+
const runtimeAliasPlugin = createRuntimeSpecifierAliasPlugin(runtimeSpecifierMap, {
|
|
54
56
|
name: "react-hmr-runtime-specifier-alias"
|
|
55
57
|
});
|
|
56
58
|
return [
|
|
@@ -97,6 +99,9 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
97
99
|
resolveTemplateExtension(filePath) {
|
|
98
100
|
return this.allTemplateExtensions.find((extension) => filePath.endsWith(extension));
|
|
99
101
|
}
|
|
102
|
+
ownsWatchedEntrypoint(filePath) {
|
|
103
|
+
return this.pageMetadataCache.ownsEntrypoint(filePath);
|
|
104
|
+
}
|
|
100
105
|
/**
|
|
101
106
|
* Determines if the file is a React/MDX entrypoint that's registered for HMR.
|
|
102
107
|
*
|
|
@@ -109,6 +114,9 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
109
114
|
if (watchedFiles.size === 0) {
|
|
110
115
|
return false;
|
|
111
116
|
}
|
|
117
|
+
if (watchedFiles.has(filePath)) {
|
|
118
|
+
return this.ownsWatchedEntrypoint(filePath);
|
|
119
|
+
}
|
|
112
120
|
return this.isReactEntrypoint(filePath);
|
|
113
121
|
}
|
|
114
122
|
/**
|
|
@@ -148,6 +156,10 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
148
156
|
appLogger.debug(`Detected layout file change: ${_filePath}`);
|
|
149
157
|
}
|
|
150
158
|
const changedEntrypointOutput = watchedFiles.get(_filePath);
|
|
159
|
+
if (changedEntrypointOutput && !this.ownsWatchedEntrypoint(_filePath)) {
|
|
160
|
+
appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
|
|
161
|
+
return { type: "none" };
|
|
162
|
+
}
|
|
151
163
|
const entrypointsToBuild = changedEntrypointOutput ? [[_filePath, changedEntrypointOutput]] : watchedFiles.entries();
|
|
152
164
|
const updates = [];
|
|
153
165
|
for (const [entrypoint, outputUrl] of entrypointsToBuild) {
|
|
@@ -225,13 +237,33 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
225
237
|
appLogger.error(`No output file generated for ${entrypointPath}`);
|
|
226
238
|
return false;
|
|
227
239
|
}
|
|
228
|
-
const
|
|
240
|
+
const resolvedTempFile = await this.resolveTempOutputPath(tempFile);
|
|
241
|
+
if (!resolvedTempFile) {
|
|
242
|
+
appLogger.debug(`Skipping stale temp output for ${outputUrl}: ${tempFile}`);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const processed = await this.processOutput(resolvedTempFile, outputPath, outputUrl);
|
|
229
246
|
return processed;
|
|
230
247
|
} catch (error) {
|
|
231
248
|
appLogger.error(`Error bundling ${entrypointPath}:`, error);
|
|
232
249
|
return false;
|
|
233
250
|
}
|
|
234
251
|
}
|
|
252
|
+
async resolveTempOutputPath(tempPath) {
|
|
253
|
+
if (fileSystem.exists(tempPath)) {
|
|
254
|
+
return tempPath;
|
|
255
|
+
}
|
|
256
|
+
if (!tempPath.includes("[hash]")) {
|
|
257
|
+
return tempPath;
|
|
258
|
+
}
|
|
259
|
+
const directory = path.dirname(tempPath);
|
|
260
|
+
const pattern = path.basename(tempPath).replaceAll("[hash]", "*");
|
|
261
|
+
const matches = await fileSystem.glob([pattern], { cwd: directory });
|
|
262
|
+
if (matches.length === 0) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return path.isAbsolute(matches[0]) ? matches[0] : path.join(directory, matches[0]);
|
|
266
|
+
}
|
|
235
267
|
/**
|
|
236
268
|
* Encodes dynamic route segments (brackets) in file paths.
|
|
237
269
|
* Converts `[slug]` to `_slug_` to avoid filesystem issues.
|
|
@@ -255,6 +287,7 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
255
287
|
}
|
|
256
288
|
try {
|
|
257
289
|
let code = await fileSystem.readFile(tempPath);
|
|
290
|
+
code = rewriteRuntimeSpecifierAliases(code, this.context.getSpecifierMap());
|
|
258
291
|
code = injectHmrHandler(code);
|
|
259
292
|
await fileSystem.writeAsync(finalPath, code);
|
|
260
293
|
await fileSystem.removeAsync(tempPath).catch(() => {
|
package/src/react-renderer.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* This module contains the React renderer
|
|
3
3
|
* @module
|
|
4
4
|
*/
|
|
5
|
-
import type { ComponentRenderInput, ComponentRenderResult, EcoComponent,
|
|
5
|
+
import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, EcoPageFile, IntegrationRendererRenderOptions, RouteRendererBody } from '@ecopages/core';
|
|
6
6
|
import { IntegrationRenderer, type RenderToResponseContext } from '@ecopages/core/route-renderer/integration-renderer';
|
|
7
7
|
import type { ProcessedAsset } from '@ecopages/core/services/asset-processing-service';
|
|
8
8
|
import { type ReactNode } from 'react';
|
|
@@ -12,11 +12,6 @@ import { ReactBundleService } from './services/react-bundle.service.js';
|
|
|
12
12
|
import { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.js';
|
|
13
13
|
import { ReactPageModuleService } from './services/react-page-module.service.js';
|
|
14
14
|
import { ReactHydrationAssetService } from './services/react-hydration-asset.service.js';
|
|
15
|
-
type ReactPageModule = EcoPageFile<{
|
|
16
|
-
config?: EcoComponentConfig;
|
|
17
|
-
}> & {
|
|
18
|
-
config?: EcoComponentConfig;
|
|
19
|
-
};
|
|
20
15
|
/**
|
|
21
16
|
* Error thrown when an error occurs while rendering a React component.
|
|
22
17
|
*/
|
|
@@ -132,6 +127,20 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
132
127
|
* if that shell yields a string that can be inserted into the final document.
|
|
133
128
|
*/
|
|
134
129
|
private renderNonReactShellComponent;
|
|
130
|
+
/**
|
|
131
|
+
* Renders one React component boundary for marker-graph orchestration.
|
|
132
|
+
*
|
|
133
|
+
* When the marker resolver has already resolved child HTML for this boundary,
|
|
134
|
+
* the child payload must remain raw SSR output rather than a React string
|
|
135
|
+
* child, otherwise React would escape it. This helper renders a unique token
|
|
136
|
+
* through React and swaps that token back to the resolved HTML afterward.
|
|
137
|
+
*
|
|
138
|
+
* @param input Component render input reconstructed from marker metadata.
|
|
139
|
+
* @param context React-specific render context for stable token generation.
|
|
140
|
+
* @returns Serialized component HTML with resolved child markup preserved.
|
|
141
|
+
*/
|
|
142
|
+
private renderComponentHtml;
|
|
143
|
+
private buildHydrationProps;
|
|
135
144
|
/**
|
|
136
145
|
* Produces the page body before the final HTML template is applied.
|
|
137
146
|
*
|
|
@@ -156,6 +165,8 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
156
165
|
* - When an explicit component instance id is provided, a stable
|
|
157
166
|
* `data-eco-component-id` attribute is attached so island hydration can target it.
|
|
158
167
|
* - Without an explicit instance id, component renders remain plain SSR output.
|
|
168
|
+
* - When resolved child HTML is provided, that boundary is treated as a pure SSR
|
|
169
|
+
* composition step and does not emit hydration assets for the parent wrapper.
|
|
159
170
|
*
|
|
160
171
|
* This preserves DOM shape for global CSS/layout selectors while keeping a
|
|
161
172
|
* deterministic mount target per component instance.
|
|
@@ -167,6 +178,9 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
167
178
|
* @returns True if the file is an MDX file
|
|
168
179
|
*/
|
|
169
180
|
isMdxFile(filePath: string): boolean;
|
|
181
|
+
protected usesIntegrationPageImporter(file: string): boolean;
|
|
182
|
+
protected importIntegrationPageFile(file: string): Promise<EcoPageFile>;
|
|
183
|
+
protected normalizeImportedPageFile<TPageModule extends EcoPageFile>(file: string, pageModule: TPageModule): TPageModule;
|
|
170
184
|
/**
|
|
171
185
|
* Processes MDX-specific configuration dependencies including layout dependencies.
|
|
172
186
|
* @param pagePath - Absolute path to the MDX page file
|
|
@@ -176,15 +190,6 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
176
190
|
private processDeclaredMdxSsrLazyDependencies;
|
|
177
191
|
private collectDeclaredMdxSsrLazyDependencies;
|
|
178
192
|
buildRouteRenderAssets(pagePath: string): Promise<ProcessedAsset[]>;
|
|
179
|
-
/**
|
|
180
|
-
* Imports a page module while normalizing React MDX modules to the same shape
|
|
181
|
-
* as ordinary React page files.
|
|
182
|
-
*
|
|
183
|
-
* MDX page imports can expose `config` separately from the default export. The
|
|
184
|
-
* React renderer reattaches that config to the page component so downstream
|
|
185
|
-
* layout, dependency, and hydration logic can treat MDX and TSX pages the same.
|
|
186
|
-
*/
|
|
187
|
-
protected importPageFile(file: string): Promise<ReactPageModule>;
|
|
188
193
|
/**
|
|
189
194
|
* Renders a full route response for the filesystem page pipeline.
|
|
190
195
|
*
|
|
@@ -222,4 +227,3 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
222
227
|
*/
|
|
223
228
|
renderToResponse<P = Record<string, unknown>>(view: EcoComponent<P>, props: P, ctx: RenderToResponseContext): Promise<Response>;
|
|
224
229
|
}
|
|
225
|
-
export {};
|
package/src/react-renderer.js
CHANGED
|
@@ -13,7 +13,25 @@ import { hasSingleRootElement } from "./utils/html-boundary.js";
|
|
|
13
13
|
import { ReactBundleService } from "./services/react-bundle.service.js";
|
|
14
14
|
import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
|
|
15
15
|
import { ReactPageModuleService } from "./services/react-page-module.service.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
getReactIslandComponentKey,
|
|
18
|
+
ReactHydrationAssetService
|
|
19
|
+
} from "./services/react-hydration-asset.service.js";
|
|
20
|
+
function decodeHtmlEntities(value) {
|
|
21
|
+
let decoded = value;
|
|
22
|
+
let previous;
|
|
23
|
+
do {
|
|
24
|
+
previous = decoded;
|
|
25
|
+
decoded = decoded.replaceAll(""", '"').replaceAll("'", "'").replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
26
|
+
} while (decoded !== previous);
|
|
27
|
+
return decoded;
|
|
28
|
+
}
|
|
29
|
+
function restoreEscapedComponentMarkers(html) {
|
|
30
|
+
return html.replace(
|
|
31
|
+
/&(?:amp;)?lt;eco-marker\b[\s\S]*?&(?:amp;)?gt;&(?:amp;)?lt;\/eco-marker&(?:amp;)?gt;/g,
|
|
32
|
+
(marker) => decodeHtmlEntities(marker)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
17
35
|
class ReactRenderError extends Error {
|
|
18
36
|
constructor(message) {
|
|
19
37
|
super(message);
|
|
@@ -53,7 +71,9 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
53
71
|
this.bundleService = new ReactBundleService({
|
|
54
72
|
rootDir: this.appConfig.rootDir,
|
|
55
73
|
routerAdapter: ReactRenderer.routerAdapter,
|
|
56
|
-
mdxCompilerOptions: ReactRenderer.mdxCompilerOptions
|
|
74
|
+
mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
|
|
75
|
+
jsxImportSource: (this.appConfig.integrations ?? []).find((integration) => integration.name === this.name)?.jsxImportSource,
|
|
76
|
+
nonReactExtensions: (this.appConfig.integrations ?? []).filter((integration) => integration.name !== this.name).flatMap((integration) => integration.extensions)
|
|
57
77
|
});
|
|
58
78
|
this.pageModuleService = new ReactPageModuleService({
|
|
59
79
|
rootDir: this.appConfig.rootDir,
|
|
@@ -197,6 +217,38 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
197
217
|
}
|
|
198
218
|
throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
|
|
199
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Renders one React component boundary for marker-graph orchestration.
|
|
222
|
+
*
|
|
223
|
+
* When the marker resolver has already resolved child HTML for this boundary,
|
|
224
|
+
* the child payload must remain raw SSR output rather than a React string
|
|
225
|
+
* child, otherwise React would escape it. This helper renders a unique token
|
|
226
|
+
* through React and swaps that token back to the resolved HTML afterward.
|
|
227
|
+
*
|
|
228
|
+
* @param input Component render input reconstructed from marker metadata.
|
|
229
|
+
* @param context React-specific render context for stable token generation.
|
|
230
|
+
* @returns Serialized component HTML with resolved child markup preserved.
|
|
231
|
+
*/
|
|
232
|
+
renderComponentHtml(input, context) {
|
|
233
|
+
if (input.children === void 0) {
|
|
234
|
+
return restoreEscapedComponentMarkers(
|
|
235
|
+
renderToString(createElement(this.asReactComponent(input.component), input.props))
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const resolvedChildHtml = typeof input.children === "string" ? input.children : String(input.children ?? "");
|
|
239
|
+
const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
|
|
240
|
+
const html = renderToString(
|
|
241
|
+
createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
|
|
242
|
+
);
|
|
243
|
+
return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(resolvedChildHtml));
|
|
244
|
+
}
|
|
245
|
+
buildHydrationProps(props) {
|
|
246
|
+
if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
|
|
247
|
+
return props ?? {};
|
|
248
|
+
}
|
|
249
|
+
const { locals: _locals, ...hydrationProps } = props;
|
|
250
|
+
return hydrationProps;
|
|
251
|
+
}
|
|
200
252
|
/**
|
|
201
253
|
* Produces the page body before the final HTML template is applied.
|
|
202
254
|
*
|
|
@@ -206,7 +258,7 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
206
258
|
*/
|
|
207
259
|
async composePageContent(options) {
|
|
208
260
|
const pageElement = createElement(options.Page, options.pageProps);
|
|
209
|
-
const pageHtml = renderToString(pageElement);
|
|
261
|
+
const pageHtml = restoreEscapedComponentMarkers(renderToString(pageElement));
|
|
210
262
|
const layoutProps = options.locals ? { locals: options.locals } : {};
|
|
211
263
|
if (!options.Layout) {
|
|
212
264
|
return { contentNode: pageElement, contentHtml: pageHtml };
|
|
@@ -215,7 +267,7 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
215
267
|
const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
|
|
216
268
|
return {
|
|
217
269
|
contentNode: layoutElement,
|
|
218
|
-
contentHtml: renderToString(layoutElement)
|
|
270
|
+
contentHtml: restoreEscapedComponentMarkers(renderToString(layoutElement))
|
|
219
271
|
};
|
|
220
272
|
}
|
|
221
273
|
const layoutHtml = await this.renderNonReactShellComponent(
|
|
@@ -234,19 +286,23 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
234
286
|
*/
|
|
235
287
|
async renderDocument(options) {
|
|
236
288
|
if (this.isReactManagedComponent(options.HtmlTemplate)) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
289
|
+
const rawChildrenToken = "__ECO_RAW_HTML_DOCUMENT_CHILD__";
|
|
290
|
+
const html = restoreEscapedComponentMarkers(
|
|
291
|
+
renderToString(
|
|
292
|
+
createElement(
|
|
293
|
+
this.asReactComponent(options.HtmlTemplate),
|
|
294
|
+
{
|
|
295
|
+
metadata: options.metadata,
|
|
296
|
+
pageProps: options.pageProps
|
|
297
|
+
},
|
|
298
|
+
rawChildrenToken
|
|
299
|
+
)
|
|
245
300
|
)
|
|
246
301
|
);
|
|
302
|
+
return this.DOC_TYPE + html.split(rawChildrenToken).join(options.contentHtml);
|
|
247
303
|
}
|
|
248
304
|
const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
|
|
249
|
-
return this.renderNonReactShellComponent(
|
|
305
|
+
return this.DOC_TYPE + await this.renderNonReactShellComponent(
|
|
250
306
|
this.asNonReactShellComponent(options.HtmlTemplate),
|
|
251
307
|
{
|
|
252
308
|
metadata: options.metadata,
|
|
@@ -265,32 +321,32 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
265
321
|
* - When an explicit component instance id is provided, a stable
|
|
266
322
|
* `data-eco-component-id` attribute is attached so island hydration can target it.
|
|
267
323
|
* - Without an explicit instance id, component renders remain plain SSR output.
|
|
324
|
+
* - When resolved child HTML is provided, that boundary is treated as a pure SSR
|
|
325
|
+
* composition step and does not emit hydration assets for the parent wrapper.
|
|
268
326
|
*
|
|
269
327
|
* This preserves DOM shape for global CSS/layout selectors while keeping a
|
|
270
328
|
* deterministic mount target per component instance.
|
|
271
329
|
*/
|
|
272
330
|
async renderComponent(input) {
|
|
273
|
-
const Component = this.asReactComponent(input.component);
|
|
274
331
|
const componentConfig = input.component.config;
|
|
275
|
-
const
|
|
276
|
-
|
|
332
|
+
const context = input.integrationContext ?? {};
|
|
333
|
+
const hasResolvedChildHtml = input.children !== void 0;
|
|
334
|
+
let html = this.renderComponentHtml(input, context);
|
|
277
335
|
let canAttachAttributes = hasSingleRootElement(html);
|
|
278
336
|
let rootTag = this.getRootTagName(html);
|
|
279
337
|
const componentFile = componentConfig?.__eco?.file;
|
|
280
|
-
const context = input.integrationContext ?? {};
|
|
281
338
|
let rootAttributes;
|
|
282
339
|
let assets;
|
|
283
|
-
if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService) {
|
|
340
|
+
if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
|
|
284
341
|
const componentInstanceId = context.componentInstanceId;
|
|
285
342
|
assets = await this.hydrationAssetService.buildComponentRenderAssets(
|
|
286
343
|
componentFile,
|
|
287
|
-
componentInstanceId,
|
|
288
|
-
input.props,
|
|
289
344
|
componentConfig
|
|
290
345
|
);
|
|
291
346
|
rootAttributes = {
|
|
292
347
|
"data-eco-component-id": componentInstanceId,
|
|
293
|
-
"data-eco-
|
|
348
|
+
"data-eco-component-key": getReactIslandComponentKey(componentFile, componentConfig),
|
|
349
|
+
"data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
|
|
294
350
|
};
|
|
295
351
|
}
|
|
296
352
|
return {
|
|
@@ -310,13 +366,33 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
310
366
|
isMdxFile(filePath) {
|
|
311
367
|
return this.pageModuleService.isMdxFile(filePath);
|
|
312
368
|
}
|
|
369
|
+
usesIntegrationPageImporter(file) {
|
|
370
|
+
return this.pageModuleService.isMdxFile(file);
|
|
371
|
+
}
|
|
372
|
+
async importIntegrationPageFile(file) {
|
|
373
|
+
return await this.pageModuleService.importMdxPageFile(file);
|
|
374
|
+
}
|
|
375
|
+
normalizeImportedPageFile(file, pageModule) {
|
|
376
|
+
const reactModule = pageModule;
|
|
377
|
+
const { default: Page, getMetadata, config } = reactModule;
|
|
378
|
+
if (this.pageModuleService.isMdxFile(file) && config) {
|
|
379
|
+
Page.config = config;
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
...pageModule,
|
|
383
|
+
default: Page,
|
|
384
|
+
getMetadata,
|
|
385
|
+
config
|
|
386
|
+
};
|
|
387
|
+
}
|
|
313
388
|
/**
|
|
314
389
|
* Processes MDX-specific configuration dependencies including layout dependencies.
|
|
315
390
|
* @param pagePath - Absolute path to the MDX page file
|
|
316
391
|
* @returns Processed assets for MDX configuration dependencies
|
|
317
392
|
*/
|
|
318
393
|
async processMdxConfigDependencies(pagePath) {
|
|
319
|
-
const
|
|
394
|
+
const pageModule = await this.importPageFile(pagePath);
|
|
395
|
+
const config = pageModule.config;
|
|
320
396
|
const resolvedLayout = config?.layout;
|
|
321
397
|
const components = [];
|
|
322
398
|
if (resolvedLayout?.config?.dependencies) {
|
|
@@ -439,26 +515,6 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
439
515
|
);
|
|
440
516
|
}
|
|
441
517
|
}
|
|
442
|
-
/**
|
|
443
|
-
* Imports a page module while normalizing React MDX modules to the same shape
|
|
444
|
-
* as ordinary React page files.
|
|
445
|
-
*
|
|
446
|
-
* MDX page imports can expose `config` separately from the default export. The
|
|
447
|
-
* React renderer reattaches that config to the page component so downstream
|
|
448
|
-
* layout, dependency, and hydration logic can treat MDX and TSX pages the same.
|
|
449
|
-
*/
|
|
450
|
-
async importPageFile(file) {
|
|
451
|
-
const module = this.pageModuleService.isMdxFile(file) ? await this.pageModuleService.importMdxPageFile(file) : await super.importPageFile(file);
|
|
452
|
-
const { default: Page, getMetadata, config } = module;
|
|
453
|
-
if (this.pageModuleService.isMdxFile(file) && config) {
|
|
454
|
-
Page.config = config;
|
|
455
|
-
}
|
|
456
|
-
return {
|
|
457
|
-
default: Page,
|
|
458
|
-
getMetadata,
|
|
459
|
-
config
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
518
|
/**
|
|
463
519
|
* Renders a full route response for the filesystem page pipeline.
|
|
464
520
|
*
|
package/src/react.plugin.js
CHANGED
|
@@ -38,6 +38,7 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
38
38
|
super({
|
|
39
39
|
name: PLUGIN_NAME,
|
|
40
40
|
extensions,
|
|
41
|
+
jsxImportSource: "react",
|
|
41
42
|
...restOptions
|
|
42
43
|
});
|
|
43
44
|
this.mdxEnabled = options?.mdx?.enabled ?? false;
|
|
@@ -70,6 +71,7 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
70
71
|
if (this.runtimeDependenciesInitialized) {
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
74
|
+
this.runtimeBundleService.setRootDir(this.appConfig?.rootDir);
|
|
73
75
|
this.integrationDependencies.unshift(...this.runtimeBundleService.getDependencies());
|
|
74
76
|
this.runtimeDependenciesInitialized = true;
|
|
75
77
|
}
|
|
@@ -16,6 +16,8 @@ export interface ReactBundleServiceConfig {
|
|
|
16
16
|
rootDir: string;
|
|
17
17
|
routerAdapter?: ReactRouterAdapter;
|
|
18
18
|
mdxCompilerOptions?: CompileOptions;
|
|
19
|
+
nonReactExtensions?: string[];
|
|
20
|
+
jsxImportSource?: string;
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* Manages esbuild bundle configuration and plugin creation for React page/component builds.
|
|
@@ -41,5 +43,5 @@ export declare class ReactBundleService {
|
|
|
41
43
|
* Creates the esbuild plugin that rewrites bare React specifiers
|
|
42
44
|
* to their runtime asset URLs.
|
|
43
45
|
*/
|
|
44
|
-
createRuntimeAliasPlugin(runtimeSpecifierMap: Record<string, string>): import("
|
|
46
|
+
createRuntimeAliasPlugin(runtimeSpecifierMap: Record<string, string>): import("@ecopages/core/build/build-types").EcoBuildPlugin | null;
|
|
45
47
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "../utils/react-runtime-specifier-map.js";
|
|
7
7
|
import { createUseSyncExternalStoreShimPlugin } from "../utils/use-sync-external-store-shim-plugin.js";
|
|
8
8
|
import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
|
|
9
|
+
import { createForeignJsxOverridePlugin } from "@ecopages/core/plugins/foreign-jsx-override-plugin";
|
|
9
10
|
import { ReactRuntimeBundleService } from "./react-runtime-bundle.service.js";
|
|
10
11
|
class ReactBundleService {
|
|
11
12
|
runtimeBundleService;
|
|
@@ -13,6 +14,7 @@ class ReactBundleService {
|
|
|
13
14
|
constructor(config) {
|
|
14
15
|
this.config = config;
|
|
15
16
|
this.runtimeBundleService = new ReactRuntimeBundleService({
|
|
17
|
+
rootDir: config.rootDir,
|
|
16
18
|
routerAdapter: config.routerAdapter
|
|
17
19
|
});
|
|
18
20
|
}
|
|
@@ -48,6 +50,11 @@ class ReactBundleService {
|
|
|
48
50
|
declaredModules,
|
|
49
51
|
alwaysAllowSpecifiers: getReactClientGraphAllowSpecifiers([], this.config.routerAdapter)
|
|
50
52
|
});
|
|
53
|
+
const foreignJsxOverridePlugin = createForeignJsxOverridePlugin({
|
|
54
|
+
name: "react-renderer-foreign-jsx-override",
|
|
55
|
+
hostJsxImportSource: this.config.jsxImportSource ?? "react",
|
|
56
|
+
foreignExtensions: this.config.nonReactExtensions ?? []
|
|
57
|
+
});
|
|
51
58
|
const runtimeAliasPlugin = this.createRuntimeAliasPlugin(runtimeSpecifierMap);
|
|
52
59
|
const useSyncExternalStoreShimPlugin = createUseSyncExternalStoreShimPlugin({
|
|
53
60
|
name: "react-renderer-use-sync-external-store-shim",
|
|
@@ -56,9 +63,20 @@ class ReactBundleService {
|
|
|
56
63
|
if (isMdx && this.config.mdxCompilerOptions) {
|
|
57
64
|
const { createReactMdxLoaderPlugin } = await import("../utils/react-mdx-loader-plugin.js");
|
|
58
65
|
const mdxPlugin = createReactMdxLoaderPlugin(this.config.mdxCompilerOptions);
|
|
59
|
-
options.plugins = [
|
|
66
|
+
options.plugins = [
|
|
67
|
+
foreignJsxOverridePlugin,
|
|
68
|
+
graphBoundaryPlugin,
|
|
69
|
+
runtimeAliasPlugin,
|
|
70
|
+
mdxPlugin,
|
|
71
|
+
useSyncExternalStoreShimPlugin
|
|
72
|
+
];
|
|
60
73
|
} else {
|
|
61
|
-
options.plugins = [
|
|
74
|
+
options.plugins = [
|
|
75
|
+
foreignJsxOverridePlugin,
|
|
76
|
+
graphBoundaryPlugin,
|
|
77
|
+
runtimeAliasPlugin,
|
|
78
|
+
useSyncExternalStoreShimPlugin
|
|
79
|
+
];
|
|
62
80
|
}
|
|
63
81
|
return options;
|
|
64
82
|
}
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export declare class ReactHmrPageMetadataCache {
|
|
8
8
|
private readonly declaredModulesByEntrypoint;
|
|
9
|
+
private readonly ownedEntrypoints;
|
|
10
|
+
/**
|
|
11
|
+
* Marks an HMR entrypoint as React-owned.
|
|
12
|
+
*/
|
|
13
|
+
markOwnedEntrypoint(entrypointPath: string): void;
|
|
9
14
|
/**
|
|
10
15
|
* Stores the declared browser modules for a page entrypoint.
|
|
11
16
|
*/
|
|
@@ -14,4 +19,8 @@ export declare class ReactHmrPageMetadataCache {
|
|
|
14
19
|
* Returns the last known declared browser modules for a page entrypoint.
|
|
15
20
|
*/
|
|
16
21
|
getDeclaredModules(entrypointPath: string): string[] | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Returns true when the watched entrypoint is owned by the React integration.
|
|
24
|
+
*/
|
|
25
|
+
ownsEntrypoint(entrypointPath: string): boolean;
|
|
17
26
|
}
|
|
@@ -1,18 +1,34 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
class ReactHmrPageMetadataCache {
|
|
2
3
|
declaredModulesByEntrypoint = /* @__PURE__ */ new Map();
|
|
4
|
+
ownedEntrypoints = /* @__PURE__ */ new Set();
|
|
5
|
+
/**
|
|
6
|
+
* Marks an HMR entrypoint as React-owned.
|
|
7
|
+
*/
|
|
8
|
+
markOwnedEntrypoint(entrypointPath) {
|
|
9
|
+
this.ownedEntrypoints.add(path.resolve(entrypointPath));
|
|
10
|
+
}
|
|
3
11
|
/**
|
|
4
12
|
* Stores the declared browser modules for a page entrypoint.
|
|
5
13
|
*/
|
|
6
14
|
setDeclaredModules(entrypointPath, declaredModules) {
|
|
7
|
-
|
|
15
|
+
const resolvedEntrypointPath = path.resolve(entrypointPath);
|
|
16
|
+
this.markOwnedEntrypoint(resolvedEntrypointPath);
|
|
17
|
+
this.declaredModulesByEntrypoint.set(resolvedEntrypointPath, [...declaredModules]);
|
|
8
18
|
}
|
|
9
19
|
/**
|
|
10
20
|
* Returns the last known declared browser modules for a page entrypoint.
|
|
11
21
|
*/
|
|
12
22
|
getDeclaredModules(entrypointPath) {
|
|
13
|
-
const declaredModules = this.declaredModulesByEntrypoint.get(entrypointPath);
|
|
23
|
+
const declaredModules = this.declaredModulesByEntrypoint.get(path.resolve(entrypointPath));
|
|
14
24
|
return declaredModules ? [...declaredModules] : void 0;
|
|
15
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Returns true when the watched entrypoint is owned by the React integration.
|
|
28
|
+
*/
|
|
29
|
+
ownsEntrypoint(entrypointPath) {
|
|
30
|
+
return this.ownedEntrypoints.has(path.resolve(entrypointPath));
|
|
31
|
+
}
|
|
16
32
|
}
|
|
17
33
|
export {
|
|
18
34
|
ReactHmrPageMetadataCache
|
|
@@ -23,21 +23,24 @@ export interface ReactHydrationAssetServiceConfig {
|
|
|
23
23
|
bundleService: ReactBundleService;
|
|
24
24
|
hmrPageMetadataCache?: ReactHmrPageMetadataCache;
|
|
25
25
|
}
|
|
26
|
+
export declare function getReactIslandComponentKey(componentFile: string, config?: EcoComponentConfig): string;
|
|
26
27
|
/**
|
|
27
28
|
* Manages the creation of client-side hydration assets for React pages and component islands.
|
|
28
29
|
*/
|
|
29
30
|
export declare class ReactHydrationAssetService {
|
|
30
31
|
private readonly config;
|
|
31
32
|
constructor(config: ReactHydrationAssetServiceConfig);
|
|
33
|
+
private getIslandBundleName;
|
|
34
|
+
private getIslandHydrationName;
|
|
32
35
|
/**
|
|
33
36
|
* Resolves the import path for the bundled page component.
|
|
34
37
|
* Uses HMR manager for development or constructs static path for production.
|
|
35
38
|
*
|
|
36
39
|
* @param pagePath - Absolute path to the page source file
|
|
37
|
-
* @param
|
|
40
|
+
* @param assetName - Generated asset name
|
|
38
41
|
* @returns The resolved import path for the bundled component
|
|
39
42
|
*/
|
|
40
|
-
resolveAssetImportPath(pagePath: string,
|
|
43
|
+
resolveAssetImportPath(pagePath: string, assetName: string): Promise<string>;
|
|
41
44
|
/**
|
|
42
45
|
* Creates the asset dependencies for a page: the bundled component and hydration script.
|
|
43
46
|
*
|
|
@@ -54,15 +57,13 @@ export declare class ReactHydrationAssetService {
|
|
|
54
57
|
/**
|
|
55
58
|
* Builds client-side assets for a React component island.
|
|
56
59
|
*
|
|
57
|
-
* Includes the bundled component entry and
|
|
60
|
+
* Includes the bundled component entry and a shared hydration bootstrap script.
|
|
58
61
|
*
|
|
59
62
|
* @param componentFile - Absolute path to the component source file
|
|
60
|
-
* @param componentInstanceId - Unique instance ID for DOM targeting
|
|
61
|
-
* @param props - Serialized props for client-side hydration
|
|
62
63
|
* @param config - Optional component config with `__eco` metadata
|
|
63
64
|
* @returns Processed assets ready for injection
|
|
64
65
|
*/
|
|
65
|
-
buildComponentRenderAssets(componentFile: string,
|
|
66
|
+
buildComponentRenderAssets(componentFile: string, config?: EcoComponentConfig): Promise<ProcessedAsset[]>;
|
|
66
67
|
/**
|
|
67
68
|
* Builds all client-side route assets for a page.
|
|
68
69
|
*
|
|
@@ -6,25 +6,34 @@ import {
|
|
|
6
6
|
} from "@ecopages/core/services/asset-processing-service";
|
|
7
7
|
import { createHydrationScript, createIslandHydrationScript } from "../utils/hydration-scripts.js";
|
|
8
8
|
import { collectDeclaredModulesInConfig } from "../utils/declared-modules.js";
|
|
9
|
+
function getReactIslandComponentKey(componentFile, config) {
|
|
10
|
+
return rapidhash(`${componentFile}:${config?.__eco?.id ?? ""}`).toString();
|
|
11
|
+
}
|
|
9
12
|
class ReactHydrationAssetService {
|
|
10
13
|
config;
|
|
11
14
|
constructor(config) {
|
|
12
15
|
this.config = config;
|
|
13
16
|
}
|
|
17
|
+
getIslandBundleName(componentFile) {
|
|
18
|
+
return `ecopages-react-island-${rapidhash(componentFile)}`;
|
|
19
|
+
}
|
|
20
|
+
getIslandHydrationName(bundleName, componentKey) {
|
|
21
|
+
return `${bundleName}-hydration-${componentKey}`;
|
|
22
|
+
}
|
|
14
23
|
/**
|
|
15
24
|
* Resolves the import path for the bundled page component.
|
|
16
25
|
* Uses HMR manager for development or constructs static path for production.
|
|
17
26
|
*
|
|
18
27
|
* @param pagePath - Absolute path to the page source file
|
|
19
|
-
* @param
|
|
28
|
+
* @param assetName - Generated asset name
|
|
20
29
|
* @returns The resolved import path for the bundled component
|
|
21
30
|
*/
|
|
22
|
-
async resolveAssetImportPath(pagePath,
|
|
31
|
+
async resolveAssetImportPath(pagePath, assetName) {
|
|
23
32
|
const hmrManager = this.config.assetProcessingService?.getHmrManager();
|
|
24
33
|
if (hmrManager?.isEnabled()) {
|
|
25
34
|
return hmrManager.registerEntrypoint(pagePath);
|
|
26
35
|
}
|
|
27
|
-
return `/${path.join(RESOLVED_ASSETS_DIR, path.relative(this.config.srcDir, pagePath)).replace(path.basename(pagePath), `${
|
|
36
|
+
return `/${path.join(RESOLVED_ASSETS_DIR, path.relative(this.config.srcDir, pagePath)).replace(path.basename(pagePath), `${assetName}.js`).replace(/\\/g, "/")}`;
|
|
28
37
|
}
|
|
29
38
|
/**
|
|
30
39
|
* Creates the asset dependencies for a page: the bundled component and hydration script.
|
|
@@ -96,19 +105,22 @@ class ReactHydrationAssetService {
|
|
|
96
105
|
/**
|
|
97
106
|
* Builds client-side assets for a React component island.
|
|
98
107
|
*
|
|
99
|
-
* Includes the bundled component entry and
|
|
108
|
+
* Includes the bundled component entry and a shared hydration bootstrap script.
|
|
100
109
|
*
|
|
101
110
|
* @param componentFile - Absolute path to the component source file
|
|
102
|
-
* @param componentInstanceId - Unique instance ID for DOM targeting
|
|
103
|
-
* @param props - Serialized props for client-side hydration
|
|
104
111
|
* @param config - Optional component config with `__eco` metadata
|
|
105
112
|
* @returns Processed assets ready for injection
|
|
106
113
|
*/
|
|
107
|
-
async buildComponentRenderAssets(componentFile,
|
|
108
|
-
const componentName =
|
|
109
|
-
const
|
|
114
|
+
async buildComponentRenderAssets(componentFile, config) {
|
|
115
|
+
const componentName = this.getIslandBundleName(componentFile);
|
|
116
|
+
const componentKey = getReactIslandComponentKey(componentFile, config);
|
|
117
|
+
const hydrationName = this.getIslandHydrationName(componentName, componentKey);
|
|
110
118
|
const hmrManager = this.config.assetProcessingService?.getHmrManager();
|
|
111
119
|
const isDevelopment = hmrManager?.isEnabled() ?? false;
|
|
120
|
+
if (isDevelopment) {
|
|
121
|
+
this.config.hmrPageMetadataCache?.markOwnedEntrypoint(componentFile);
|
|
122
|
+
}
|
|
123
|
+
const importPath = await this.resolveAssetImportPath(componentFile, componentName);
|
|
112
124
|
const declaredModules = collectDeclaredModulesInConfig(config);
|
|
113
125
|
const bundleOptions = await this.config.bundleService.createBundleOptions(
|
|
114
126
|
componentName,
|
|
@@ -136,19 +148,18 @@ class ReactHydrationAssetService {
|
|
|
136
148
|
importPath,
|
|
137
149
|
reactImportPath: runtimeImports.react,
|
|
138
150
|
reactDomClientImportPath: runtimeImports.reactDomClient,
|
|
139
|
-
targetSelector: `[data-eco-component-
|
|
140
|
-
props,
|
|
151
|
+
targetSelector: `[data-eco-component-key="${componentKey}"]`,
|
|
141
152
|
componentRef: config?.__eco?.id,
|
|
142
153
|
componentFile,
|
|
143
154
|
isDevelopment
|
|
144
155
|
}),
|
|
145
|
-
name:
|
|
156
|
+
name: hydrationName,
|
|
146
157
|
bundle: false,
|
|
147
158
|
attributes: {
|
|
148
159
|
type: "module",
|
|
149
160
|
defer: "",
|
|
150
161
|
"data-eco-rerun": "true",
|
|
151
|
-
"data-eco-script-id":
|
|
162
|
+
"data-eco-script-id": hydrationName,
|
|
152
163
|
"data-eco-persist": "true"
|
|
153
164
|
}
|
|
154
165
|
})
|
|
@@ -194,5 +205,6 @@ class ReactHydrationAssetService {
|
|
|
194
205
|
}
|
|
195
206
|
}
|
|
196
207
|
export {
|
|
197
|
-
ReactHydrationAssetService
|
|
208
|
+
ReactHydrationAssetService,
|
|
209
|
+
getReactIslandComponentKey
|
|
198
210
|
};
|
|
@@ -42,7 +42,7 @@ class ReactPageModuleService {
|
|
|
42
42
|
entrypoints: [filePath],
|
|
43
43
|
root: this.config.rootDir,
|
|
44
44
|
outdir,
|
|
45
|
-
target: "
|
|
45
|
+
target: "es2022",
|
|
46
46
|
format: "esm",
|
|
47
47
|
sourcemap: "none",
|
|
48
48
|
splitting: false,
|
|
@@ -62,7 +62,10 @@ class ReactPageModuleService {
|
|
|
62
62
|
if (!compiledOutput) {
|
|
63
63
|
throw new Error(`No compiled MDX output generated for page: ${filePath}`);
|
|
64
64
|
}
|
|
65
|
-
return await import(
|
|
65
|
+
return await import(
|
|
66
|
+
/* @vite-ignore */
|
|
67
|
+
pathToFileURL(compiledOutput).href
|
|
68
|
+
);
|
|
66
69
|
}
|
|
67
70
|
/**
|
|
68
71
|
* Ensures that an EcoComponentConfig has proper `__eco` metadata attached.
|
|
@@ -19,11 +19,13 @@ export type ReactRuntimeImports = {
|
|
|
19
19
|
};
|
|
20
20
|
export interface ReactRuntimeBundleServiceConfig {
|
|
21
21
|
routerAdapter?: ReactRouterAdapter;
|
|
22
|
+
rootDir?: string;
|
|
22
23
|
}
|
|
23
24
|
type RuntimeMode = 'development' | 'production';
|
|
24
25
|
export declare class ReactRuntimeBundleService {
|
|
25
26
|
private readonly config;
|
|
26
27
|
constructor(config: ReactRuntimeBundleServiceConfig);
|
|
28
|
+
setRootDir(rootDir: string | undefined): void;
|
|
27
29
|
private get isDevelopment();
|
|
28
30
|
private getCurrentRuntimeMode;
|
|
29
31
|
private createRuntimeDefines;
|
|
@@ -11,6 +11,9 @@ class ReactRuntimeBundleService {
|
|
|
11
11
|
constructor(config) {
|
|
12
12
|
this.config = config;
|
|
13
13
|
}
|
|
14
|
+
setRootDir(rootDir) {
|
|
15
|
+
this.config.rootDir = rootDir;
|
|
16
|
+
}
|
|
14
17
|
get isDevelopment() {
|
|
15
18
|
return process.env.NODE_ENV === "development";
|
|
16
19
|
}
|
|
@@ -79,6 +82,7 @@ class ReactRuntimeBundleService {
|
|
|
79
82
|
name: "react",
|
|
80
83
|
fileName: this.getReactVendorFileName(mode),
|
|
81
84
|
cacheDirName: `ecopages-react-runtime-${mode}`,
|
|
85
|
+
rootDir: this.config.rootDir,
|
|
82
86
|
bundleOptions: {
|
|
83
87
|
define: this.createRuntimeDefines(mode)
|
|
84
88
|
}
|
|
@@ -88,6 +92,7 @@ class ReactRuntimeBundleService {
|
|
|
88
92
|
name: "react-dom",
|
|
89
93
|
fileName: this.getReactDomVendorFileName(mode),
|
|
90
94
|
cacheDirName: `ecopages-react-runtime-${mode}`,
|
|
95
|
+
rootDir: this.config.rootDir,
|
|
91
96
|
bundleOptions: {
|
|
92
97
|
define: this.createRuntimeDefines(mode),
|
|
93
98
|
plugins: reactDomBundlePlugins
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, extname, resolve } from "node:path";
|
|
3
3
|
import { parseSync } from "oxc-parser";
|
|
4
|
-
import { analyzeReachability } from "./reachability-analyzer";
|
|
4
|
+
import { analyzeReachability } from "./reachability-analyzer.js";
|
|
5
5
|
const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
|
|
6
6
|
const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
7
7
|
"cache",
|
|
@@ -484,7 +484,7 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
484
484
|
}
|
|
485
485
|
if (!modified) return void 0;
|
|
486
486
|
const ext = extname(args.path).slice(1);
|
|
487
|
-
return { contents: transformed, loader: ext };
|
|
487
|
+
return { contents: transformed, loader: ext, resolveDir: dirname(args.path) };
|
|
488
488
|
});
|
|
489
489
|
}
|
|
490
490
|
};
|
|
@@ -41,7 +41,10 @@ function collectPageDeclaredModulesFromModule(pageModule) {
|
|
|
41
41
|
}
|
|
42
42
|
async function collectPageDeclaredModules(pagePath) {
|
|
43
43
|
try {
|
|
44
|
-
const pageModule = await import(
|
|
44
|
+
const pageModule = await import(
|
|
45
|
+
/* @vite-ignore */
|
|
46
|
+
pagePath
|
|
47
|
+
);
|
|
45
48
|
return collectPageDeclaredModulesFromModule(pageModule);
|
|
46
49
|
} catch {
|
|
47
50
|
return [];
|
|
@@ -30,10 +30,8 @@ export type IslandHydrationScriptOptions = {
|
|
|
30
30
|
reactImportPath: string;
|
|
31
31
|
/** Browser import path for react-dom/client runtime. */
|
|
32
32
|
reactDomClientImportPath: string;
|
|
33
|
-
/** Selector that resolves to
|
|
33
|
+
/** Selector that resolves to all SSR root elements for this island component. */
|
|
34
34
|
targetSelector: string;
|
|
35
|
-
/** Serialized component props emitted at render time. */
|
|
36
|
-
props: Record<string, unknown>;
|
|
37
35
|
/** Optional stable component id used to resolve named exports reliably. */
|
|
38
36
|
componentRef?: string;
|
|
39
37
|
/** Optional source file hint used as fallback for component resolution. */
|
|
@@ -31,16 +31,19 @@ function getProdPageRootCleanupScript() {
|
|
|
31
31
|
return 'window.__ECO_PAGES__=window.__ECO_PAGES__||{};window.__ECO_PAGES__.react=window.__ECO_PAGES__.react||{};window.__ECO_PAGES__.react.cleanupPageRoot=()=>{const a=window.__ECO_PAGES__.react?.pageRoot||root;if(!a){window.__ECO_PAGES__.react.pageRoot=null;window.__ECO_PAGES__?.navigation?.releaseOwnership?.("react-router");delete window.__ECO_PAGES__.page;return}window.__ECO_PAGES__.react.pageRoot=null;window.__ECO_PAGES__?.navigation?.releaseOwnership?.("react-router");delete window.__ECO_PAGES__.page;root=null;a.unmount()};';
|
|
32
32
|
}
|
|
33
33
|
function getDevRouterBootstrapRegistrationScript() {
|
|
34
|
-
return `window.__ECO_PAGES__?.navigation?.
|
|
34
|
+
return `const currentOwnerState = window.__ECO_PAGES__?.navigation?.getOwnerState?.();
|
|
35
|
+
if (!(currentOwnerState?.owner === "react-router" && currentOwnerState.canHandleSpaNavigation)) {
|
|
36
|
+
window.__ECO_PAGES__?.navigation?.register({
|
|
35
37
|
owner: "react-router",
|
|
36
38
|
cleanupBeforeHandoff: async () => {
|
|
37
39
|
window.__ECO_PAGES__?.react?.cleanupPageRoot?.();
|
|
38
40
|
}
|
|
39
41
|
});
|
|
40
|
-
window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router")
|
|
42
|
+
window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router");
|
|
43
|
+
}`;
|
|
41
44
|
}
|
|
42
45
|
function getProdRouterBootstrapRegistrationScript() {
|
|
43
|
-
return 'window.__ECO_PAGES__?.navigation?.register({owner:"react-router",cleanupBeforeHandoff:async()=>{window.__ECO_PAGES__?.react?.cleanupPageRoot?.()}});window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router")
|
|
46
|
+
return 'const o=window.__ECO_PAGES__?.navigation?.getOwnerState?.();if(!(o?.owner==="react-router"&&o.canHandleSpaNavigation)){window.__ECO_PAGES__?.navigation?.register({owner:"react-router",cleanupBeforeHandoff:async()=>{window.__ECO_PAGES__?.react?.cleanupPageRoot?.()}});window.__ECO_PAGES__?.navigation?.claimOwnership?.("react-router")}';
|
|
44
47
|
}
|
|
45
48
|
function createDevScriptWithRouter(options) {
|
|
46
49
|
const { importPath, isMdx, router, reactImportPath, reactDomClientImportPath, routerImportPath } = options;
|
|
@@ -93,16 +96,20 @@ const mount = () => {
|
|
|
93
96
|
window.__ECO_PAGES__.react.pageRoot = root;
|
|
94
97
|
}
|
|
95
98
|
window.__ECO_PAGES__.hmrHandlers["${importPath}"] = async (newUrl) => {
|
|
96
|
-
if (window.__ECO_PAGES__?.navigation?.getOwnerState().owner === "react-router") {
|
|
97
|
-
await window.__ECO_PAGES__?.navigation?.reloadCurrentPage?.({ clearCache: false, source: "react-router" });
|
|
98
|
-
console.log("[ecopages] ${getComponentType(isMdx)} component updated via router");
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
99
|
try {
|
|
102
100
|
const newModule = await import(newUrl);
|
|
101
|
+
const nextProps = getPageData();
|
|
103
102
|
${getHmrImportStatement(isMdx)}
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
window.__ECO_PAGES__.page = {
|
|
104
|
+
module: "${importPath}",
|
|
105
|
+
props: nextProps
|
|
106
|
+
};
|
|
107
|
+
root.render(createTree(NewPage, nextProps));
|
|
108
|
+
if (window.__ECO_PAGES__?.navigation?.getOwnerState().owner === "react-router") {
|
|
109
|
+
console.log("[ecopages] ${getComponentType(isMdx)} component updated via router");
|
|
110
|
+
} else {
|
|
111
|
+
console.log("[ecopages] ${getComponentType(isMdx)} component updated");
|
|
112
|
+
}
|
|
106
113
|
} catch (e) {
|
|
107
114
|
console.error("[ecopages] Failed to hot-reload ${getComponentType(isMdx)} component:", e);
|
|
108
115
|
}
|
|
@@ -245,17 +252,22 @@ const resolveComponent = () => {
|
|
|
245
252
|
};
|
|
246
253
|
|
|
247
254
|
const mount = () => {
|
|
248
|
-
const
|
|
255
|
+
const targets = document.querySelectorAll(${targetSelector});
|
|
249
256
|
const Component = resolveComponent();
|
|
250
|
-
if (!
|
|
257
|
+
if (!Component || targets.length === 0) {
|
|
251
258
|
return;
|
|
252
259
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
targets.forEach((target) => {
|
|
261
|
+
if (!(target instanceof HTMLElement)) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const props = JSON.parse(atob(target.getAttribute("data-eco-props") || "e30="));
|
|
265
|
+
const container = document.createElement("eco-island");
|
|
266
|
+
container.style.display = "block";
|
|
267
|
+
target.replaceWith(container);
|
|
268
|
+
const root = createRoot(container);
|
|
269
|
+
root.render(createElement(Component, props));
|
|
270
|
+
});
|
|
259
271
|
};
|
|
260
272
|
|
|
261
273
|
if (document.readyState === "loading") {
|
|
@@ -265,7 +277,7 @@ if (document.readyState === "loading") {
|
|
|
265
277
|
}
|
|
266
278
|
`.trim();
|
|
267
279
|
}
|
|
268
|
-
return `import{createRoot as cr}from"${options.reactDomClientImportPath}";import{createElement as ce}from"${options.reactImportPath}";import*as M from"${options.importPath}";const r=${componentRef};const f=${componentFile};const mv=Object.values(M);const c=mv.find((e)=>{if(typeof e!=="function")return false;const ec=e.config?.__eco;if(!ec)return false;if(r&&ec.id===r)return true;if(f&&ec.file===f)return true;return false;})??(typeof M.default==="function"?M.default:mv.find((e)=>typeof e==="function")??null);const m=()=>{const
|
|
280
|
+
return `import{createRoot as cr}from"${options.reactDomClientImportPath}";import{createElement as ce}from"${options.reactImportPath}";import*as M from"${options.importPath}";const r=${componentRef};const f=${componentFile};const mv=Object.values(M);const c=mv.find((e)=>{if(typeof e!=="function")return false;const ec=e.config?.__eco;if(!ec)return false;if(r&&ec.id===r)return true;if(f&&ec.file===f)return true;return false;})??(typeof M.default==="function"?M.default:mv.find((e)=>typeof e==="function")??null);const m=()=>{const ts=document.querySelectorAll(${targetSelector});if(!c||ts.length===0)return;ts.forEach((t)=>{if(!(t instanceof HTMLElement))return;const p=JSON.parse(atob(t.getAttribute("data-eco-props")||"e30="));const ct=document.createElement("eco-island");ct.style.display="block";t.replaceWith(ct);cr(ct).render(ce(c,p))})};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m,{once:true}):m()`;
|
|
269
281
|
}
|
|
270
282
|
export {
|
|
271
283
|
createHydrationScript,
|