@ecopages/react 0.2.0-alpha.11 → 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 +3 -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 +4 -4
- package/src/react-renderer.js +13 -10
- package/src/react.plugin.js +1 -0
- 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
|
@@ -10,6 +10,9 @@ All notable changes to `@ecopages/react` are documented here.
|
|
|
10
10
|
|
|
11
11
|
- Fixed development hydration, router HMR ownership, and page bootstraps across Bun, Vite, and Nitro flows.
|
|
12
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.
|
|
13
16
|
|
|
14
17
|
### Features
|
|
15
18
|
|
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
|
},
|
|
@@ -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
|
@@ -130,14 +130,14 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
130
130
|
/**
|
|
131
131
|
* Renders one React component boundary for marker-graph orchestration.
|
|
132
132
|
*
|
|
133
|
-
* When the marker resolver has already
|
|
133
|
+
* When the marker resolver has already resolved child HTML for this boundary,
|
|
134
134
|
* the child payload must remain raw SSR output rather than a React string
|
|
135
135
|
* child, otherwise React would escape it. This helper renders a unique token
|
|
136
|
-
* through React and swaps that token back to the
|
|
136
|
+
* through React and swaps that token back to the resolved HTML afterward.
|
|
137
137
|
*
|
|
138
138
|
* @param input Component render input reconstructed from marker metadata.
|
|
139
139
|
* @param context React-specific render context for stable token generation.
|
|
140
|
-
* @returns Serialized component HTML with
|
|
140
|
+
* @returns Serialized component HTML with resolved child markup preserved.
|
|
141
141
|
*/
|
|
142
142
|
private renderComponentHtml;
|
|
143
143
|
private buildHydrationProps;
|
|
@@ -165,7 +165,7 @@ export declare class ReactRenderer extends IntegrationRenderer<ReactNode> {
|
|
|
165
165
|
* - When an explicit component instance id is provided, a stable
|
|
166
166
|
* `data-eco-component-id` attribute is attached so island hydration can target it.
|
|
167
167
|
* - Without an explicit instance id, component renders remain plain SSR output.
|
|
168
|
-
* - When
|
|
168
|
+
* - When resolved child HTML is provided, that boundary is treated as a pure SSR
|
|
169
169
|
* composition step and does not emit hydration assets for the parent wrapper.
|
|
170
170
|
*
|
|
171
171
|
* This preserves DOM shape for global CSS/layout selectors while keeping a
|
package/src/react-renderer.js
CHANGED
|
@@ -13,7 +13,10 @@ 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";
|
|
17
20
|
function decodeHtmlEntities(value) {
|
|
18
21
|
let decoded = value;
|
|
19
22
|
let previous;
|
|
@@ -217,14 +220,14 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
217
220
|
/**
|
|
218
221
|
* Renders one React component boundary for marker-graph orchestration.
|
|
219
222
|
*
|
|
220
|
-
* When the marker resolver has already
|
|
223
|
+
* When the marker resolver has already resolved child HTML for this boundary,
|
|
221
224
|
* the child payload must remain raw SSR output rather than a React string
|
|
222
225
|
* child, otherwise React would escape it. This helper renders a unique token
|
|
223
|
-
* through React and swaps that token back to the
|
|
226
|
+
* through React and swaps that token back to the resolved HTML afterward.
|
|
224
227
|
*
|
|
225
228
|
* @param input Component render input reconstructed from marker metadata.
|
|
226
229
|
* @param context React-specific render context for stable token generation.
|
|
227
|
-
* @returns Serialized component HTML with
|
|
230
|
+
* @returns Serialized component HTML with resolved child markup preserved.
|
|
228
231
|
*/
|
|
229
232
|
renderComponentHtml(input, context) {
|
|
230
233
|
if (input.children === void 0) {
|
|
@@ -232,11 +235,12 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
232
235
|
renderToString(createElement(this.asReactComponent(input.component), input.props))
|
|
233
236
|
);
|
|
234
237
|
}
|
|
238
|
+
const resolvedChildHtml = typeof input.children === "string" ? input.children : String(input.children ?? "");
|
|
235
239
|
const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
|
|
236
240
|
const html = renderToString(
|
|
237
241
|
createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
|
|
238
242
|
);
|
|
239
|
-
return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(
|
|
243
|
+
return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(resolvedChildHtml));
|
|
240
244
|
}
|
|
241
245
|
buildHydrationProps(props) {
|
|
242
246
|
if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
|
|
@@ -295,10 +299,10 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
295
299
|
)
|
|
296
300
|
)
|
|
297
301
|
);
|
|
298
|
-
return html.split(rawChildrenToken).join(options.contentHtml);
|
|
302
|
+
return this.DOC_TYPE + html.split(rawChildrenToken).join(options.contentHtml);
|
|
299
303
|
}
|
|
300
304
|
const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
|
|
301
|
-
return this.renderNonReactShellComponent(
|
|
305
|
+
return this.DOC_TYPE + await this.renderNonReactShellComponent(
|
|
302
306
|
this.asNonReactShellComponent(options.HtmlTemplate),
|
|
303
307
|
{
|
|
304
308
|
metadata: options.metadata,
|
|
@@ -317,7 +321,7 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
317
321
|
* - When an explicit component instance id is provided, a stable
|
|
318
322
|
* `data-eco-component-id` attribute is attached so island hydration can target it.
|
|
319
323
|
* - Without an explicit instance id, component renders remain plain SSR output.
|
|
320
|
-
* - When
|
|
324
|
+
* - When resolved child HTML is provided, that boundary is treated as a pure SSR
|
|
321
325
|
* composition step and does not emit hydration assets for the parent wrapper.
|
|
322
326
|
*
|
|
323
327
|
* This preserves DOM shape for global CSS/layout selectors while keeping a
|
|
@@ -337,12 +341,11 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
337
341
|
const componentInstanceId = context.componentInstanceId;
|
|
338
342
|
assets = await this.hydrationAssetService.buildComponentRenderAssets(
|
|
339
343
|
componentFile,
|
|
340
|
-
componentInstanceId,
|
|
341
|
-
this.buildHydrationProps(input.props),
|
|
342
344
|
componentConfig
|
|
343
345
|
);
|
|
344
346
|
rootAttributes = {
|
|
345
347
|
"data-eco-component-id": componentInstanceId,
|
|
348
|
+
"data-eco-component-key": getReactIslandComponentKey(componentFile, componentConfig),
|
|
346
349
|
"data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
|
|
347
350
|
};
|
|
348
351
|
}
|
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
|
}
|
|
@@ -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
|
-
};
|