@ecopages/react 0.2.0-alpha.11 → 0.2.0-alpha.13
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 +5 -4
- package/README.md +10 -0
- package/package.json +6 -6
- package/src/react-hmr-strategy.d.ts +1 -0
- package/src/react-hmr-strategy.js +15 -2
- package/src/react-renderer.d.ts +17 -33
- package/src/react-renderer.js +150 -155
- package/src/react.plugin.d.ts +0 -12
- package/src/react.plugin.js +1 -13
- package/src/services/react-bundle.service.js +5 -3
- 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 +1 -1
- package/src/services/react-runtime-bundle.service.d.ts +2 -0
- package/src/services/react-runtime-bundle.service.js +5 -0
- package/src/utils/hydration-scripts.d.ts +1 -3
- package/src/utils/hydration-scripts.js +14 -9
- package/src/utils/foreign-jsx-override-plugin.d.ts +0 -19
- package/src/utils/foreign-jsx-override-plugin.js +0 -43
package/CHANGELOG.md
CHANGED
|
@@ -8,8 +8,7 @@ All notable changes to `@ecopages/react` are documented here.
|
|
|
8
8
|
|
|
9
9
|
### Bug Fixes
|
|
10
10
|
|
|
11
|
-
- Fixed
|
|
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.
|
|
11
|
+
- Fixed React hydration, Fast Refresh, module loading, doctype handling, island asset reuse, and mixed-renderer boundary resolution across Bun, Vite, and Nitro flows.
|
|
13
12
|
|
|
14
13
|
### Features
|
|
15
14
|
|
|
@@ -19,10 +18,12 @@ All notable changes to `@ecopages/react` are documented here.
|
|
|
19
18
|
|
|
20
19
|
- Consolidated React bundling, hydration, and runtime state behind shared service boundaries and `window.__ECO_PAGES__`.
|
|
21
20
|
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
- Updated the README to document React-owned mixed boundaries and React MDX setup.
|
|
24
|
+
|
|
22
25
|
---
|
|
23
26
|
|
|
24
27
|
## Migration Notes
|
|
25
28
|
|
|
26
|
-
- The React integration now requires explicit client boundary declarations for client-rendered components.
|
|
27
29
|
- React MDX support is built in and no longer requires installing `@ecopages/mdx` just to enable React MDX routes.
|
|
28
|
-
- The internal service layer is not part of the public API and may change between releases.
|
package/README.md
CHANGED
|
@@ -60,6 +60,16 @@ const config = await new ConfigBuilder()
|
|
|
60
60
|
export default config;
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
## Mixed Rendering
|
|
64
|
+
|
|
65
|
+
The React integration can participate in mixed-renderer apps in three ways:
|
|
66
|
+
|
|
67
|
+
- React can own the page or view directly.
|
|
68
|
+
- React can render nested component boundaries inside pages owned by another integration.
|
|
69
|
+
- React can render through non-React page, layout, or document shells when those shell components return strings.
|
|
70
|
+
|
|
71
|
+
When a non-React render pass enters a React-owned boundary, Ecopages hands that boundary back to the React renderer. When React renders through a non-React shell, that shell must serialize to HTML so React can insert the result into the final response without escaping it.
|
|
72
|
+
|
|
63
73
|
## Server and Client Graph Contract
|
|
64
74
|
|
|
65
75
|
The React integration supports Node.js modules and server-only code **only on the server execution graph**.
|
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.13",
|
|
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.13",
|
|
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.13",
|
|
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
|
},
|
|
@@ -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) {
|
|
@@ -275,6 +287,7 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
275
287
|
}
|
|
276
288
|
try {
|
|
277
289
|
let code = await fileSystem.readFile(tempPath);
|
|
290
|
+
code = rewriteRuntimeSpecifierAliases(code, this.context.getSpecifierMap());
|
|
278
291
|
code = injectHmrHandler(code);
|
|
279
292
|
await fileSystem.writeAsync(finalPath, code);
|
|
280
293
|
await fileSystem.removeAsync(tempPath).catch(() => {
|
package/src/react-renderer.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
77
77
|
*
|
|
78
78
|
* React pages embedded in a non-React HTML shell still need to expose the same
|
|
79
79
|
* page-data contract as fully React-owned documents so navigation and hydration
|
|
80
|
-
* can read one
|
|
80
|
+
* can read one shared document payload consistently.
|
|
81
81
|
*/
|
|
82
82
|
private buildRouterPageDataScript;
|
|
83
83
|
private getRouterDocumentAttributes;
|
|
@@ -111,14 +111,6 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
111
111
|
* HTML transformer state.
|
|
112
112
|
*/
|
|
113
113
|
private appendHydrationAssetsForFile;
|
|
114
|
-
/**
|
|
115
|
-
* Resolves metadata for direct `renderToResponse()` calls.
|
|
116
|
-
*
|
|
117
|
-
* View rendering bypasses the normal route-file pipeline, so metadata has to be
|
|
118
|
-
* evaluated here from either the component-level generator or the application
|
|
119
|
-
* default.
|
|
120
|
-
*/
|
|
121
|
-
private resolveViewMetadata;
|
|
122
114
|
/**
|
|
123
115
|
* Renders a non-React layout or HTML template and enforces that mixed shells
|
|
124
116
|
* return serialized HTML.
|
|
@@ -128,35 +120,23 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
128
120
|
*/
|
|
129
121
|
private renderNonReactShellComponent;
|
|
130
122
|
/**
|
|
131
|
-
* Renders one React component boundary
|
|
123
|
+
* Renders one React component boundary while preserving already-resolved child HTML.
|
|
132
124
|
*
|
|
133
|
-
* When
|
|
134
|
-
* the child payload must remain raw SSR output rather than a React
|
|
135
|
-
* child, otherwise React would escape it. This helper renders a unique
|
|
136
|
-
* through React and swaps that token back to the
|
|
125
|
+
* When nested boundary resolution has already produced child HTML for this
|
|
126
|
+
* boundary, the child payload must remain raw SSR output rather than a React
|
|
127
|
+
* string child, otherwise React would escape it. This helper renders a unique
|
|
128
|
+
* token through React and swaps that token back to the resolved HTML
|
|
129
|
+
* afterward.
|
|
137
130
|
*
|
|
138
|
-
* @param input Component render input
|
|
131
|
+
* @param input Component render input for the current boundary.
|
|
139
132
|
* @param context React-specific render context for stable token generation.
|
|
140
|
-
* @returns Serialized component HTML with
|
|
133
|
+
* @returns Serialized component HTML with resolved child markup preserved.
|
|
141
134
|
*/
|
|
142
135
|
private renderComponentHtml;
|
|
136
|
+
private restoreRuntimeChildHtml;
|
|
137
|
+
private renderQueuedChildrenToHtml;
|
|
138
|
+
private resolveQueuedBoundaryHtml;
|
|
143
139
|
private buildHydrationProps;
|
|
144
|
-
/**
|
|
145
|
-
* Produces the page body before the final HTML template is applied.
|
|
146
|
-
*
|
|
147
|
-
* This method owns the React/non-React layout split. React-managed layouts stay
|
|
148
|
-
* as React elements so they can stream normally; non-React layouts are rendered
|
|
149
|
-
* to HTML first and then passed through as serialized content.
|
|
150
|
-
*/
|
|
151
|
-
private composePageContent;
|
|
152
|
-
/**
|
|
153
|
-
* Wraps composed page content in the final document template.
|
|
154
|
-
*
|
|
155
|
-
* React-owned HTML templates stream directly. Non-React templates receive
|
|
156
|
-
* pre-rendered page HTML plus the canonical React page-data payload so the
|
|
157
|
-
* client runtime can recover page data after cross-integration handoff.
|
|
158
|
-
*/
|
|
159
|
-
private renderDocument;
|
|
160
140
|
/**
|
|
161
141
|
* Renders a React component for component-level orchestration.
|
|
162
142
|
*
|
|
@@ -165,13 +145,17 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
165
145
|
* - When an explicit component instance id is provided, a stable
|
|
166
146
|
* `data-eco-component-id` attribute is attached so island hydration can target it.
|
|
167
147
|
* - Without an explicit instance id, component renders remain plain SSR output.
|
|
168
|
-
* - When
|
|
148
|
+
* - When resolved child HTML is provided, that boundary is treated as a pure SSR
|
|
169
149
|
* composition step and does not emit hydration assets for the parent wrapper.
|
|
170
150
|
*
|
|
171
151
|
* This preserves DOM shape for global CSS/layout selectors while keeping a
|
|
172
152
|
* deterministic mount target per component instance.
|
|
173
153
|
*/
|
|
174
154
|
renderComponent(input: ComponentRenderInput): Promise<ComponentRenderResult>;
|
|
155
|
+
protected createComponentBoundaryRuntime(options: {
|
|
156
|
+
boundaryInput: ComponentRenderInput;
|
|
157
|
+
rendererCache: Map<string, IntegrationRenderer<any>>;
|
|
158
|
+
}): import("@ecopages/core").ComponentBoundaryRuntime;
|
|
175
159
|
/**
|
|
176
160
|
* Checks if the given file path corresponds to an MDX file based on configured extensions.
|
|
177
161
|
* @param filePath - The file path to check
|
package/src/react-renderer.js
CHANGED
|
@@ -6,29 +6,14 @@ import { rapidhash } from "@ecopages/core/hash";
|
|
|
6
6
|
import { AssetFactory } from "@ecopages/core/services/asset-processing-service";
|
|
7
7
|
import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from "@ecopages/core/router/navigation-coordinator";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { createElement } from "react";
|
|
9
|
+
import { createElement, Fragment } from "react";
|
|
10
10
|
import { renderToReadableStream, renderToString } from "react-dom/server";
|
|
11
11
|
import { PLUGIN_NAME } from "./react.plugin.js";
|
|
12
12
|
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 { ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
|
|
17
|
-
function decodeHtmlEntities(value) {
|
|
18
|
-
let decoded = value;
|
|
19
|
-
let previous;
|
|
20
|
-
do {
|
|
21
|
-
previous = decoded;
|
|
22
|
-
decoded = decoded.replaceAll(""", '"').replaceAll("'", "'").replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
23
|
-
} while (decoded !== previous);
|
|
24
|
-
return decoded;
|
|
25
|
-
}
|
|
26
|
-
function restoreEscapedComponentMarkers(html) {
|
|
27
|
-
return html.replace(
|
|
28
|
-
/&(?:amp;)?lt;eco-marker\b[\s\S]*?&(?:amp;)?gt;&(?:amp;)?lt;\/eco-marker&(?:amp;)?gt;/g,
|
|
29
|
-
(marker) => decodeHtmlEntities(marker)
|
|
30
|
-
);
|
|
31
|
-
}
|
|
16
|
+
import { getReactIslandComponentKey, ReactHydrationAssetService } from "./services/react-hydration-asset.service.js";
|
|
32
17
|
class ReactRenderError extends Error {
|
|
33
18
|
constructor(message) {
|
|
34
19
|
super(message);
|
|
@@ -121,7 +106,7 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
121
106
|
*
|
|
122
107
|
* React pages embedded in a non-React HTML shell still need to expose the same
|
|
123
108
|
* page-data contract as fully React-owned documents so navigation and hydration
|
|
124
|
-
* can read one
|
|
109
|
+
* can read one shared document payload consistently.
|
|
125
110
|
*/
|
|
126
111
|
buildRouterPageDataScript(pageProps) {
|
|
127
112
|
const safeJson = JSON.stringify(pageProps || {}).replace(/</g, "\\u003c");
|
|
@@ -180,25 +165,7 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
180
165
|
return;
|
|
181
166
|
}
|
|
182
167
|
const hydrationAssets = await this.buildRouteRenderAssets(filePath);
|
|
183
|
-
this.
|
|
184
|
-
...this.htmlTransformer.getProcessedDependencies(),
|
|
185
|
-
...hydrationAssets
|
|
186
|
-
]);
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* Resolves metadata for direct `renderToResponse()` calls.
|
|
190
|
-
*
|
|
191
|
-
* View rendering bypasses the normal route-file pipeline, so metadata has to be
|
|
192
|
-
* evaluated here from either the component-level generator or the application
|
|
193
|
-
* default.
|
|
194
|
-
*/
|
|
195
|
-
async resolveViewMetadata(view, props) {
|
|
196
|
-
return view.metadata ? await view.metadata({
|
|
197
|
-
params: {},
|
|
198
|
-
query: {},
|
|
199
|
-
props,
|
|
200
|
-
appConfig: this.appConfig
|
|
201
|
-
}) : this.appConfig.defaultMetadata;
|
|
168
|
+
this.appendProcessedDependencies(hydrationAssets);
|
|
202
169
|
}
|
|
203
170
|
/**
|
|
204
171
|
* Renders a non-React layout or HTML template and enforces that mixed shells
|
|
@@ -215,99 +182,77 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
215
182
|
throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
|
|
216
183
|
}
|
|
217
184
|
/**
|
|
218
|
-
* Renders one React component boundary
|
|
185
|
+
* Renders one React component boundary while preserving already-resolved child HTML.
|
|
219
186
|
*
|
|
220
|
-
* When
|
|
221
|
-
* the child payload must remain raw SSR output rather than a React
|
|
222
|
-
* child, otherwise React would escape it. This helper renders a unique
|
|
223
|
-
* through React and swaps that token back to the
|
|
187
|
+
* When nested boundary resolution has already produced child HTML for this
|
|
188
|
+
* boundary, the child payload must remain raw SSR output rather than a React
|
|
189
|
+
* string child, otherwise React would escape it. This helper renders a unique
|
|
190
|
+
* token through React and swaps that token back to the resolved HTML
|
|
191
|
+
* afterward.
|
|
224
192
|
*
|
|
225
|
-
* @param input Component render input
|
|
193
|
+
* @param input Component render input for the current boundary.
|
|
226
194
|
* @param context React-specific render context for stable token generation.
|
|
227
|
-
* @returns Serialized component HTML with
|
|
195
|
+
* @returns Serialized component HTML with resolved child markup preserved.
|
|
228
196
|
*/
|
|
229
|
-
renderComponentHtml(input, context) {
|
|
197
|
+
renderComponentHtml(input, context, runtimeContext) {
|
|
230
198
|
if (input.children === void 0) {
|
|
231
|
-
return
|
|
199
|
+
return this.normalizeBoundaryArtifactHtml(
|
|
232
200
|
renderToString(createElement(this.asReactComponent(input.component), input.props))
|
|
233
201
|
);
|
|
234
202
|
}
|
|
203
|
+
const resolvedChildHtml = typeof input.children === "string" ? input.children : String(input.children ?? "");
|
|
235
204
|
const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
|
|
205
|
+
if (runtimeContext) {
|
|
206
|
+
runtimeContext.rawChildrenToken = rawChildrenToken;
|
|
207
|
+
runtimeContext.rawChildrenHtml = resolvedChildHtml;
|
|
208
|
+
}
|
|
236
209
|
const html = renderToString(
|
|
237
210
|
createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
|
|
238
211
|
);
|
|
239
|
-
return
|
|
212
|
+
return this.normalizeBoundaryArtifactHtml(html.split(rawChildrenToken).join(resolvedChildHtml));
|
|
240
213
|
}
|
|
241
|
-
|
|
242
|
-
if (!
|
|
243
|
-
return
|
|
214
|
+
restoreRuntimeChildHtml(html, runtimeContext) {
|
|
215
|
+
if (!runtimeContext?.rawChildrenToken || runtimeContext.rawChildrenHtml === void 0) {
|
|
216
|
+
return html;
|
|
244
217
|
}
|
|
245
|
-
|
|
246
|
-
return hydrationProps;
|
|
218
|
+
return html.split(runtimeContext.rawChildrenToken).join(runtimeContext.rawChildrenHtml);
|
|
247
219
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
* This method owns the React/non-React layout split. React-managed layouts stay
|
|
252
|
-
* as React elements so they can stream normally; non-React layouts are rendered
|
|
253
|
-
* to HTML first and then passed through as serialized content.
|
|
254
|
-
*/
|
|
255
|
-
async composePageContent(options) {
|
|
256
|
-
const pageElement = createElement(options.Page, options.pageProps);
|
|
257
|
-
const pageHtml = restoreEscapedComponentMarkers(renderToString(pageElement));
|
|
258
|
-
const layoutProps = options.locals ? { locals: options.locals } : {};
|
|
259
|
-
if (!options.Layout) {
|
|
260
|
-
return { contentNode: pageElement, contentHtml: pageHtml };
|
|
261
|
-
}
|
|
262
|
-
if (this.isReactManagedComponent(options.Layout)) {
|
|
263
|
-
const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
|
|
264
|
-
return {
|
|
265
|
-
contentNode: layoutElement,
|
|
266
|
-
contentHtml: restoreEscapedComponentMarkers(renderToString(layoutElement))
|
|
267
|
-
};
|
|
220
|
+
async renderQueuedChildrenToHtml(children, runtimeContext, queuedResolutionsByToken, resolveToken) {
|
|
221
|
+
if (children === void 0) {
|
|
222
|
+
return void 0;
|
|
268
223
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
{ ...layoutProps, children: pageHtml },
|
|
272
|
-
"Layout"
|
|
224
|
+
let html = this.normalizeBoundaryArtifactHtml(
|
|
225
|
+
renderToString(createElement(Fragment, null, children))
|
|
273
226
|
);
|
|
274
|
-
|
|
227
|
+
html = this.restoreRuntimeChildHtml(html, runtimeContext);
|
|
228
|
+
html = await this.resolveQueuedBoundaryTokens(html, queuedResolutionsByToken, resolveToken);
|
|
229
|
+
return html;
|
|
275
230
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
return html.split(rawChildrenToken).join(options.contentHtml);
|
|
231
|
+
async resolveQueuedBoundaryHtml(html, runtimeContext) {
|
|
232
|
+
return this.resolveRendererOwnedQueuedBoundaryHtml({
|
|
233
|
+
html,
|
|
234
|
+
runtimeContext,
|
|
235
|
+
queueLabel: "React",
|
|
236
|
+
renderQueuedChildren: async (children, currentRuntimeContext, queuedResolutionsByToken, resolveToken) => {
|
|
237
|
+
const renderedHtml = await this.renderQueuedChildrenToHtml(
|
|
238
|
+
children,
|
|
239
|
+
currentRuntimeContext,
|
|
240
|
+
queuedResolutionsByToken,
|
|
241
|
+
resolveToken
|
|
242
|
+
);
|
|
243
|
+
return {
|
|
244
|
+
assets: [],
|
|
245
|
+
html: renderedHtml
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
buildHydrationProps(props) {
|
|
251
|
+
if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
|
|
252
|
+
return props ?? {};
|
|
299
253
|
}
|
|
300
|
-
const
|
|
301
|
-
return
|
|
302
|
-
this.asNonReactShellComponent(options.HtmlTemplate),
|
|
303
|
-
{
|
|
304
|
-
metadata: options.metadata,
|
|
305
|
-
pageProps: options.pageProps,
|
|
306
|
-
children: options.contentHtml,
|
|
307
|
-
headContent
|
|
308
|
-
},
|
|
309
|
-
"HtmlTemplate"
|
|
310
|
-
);
|
|
254
|
+
const { locals: _locals, ...hydrationProps } = props;
|
|
255
|
+
return hydrationProps;
|
|
311
256
|
}
|
|
312
257
|
/**
|
|
313
258
|
* Renders a React component for component-level orchestration.
|
|
@@ -317,17 +262,49 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
317
262
|
* - When an explicit component instance id is provided, a stable
|
|
318
263
|
* `data-eco-component-id` attribute is attached so island hydration can target it.
|
|
319
264
|
* - Without an explicit instance id, component renders remain plain SSR output.
|
|
320
|
-
* - When
|
|
265
|
+
* - When resolved child HTML is provided, that boundary is treated as a pure SSR
|
|
321
266
|
* composition step and does not emit hydration assets for the parent wrapper.
|
|
322
267
|
*
|
|
323
268
|
* This preserves DOM shape for global CSS/layout selectors while keeping a
|
|
324
269
|
* deterministic mount target per component instance.
|
|
325
270
|
*/
|
|
326
271
|
async renderComponent(input) {
|
|
272
|
+
const runtimeContext = this.getQueuedBoundaryRuntime(input);
|
|
273
|
+
if (!this.isReactManagedComponent(input.component)) {
|
|
274
|
+
let props = input.props;
|
|
275
|
+
if (input.children !== void 0) {
|
|
276
|
+
props = {
|
|
277
|
+
...input.props,
|
|
278
|
+
children: typeof input.children === "string" ? input.children : String(input.children ?? "")
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const html2 = await this.renderNonReactShellComponent(
|
|
282
|
+
this.asNonReactShellComponent(input.component),
|
|
283
|
+
props,
|
|
284
|
+
"Component"
|
|
285
|
+
);
|
|
286
|
+
const hasDependencies = Boolean(input.component.config?.dependencies);
|
|
287
|
+
const canResolveAssets = typeof this.assetProcessingService?.processDependencies === "function";
|
|
288
|
+
const assets2 = hasDependencies && canResolveAssets ? await this.processComponentDependencies([input.component]) : void 0;
|
|
289
|
+
const queuedBoundaryResolution2 = await this.resolveQueuedBoundaryHtml(html2, runtimeContext);
|
|
290
|
+
const mergedAssets2 = this.htmlTransformer.dedupeProcessedAssets([
|
|
291
|
+
...assets2 ?? [],
|
|
292
|
+
...queuedBoundaryResolution2.assets
|
|
293
|
+
]);
|
|
294
|
+
return {
|
|
295
|
+
html: queuedBoundaryResolution2.html,
|
|
296
|
+
canAttachAttributes: true,
|
|
297
|
+
rootTag: this.getRootTagName(queuedBoundaryResolution2.html),
|
|
298
|
+
integrationName: this.name,
|
|
299
|
+
assets: mergedAssets2.length > 0 ? mergedAssets2 : void 0
|
|
300
|
+
};
|
|
301
|
+
}
|
|
327
302
|
const componentConfig = input.component.config;
|
|
328
303
|
const context = input.integrationContext ?? {};
|
|
329
304
|
const hasResolvedChildHtml = input.children !== void 0;
|
|
330
|
-
let html = this.renderComponentHtml(input, context);
|
|
305
|
+
let html = this.renderComponentHtml(input, context, runtimeContext);
|
|
306
|
+
const queuedBoundaryResolution = await this.resolveQueuedBoundaryHtml(html, runtimeContext);
|
|
307
|
+
html = queuedBoundaryResolution.html;
|
|
331
308
|
let canAttachAttributes = hasSingleRootElement(html);
|
|
332
309
|
let rootTag = this.getRootTagName(html);
|
|
333
310
|
const componentFile = componentConfig?.__eco?.file;
|
|
@@ -335,26 +312,40 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
335
312
|
let assets;
|
|
336
313
|
if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
|
|
337
314
|
const componentInstanceId = context.componentInstanceId;
|
|
338
|
-
assets = await this.hydrationAssetService.buildComponentRenderAssets(
|
|
339
|
-
componentFile,
|
|
340
|
-
componentInstanceId,
|
|
341
|
-
this.buildHydrationProps(input.props),
|
|
342
|
-
componentConfig
|
|
343
|
-
);
|
|
315
|
+
assets = await this.hydrationAssetService.buildComponentRenderAssets(componentFile, componentConfig);
|
|
344
316
|
rootAttributes = {
|
|
345
317
|
"data-eco-component-id": componentInstanceId,
|
|
318
|
+
"data-eco-component-key": getReactIslandComponentKey(componentFile, componentConfig),
|
|
346
319
|
"data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
|
|
347
320
|
};
|
|
348
321
|
}
|
|
322
|
+
const mergedAssets = this.htmlTransformer.dedupeProcessedAssets([
|
|
323
|
+
...assets ?? [],
|
|
324
|
+
...queuedBoundaryResolution.assets
|
|
325
|
+
]);
|
|
349
326
|
return {
|
|
350
327
|
html,
|
|
351
328
|
canAttachAttributes,
|
|
352
329
|
rootTag,
|
|
353
330
|
integrationName: this.name,
|
|
354
331
|
rootAttributes,
|
|
355
|
-
assets
|
|
332
|
+
assets: mergedAssets.length > 0 ? mergedAssets : void 0
|
|
356
333
|
};
|
|
357
334
|
}
|
|
335
|
+
createComponentBoundaryRuntime(options) {
|
|
336
|
+
return this.createQueuedBoundaryRuntime({
|
|
337
|
+
boundaryInput: options.boundaryInput,
|
|
338
|
+
rendererCache: options.rendererCache,
|
|
339
|
+
createRuntimeContext: (integrationContext, rendererCache) => ({
|
|
340
|
+
rendererCache,
|
|
341
|
+
componentInstanceScope: integrationContext.componentInstanceId,
|
|
342
|
+
nextBoundaryId: 0,
|
|
343
|
+
queuedResolutions: [],
|
|
344
|
+
rawChildrenToken: void 0,
|
|
345
|
+
rawChildrenHtml: void 0
|
|
346
|
+
})
|
|
347
|
+
});
|
|
348
|
+
}
|
|
358
349
|
/**
|
|
359
350
|
* Checks if the given file path corresponds to an MDX file based on configured extensions.
|
|
360
351
|
* @param filePath - The file path to check
|
|
@@ -540,18 +531,19 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
540
531
|
query,
|
|
541
532
|
safeLocals
|
|
542
533
|
});
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
534
|
+
return await this.renderPageWithDocumentShell({
|
|
535
|
+
page: {
|
|
536
|
+
component: Page,
|
|
537
|
+
props: { params, query, ...props, locals: pageLocals }
|
|
538
|
+
},
|
|
539
|
+
layout: Layout ? {
|
|
540
|
+
component: Layout,
|
|
541
|
+
props: locals ? { locals } : {}
|
|
542
|
+
} : void 0,
|
|
543
|
+
htmlTemplate: HtmlTemplate,
|
|
551
544
|
metadata,
|
|
552
545
|
pageProps: allPageProps,
|
|
553
|
-
|
|
554
|
-
contentHtml
|
|
546
|
+
documentProps: !this.isReactManagedComponent(HtmlTemplate) && ReactRenderer.routerAdapter ? { headContent: this.buildRouterPageDataScript(allPageProps) } : void 0
|
|
555
547
|
});
|
|
556
548
|
} catch (error) {
|
|
557
549
|
throw this.createRenderError("Failed to render component", error);
|
|
@@ -614,38 +606,41 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
614
606
|
const ViewComponent = this.asReactComponent(view);
|
|
615
607
|
const normalizedProps = props ?? {};
|
|
616
608
|
if (ctx.partial) {
|
|
617
|
-
|
|
618
|
-
|
|
609
|
+
return this.renderPartialViewResponse({
|
|
610
|
+
view,
|
|
611
|
+
props,
|
|
612
|
+
ctx,
|
|
613
|
+
renderInline: async () => await renderToReadableStream(createElement(ViewComponent, normalizedProps))
|
|
614
|
+
});
|
|
619
615
|
}
|
|
620
616
|
const HtmlTemplate = await this.getHtmlTemplate();
|
|
621
617
|
const metadata = await this.resolveViewMetadata(view, props);
|
|
622
618
|
await this.prepareViewDependencies(view, Layout);
|
|
623
619
|
await this.appendHydrationAssetsForFile(viewConfig?.__eco?.file);
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
pageProps: normalizedProps
|
|
620
|
+
const viewRender = await this.renderComponentBoundary({
|
|
621
|
+
component: view,
|
|
622
|
+
props: normalizedProps
|
|
628
623
|
});
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
624
|
+
const layoutRender = Layout ? await this.renderComponentBoundary({
|
|
625
|
+
component: Layout,
|
|
626
|
+
props: {},
|
|
627
|
+
children: viewRender.html
|
|
628
|
+
}) : void 0;
|
|
629
|
+
const documentRender = await this.renderComponentBoundary({
|
|
630
|
+
component: HtmlTemplate,
|
|
631
|
+
props: {
|
|
632
|
+
metadata,
|
|
633
|
+
pageProps: normalizedProps,
|
|
634
|
+
...!this.isReactManagedComponent(HtmlTemplate) && ReactRenderer.routerAdapter ? { headContent: this.buildRouterPageDataScript(normalizedProps) } : {}
|
|
635
|
+
},
|
|
636
|
+
children: layoutRender?.html ?? viewRender.html
|
|
637
|
+
});
|
|
638
|
+
this.appendProcessedDependencies(viewRender.assets, layoutRender?.assets, documentRender.assets);
|
|
639
|
+
const transformedHtml = await this.finalizeResolvedHtml({
|
|
640
|
+
html: `${this.DOC_TYPE}${documentRender.html}`,
|
|
641
|
+
partial: false,
|
|
642
|
+
documentAttributes: this.getRouterDocumentAttributes()
|
|
635
643
|
});
|
|
636
|
-
const transformedResponse = await this.htmlTransformer.transform(
|
|
637
|
-
new Response(body, {
|
|
638
|
-
headers: { "Content-Type": "text/html" }
|
|
639
|
-
})
|
|
640
|
-
);
|
|
641
|
-
let transformedHtml = await transformedResponse.text();
|
|
642
|
-
const documentAttributes = this.getRouterDocumentAttributes();
|
|
643
|
-
if (documentAttributes) {
|
|
644
|
-
transformedHtml = this.htmlTransformer.applyAttributesToHtmlElement(
|
|
645
|
-
transformedHtml,
|
|
646
|
-
documentAttributes
|
|
647
|
-
);
|
|
648
|
-
}
|
|
649
644
|
return this.createHtmlResponse(transformedHtml, ctx);
|
|
650
645
|
} catch (error) {
|
|
651
646
|
throw this.createRenderError("Failed to render view", error);
|
package/src/react.plugin.d.ts
CHANGED
|
@@ -10,7 +10,6 @@ import type { CompileOptions } from '@mdx-js/mdx';
|
|
|
10
10
|
import type React from 'react';
|
|
11
11
|
import { ReactRenderer } from './react-renderer.js';
|
|
12
12
|
import type { ReactRouterAdapter } from './router-adapter.js';
|
|
13
|
-
import type { ComponentBoundaryPolicyInput } from '@ecopages/core/plugins/integration-plugin';
|
|
14
13
|
/**
|
|
15
14
|
* MDX configuration options for the React plugin
|
|
16
15
|
*/
|
|
@@ -139,17 +138,6 @@ export declare class ReactPlugin extends IntegrationPlugin<React.JSX.Element> {
|
|
|
139
138
|
*/
|
|
140
139
|
getHmrStrategy(): HmrStrategy | undefined;
|
|
141
140
|
getRuntimeSpecifierMap(): Record<string, string>;
|
|
142
|
-
/**
|
|
143
|
-
* Declares React's boundary deferral rule for cross-integration rendering.
|
|
144
|
-
*
|
|
145
|
-
* React defers when a render pass owned by another integration enters a React
|
|
146
|
-
* component boundary. That boundary is then resolved later through the marker
|
|
147
|
-
* graph stage using the React renderer.
|
|
148
|
-
*
|
|
149
|
-
* @param input Boundary metadata for the active render pass.
|
|
150
|
-
* @returns `true` when the boundary should be deferred into the marker pass.
|
|
151
|
-
*/
|
|
152
|
-
shouldDeferComponentBoundary(input: ComponentBoundaryPolicyInput): boolean;
|
|
153
141
|
}
|
|
154
142
|
/**
|
|
155
143
|
* Factory function to create a React plugin instance
|
package/src/react.plugin.js
CHANGED
|
@@ -71,6 +71,7 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
71
71
|
if (this.runtimeDependenciesInitialized) {
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
|
+
this.runtimeBundleService.setRootDir(this.appConfig?.rootDir);
|
|
74
75
|
this.integrationDependencies.unshift(...this.runtimeBundleService.getDependencies());
|
|
75
76
|
this.runtimeDependenciesInitialized = true;
|
|
76
77
|
}
|
|
@@ -134,19 +135,6 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
134
135
|
getRuntimeSpecifierMap() {
|
|
135
136
|
return this.runtimeBundleService.getSpecifierMap();
|
|
136
137
|
}
|
|
137
|
-
/**
|
|
138
|
-
* Declares React's boundary deferral rule for cross-integration rendering.
|
|
139
|
-
*
|
|
140
|
-
* React defers when a render pass owned by another integration enters a React
|
|
141
|
-
* component boundary. That boundary is then resolved later through the marker
|
|
142
|
-
* graph stage using the React renderer.
|
|
143
|
-
*
|
|
144
|
-
* @param input Boundary metadata for the active render pass.
|
|
145
|
-
* @returns `true` when the boundary should be deferred into the marker pass.
|
|
146
|
-
*/
|
|
147
|
-
shouldDeferComponentBoundary(input) {
|
|
148
|
-
return input.targetIntegration === this.name && input.currentIntegration !== this.name;
|
|
149
|
-
}
|
|
150
138
|
}
|
|
151
139
|
function reactPlugin(options) {
|
|
152
140
|
return new ReactPlugin(options);
|
|
@@ -4,9 +4,9 @@ import {
|
|
|
4
4
|
getReactClientGraphAllowSpecifiers,
|
|
5
5
|
getReactRuntimeExternalSpecifiers
|
|
6
6
|
} from "../utils/react-runtime-specifier-map.js";
|
|
7
|
-
import { createForeignJsxOverridePlugin } from "../utils/foreign-jsx-override-plugin.js";
|
|
8
7
|
import { createUseSyncExternalStoreShimPlugin } from "../utils/use-sync-external-store-shim-plugin.js";
|
|
9
8
|
import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
|
|
9
|
+
import { createForeignJsxOverridePlugin } from "@ecopages/core/plugins/foreign-jsx-override-plugin";
|
|
10
10
|
import { ReactRuntimeBundleService } from "./react-runtime-bundle.service.js";
|
|
11
11
|
class ReactBundleService {
|
|
12
12
|
runtimeBundleService;
|
|
@@ -14,6 +14,7 @@ class ReactBundleService {
|
|
|
14
14
|
constructor(config) {
|
|
15
15
|
this.config = config;
|
|
16
16
|
this.runtimeBundleService = new ReactRuntimeBundleService({
|
|
17
|
+
rootDir: config.rootDir,
|
|
17
18
|
routerAdapter: config.routerAdapter
|
|
18
19
|
});
|
|
19
20
|
}
|
|
@@ -49,9 +50,10 @@ class ReactBundleService {
|
|
|
49
50
|
declaredModules,
|
|
50
51
|
alwaysAllowSpecifiers: getReactClientGraphAllowSpecifiers([], this.config.routerAdapter)
|
|
51
52
|
});
|
|
52
|
-
const foreignJsxOverridePlugin = createForeignJsxOverridePlugin(
|
|
53
|
+
const foreignJsxOverridePlugin = createForeignJsxOverridePlugin({
|
|
53
54
|
name: "react-renderer-foreign-jsx-override",
|
|
54
|
-
|
|
55
|
+
hostJsxImportSource: this.config.jsxImportSource ?? "react",
|
|
56
|
+
foreignExtensions: this.config.nonReactExtensions ?? []
|
|
55
57
|
});
|
|
56
58
|
const runtimeAliasPlugin = this.createRuntimeAliasPlugin(runtimeSpecifierMap);
|
|
57
59
|
const useSyncExternalStoreShimPlugin = createUseSyncExternalStoreShimPlugin({
|
|
@@ -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
|
};
|
|
@@ -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
|
|
@@ -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. */
|
|
@@ -252,17 +252,22 @@ const resolveComponent = () => {
|
|
|
252
252
|
};
|
|
253
253
|
|
|
254
254
|
const mount = () => {
|
|
255
|
-
const
|
|
255
|
+
const targets = document.querySelectorAll(${targetSelector});
|
|
256
256
|
const Component = resolveComponent();
|
|
257
|
-
if (!
|
|
257
|
+
if (!Component || targets.length === 0) {
|
|
258
258
|
return;
|
|
259
259
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
});
|
|
266
271
|
};
|
|
267
272
|
|
|
268
273
|
if (document.readyState === "loading") {
|
|
@@ -272,7 +277,7 @@ if (document.readyState === "loading") {
|
|
|
272
277
|
}
|
|
273
278
|
`.trim();
|
|
274
279
|
}
|
|
275
|
-
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()`;
|
|
276
281
|
}
|
|
277
282
|
export {
|
|
278
283
|
createHydrationScript,
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
|
|
2
|
-
interface ForeignJsxOverrideOptions {
|
|
3
|
-
jsxImportSource: string;
|
|
4
|
-
name?: string;
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Esbuild plugin that overrides the JSX import source for non-host integration
|
|
8
|
-
* files (`.lit.tsx`, `.kita.tsx`, etc.) when bundled into a host client bundle.
|
|
9
|
-
*
|
|
10
|
-
* Without this plugin, non-host component files inherit the project-level
|
|
11
|
-
* `jsxImportSource` from tsconfig (typically `@kitajs/html`), which produces
|
|
12
|
-
* HTML strings from JSX. When the host framework calls those functions during
|
|
13
|
-
* hydration, it renders the string as a text node instead of a DOM element.
|
|
14
|
-
*
|
|
15
|
-
* This plugin prepends the host's `@jsxImportSource` pragma so esbuild compiles
|
|
16
|
-
* their JSX to the host framework's element creation calls.
|
|
17
|
-
*/
|
|
18
|
-
export declare function createForeignJsxOverridePlugin(nonReactExtensions: string[], options: ForeignJsxOverrideOptions): EcoBuildPlugin;
|
|
19
|
-
export {};
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
function createForeignJsxOverridePlugin(nonReactExtensions, options) {
|
|
3
|
-
const extensions = nonReactExtensions.filter((ext) => ext.endsWith(".tsx") || ext.endsWith(".jsx"));
|
|
4
|
-
if (extensions.length === 0) {
|
|
5
|
-
return {
|
|
6
|
-
name: options.name ?? "react-foreign-jsx-override",
|
|
7
|
-
setup() {
|
|
8
|
-
}
|
|
9
|
-
};
|
|
10
|
-
}
|
|
11
|
-
function matchesNonReactExtension(id) {
|
|
12
|
-
for (const ext of extensions) {
|
|
13
|
-
if (id.endsWith(ext)) {
|
|
14
|
-
return true;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
const pragma = `/** @jsxImportSource ${options.jsxImportSource} */
|
|
20
|
-
`;
|
|
21
|
-
const filter = new RegExp(`(${extensions.map((e) => e.replace(".", "\\.")).join("|")})$`);
|
|
22
|
-
return {
|
|
23
|
-
name: options.name ?? "react-foreign-jsx-override",
|
|
24
|
-
setup(build) {
|
|
25
|
-
build.onLoad({ filter }, (args) => {
|
|
26
|
-
if (!matchesNonReactExtension(args.path)) {
|
|
27
|
-
return void 0;
|
|
28
|
-
}
|
|
29
|
-
const source = readFileSync(args.path, "utf-8");
|
|
30
|
-
if (source.includes("@jsxImportSource")) {
|
|
31
|
-
return void 0;
|
|
32
|
-
}
|
|
33
|
-
return {
|
|
34
|
-
contents: pragma + source,
|
|
35
|
-
loader: "tsx"
|
|
36
|
-
};
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
export {
|
|
42
|
-
createForeignJsxOverridePlugin
|
|
43
|
-
};
|