@ecopages/react 0.2.0-alpha.10 → 0.2.0-alpha.11
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 +9 -8
- package/package.json +3 -3
- package/src/react-hmr-strategy.d.ts +3 -2
- package/src/react-hmr-strategy.js +21 -1
- package/src/react-renderer.d.ts +20 -16
- package/src/react-renderer.js +92 -39
- package/src/react.plugin.js +1 -0
- package/src/services/react-bundle.service.d.ts +3 -1
- package/src/services/react-bundle.service.js +18 -2
- package/src/services/react-page-module.service.js +4 -1
- package/src/utils/client-graph-boundary-plugin.js +2 -2
- package/src/utils/declared-modules.js +4 -1
- package/src/utils/foreign-jsx-override-plugin.d.ts +19 -0
- package/src/utils/foreign-jsx-override-plugin.js +43 -0
- package/src/utils/hydration-scripts.js +17 -10
package/CHANGELOG.md
CHANGED
|
@@ -6,17 +6,18 @@ 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
|
-
- **React MDX**: Inlined MDX support directly into the React integration for a zero-config setup, including Node-native compatibility for experimental startup.
|
|
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.
|
|
14
13
|
|
|
15
|
-
###
|
|
14
|
+
### Features
|
|
16
15
|
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
- Added built-in React MDX support and reachability-based hydration analysis for React page bundles.
|
|
17
|
+
|
|
18
|
+
### Refactoring
|
|
19
|
+
|
|
20
|
+
- Consolidated React bundling, hydration, and runtime state behind shared service boundaries and `window.__ECO_PAGES__`.
|
|
20
21
|
|
|
21
22
|
---
|
|
22
23
|
|
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.11",
|
|
4
4
|
"description": "React integration for Ecopages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -53,14 +53,14 @@
|
|
|
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.11",
|
|
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.
|
|
63
|
+
"@ecopages/file-system": "0.2.0-alpha.11",
|
|
64
64
|
"@ecopages/logger": "latest",
|
|
65
65
|
"@mdx-js/esbuild": "^3.0.1",
|
|
66
66
|
"@mdx-js/mdx": "^3.1.0",
|
|
@@ -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;
|
|
@@ -128,6 +128,7 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
128
128
|
* @returns True if bundling was successful
|
|
129
129
|
*/
|
|
130
130
|
private bundleReactEntrypoint;
|
|
131
|
+
private resolveTempOutputPath;
|
|
131
132
|
/**
|
|
132
133
|
* Encodes dynamic route segments (brackets) in file paths.
|
|
133
134
|
* Converts `[slug]` to `_slug_` to avoid filesystem issues.
|
|
@@ -225,13 +225,33 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
225
225
|
appLogger.error(`No output file generated for ${entrypointPath}`);
|
|
226
226
|
return false;
|
|
227
227
|
}
|
|
228
|
-
const
|
|
228
|
+
const resolvedTempFile = await this.resolveTempOutputPath(tempFile);
|
|
229
|
+
if (!resolvedTempFile) {
|
|
230
|
+
appLogger.debug(`Skipping stale temp output for ${outputUrl}: ${tempFile}`);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
const processed = await this.processOutput(resolvedTempFile, outputPath, outputUrl);
|
|
229
234
|
return processed;
|
|
230
235
|
} catch (error) {
|
|
231
236
|
appLogger.error(`Error bundling ${entrypointPath}:`, error);
|
|
232
237
|
return false;
|
|
233
238
|
}
|
|
234
239
|
}
|
|
240
|
+
async resolveTempOutputPath(tempPath) {
|
|
241
|
+
if (fileSystem.exists(tempPath)) {
|
|
242
|
+
return tempPath;
|
|
243
|
+
}
|
|
244
|
+
if (!tempPath.includes("[hash]")) {
|
|
245
|
+
return tempPath;
|
|
246
|
+
}
|
|
247
|
+
const directory = path.dirname(tempPath);
|
|
248
|
+
const pattern = path.basename(tempPath).replaceAll("[hash]", "*");
|
|
249
|
+
const matches = await fileSystem.glob([pattern], { cwd: directory });
|
|
250
|
+
if (matches.length === 0) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
return path.isAbsolute(matches[0]) ? matches[0] : path.join(directory, matches[0]);
|
|
254
|
+
}
|
|
235
255
|
/**
|
|
236
256
|
* Encodes dynamic route segments (brackets) in file paths.
|
|
237
257
|
* Converts `[slug]` to `_slug_` to avoid filesystem issues.
|
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 stitched 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 stitched 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 stitched 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 stitched 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
|
@@ -14,6 +14,21 @@ 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
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
|
+
}
|
|
17
32
|
class ReactRenderError extends Error {
|
|
18
33
|
constructor(message) {
|
|
19
34
|
super(message);
|
|
@@ -53,7 +68,9 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
53
68
|
this.bundleService = new ReactBundleService({
|
|
54
69
|
rootDir: this.appConfig.rootDir,
|
|
55
70
|
routerAdapter: ReactRenderer.routerAdapter,
|
|
56
|
-
mdxCompilerOptions: ReactRenderer.mdxCompilerOptions
|
|
71
|
+
mdxCompilerOptions: ReactRenderer.mdxCompilerOptions,
|
|
72
|
+
jsxImportSource: (this.appConfig.integrations ?? []).find((integration) => integration.name === this.name)?.jsxImportSource,
|
|
73
|
+
nonReactExtensions: (this.appConfig.integrations ?? []).filter((integration) => integration.name !== this.name).flatMap((integration) => integration.extensions)
|
|
57
74
|
});
|
|
58
75
|
this.pageModuleService = new ReactPageModuleService({
|
|
59
76
|
rootDir: this.appConfig.rootDir,
|
|
@@ -197,6 +214,37 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
197
214
|
}
|
|
198
215
|
throw new ReactRenderError(`${label} must return a string when used as a mixed shell for React pages.`);
|
|
199
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Renders one React component boundary for marker-graph orchestration.
|
|
219
|
+
*
|
|
220
|
+
* When the marker resolver has already stitched child HTML for this boundary,
|
|
221
|
+
* the child payload must remain raw SSR output rather than a React string
|
|
222
|
+
* child, otherwise React would escape it. This helper renders a unique token
|
|
223
|
+
* through React and swaps that token back to the stitched HTML afterward.
|
|
224
|
+
*
|
|
225
|
+
* @param input Component render input reconstructed from marker metadata.
|
|
226
|
+
* @param context React-specific render context for stable token generation.
|
|
227
|
+
* @returns Serialized component HTML with stitched child markup preserved.
|
|
228
|
+
*/
|
|
229
|
+
renderComponentHtml(input, context) {
|
|
230
|
+
if (input.children === void 0) {
|
|
231
|
+
return restoreEscapedComponentMarkers(
|
|
232
|
+
renderToString(createElement(this.asReactComponent(input.component), input.props))
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const rawChildrenToken = `__ECO_RAW_HTML_CHILD_${context.componentInstanceId ?? "component"}__`;
|
|
236
|
+
const html = renderToString(
|
|
237
|
+
createElement(this.asReactComponent(input.component), input.props, rawChildrenToken)
|
|
238
|
+
);
|
|
239
|
+
return restoreEscapedComponentMarkers(html.split(rawChildrenToken).join(input.children));
|
|
240
|
+
}
|
|
241
|
+
buildHydrationProps(props) {
|
|
242
|
+
if (!props || !Object.prototype.hasOwnProperty.call(props, "locals")) {
|
|
243
|
+
return props ?? {};
|
|
244
|
+
}
|
|
245
|
+
const { locals: _locals, ...hydrationProps } = props;
|
|
246
|
+
return hydrationProps;
|
|
247
|
+
}
|
|
200
248
|
/**
|
|
201
249
|
* Produces the page body before the final HTML template is applied.
|
|
202
250
|
*
|
|
@@ -206,7 +254,7 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
206
254
|
*/
|
|
207
255
|
async composePageContent(options) {
|
|
208
256
|
const pageElement = createElement(options.Page, options.pageProps);
|
|
209
|
-
const pageHtml = renderToString(pageElement);
|
|
257
|
+
const pageHtml = restoreEscapedComponentMarkers(renderToString(pageElement));
|
|
210
258
|
const layoutProps = options.locals ? { locals: options.locals } : {};
|
|
211
259
|
if (!options.Layout) {
|
|
212
260
|
return { contentNode: pageElement, contentHtml: pageHtml };
|
|
@@ -215,7 +263,7 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
215
263
|
const layoutElement = createElement(this.asReactComponent(options.Layout), layoutProps, pageElement);
|
|
216
264
|
return {
|
|
217
265
|
contentNode: layoutElement,
|
|
218
|
-
contentHtml: renderToString(layoutElement)
|
|
266
|
+
contentHtml: restoreEscapedComponentMarkers(renderToString(layoutElement))
|
|
219
267
|
};
|
|
220
268
|
}
|
|
221
269
|
const layoutHtml = await this.renderNonReactShellComponent(
|
|
@@ -234,16 +282,20 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
234
282
|
*/
|
|
235
283
|
async renderDocument(options) {
|
|
236
284
|
if (this.isReactManagedComponent(options.HtmlTemplate)) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
285
|
+
const rawChildrenToken = "__ECO_RAW_HTML_DOCUMENT_CHILD__";
|
|
286
|
+
const html = restoreEscapedComponentMarkers(
|
|
287
|
+
renderToString(
|
|
288
|
+
createElement(
|
|
289
|
+
this.asReactComponent(options.HtmlTemplate),
|
|
290
|
+
{
|
|
291
|
+
metadata: options.metadata,
|
|
292
|
+
pageProps: options.pageProps
|
|
293
|
+
},
|
|
294
|
+
rawChildrenToken
|
|
295
|
+
)
|
|
245
296
|
)
|
|
246
297
|
);
|
|
298
|
+
return html.split(rawChildrenToken).join(options.contentHtml);
|
|
247
299
|
}
|
|
248
300
|
const headContent = ReactRenderer.routerAdapter ? this.buildRouterPageDataScript(options.pageProps) : void 0;
|
|
249
301
|
return this.renderNonReactShellComponent(
|
|
@@ -265,32 +317,33 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
265
317
|
* - When an explicit component instance id is provided, a stable
|
|
266
318
|
* `data-eco-component-id` attribute is attached so island hydration can target it.
|
|
267
319
|
* - Without an explicit instance id, component renders remain plain SSR output.
|
|
320
|
+
* - When stitched child HTML is provided, that boundary is treated as a pure SSR
|
|
321
|
+
* composition step and does not emit hydration assets for the parent wrapper.
|
|
268
322
|
*
|
|
269
323
|
* This preserves DOM shape for global CSS/layout selectors while keeping a
|
|
270
324
|
* deterministic mount target per component instance.
|
|
271
325
|
*/
|
|
272
326
|
async renderComponent(input) {
|
|
273
|
-
const Component = this.asReactComponent(input.component);
|
|
274
327
|
const componentConfig = input.component.config;
|
|
275
|
-
const
|
|
276
|
-
|
|
328
|
+
const context = input.integrationContext ?? {};
|
|
329
|
+
const hasResolvedChildHtml = input.children !== void 0;
|
|
330
|
+
let html = this.renderComponentHtml(input, context);
|
|
277
331
|
let canAttachAttributes = hasSingleRootElement(html);
|
|
278
332
|
let rootTag = this.getRootTagName(html);
|
|
279
333
|
const componentFile = componentConfig?.__eco?.file;
|
|
280
|
-
const context = input.integrationContext ?? {};
|
|
281
334
|
let rootAttributes;
|
|
282
335
|
let assets;
|
|
283
|
-
if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService) {
|
|
336
|
+
if (canAttachAttributes && componentFile && context.componentInstanceId && this.assetProcessingService && !hasResolvedChildHtml) {
|
|
284
337
|
const componentInstanceId = context.componentInstanceId;
|
|
285
338
|
assets = await this.hydrationAssetService.buildComponentRenderAssets(
|
|
286
339
|
componentFile,
|
|
287
340
|
componentInstanceId,
|
|
288
|
-
input.props,
|
|
341
|
+
this.buildHydrationProps(input.props),
|
|
289
342
|
componentConfig
|
|
290
343
|
);
|
|
291
344
|
rootAttributes = {
|
|
292
345
|
"data-eco-component-id": componentInstanceId,
|
|
293
|
-
"data-eco-props": btoa(JSON.stringify(input.props
|
|
346
|
+
"data-eco-props": btoa(JSON.stringify(this.buildHydrationProps(input.props)))
|
|
294
347
|
};
|
|
295
348
|
}
|
|
296
349
|
return {
|
|
@@ -310,13 +363,33 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
310
363
|
isMdxFile(filePath) {
|
|
311
364
|
return this.pageModuleService.isMdxFile(filePath);
|
|
312
365
|
}
|
|
366
|
+
usesIntegrationPageImporter(file) {
|
|
367
|
+
return this.pageModuleService.isMdxFile(file);
|
|
368
|
+
}
|
|
369
|
+
async importIntegrationPageFile(file) {
|
|
370
|
+
return await this.pageModuleService.importMdxPageFile(file);
|
|
371
|
+
}
|
|
372
|
+
normalizeImportedPageFile(file, pageModule) {
|
|
373
|
+
const reactModule = pageModule;
|
|
374
|
+
const { default: Page, getMetadata, config } = reactModule;
|
|
375
|
+
if (this.pageModuleService.isMdxFile(file) && config) {
|
|
376
|
+
Page.config = config;
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
...pageModule,
|
|
380
|
+
default: Page,
|
|
381
|
+
getMetadata,
|
|
382
|
+
config
|
|
383
|
+
};
|
|
384
|
+
}
|
|
313
385
|
/**
|
|
314
386
|
* Processes MDX-specific configuration dependencies including layout dependencies.
|
|
315
387
|
* @param pagePath - Absolute path to the MDX page file
|
|
316
388
|
* @returns Processed assets for MDX configuration dependencies
|
|
317
389
|
*/
|
|
318
390
|
async processMdxConfigDependencies(pagePath) {
|
|
319
|
-
const
|
|
391
|
+
const pageModule = await this.importPageFile(pagePath);
|
|
392
|
+
const config = pageModule.config;
|
|
320
393
|
const resolvedLayout = config?.layout;
|
|
321
394
|
const components = [];
|
|
322
395
|
if (resolvedLayout?.config?.dependencies) {
|
|
@@ -439,26 +512,6 @@ class ReactRenderer extends IntegrationRenderer {
|
|
|
439
512
|
);
|
|
440
513
|
}
|
|
441
514
|
}
|
|
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
515
|
/**
|
|
463
516
|
* Renders a full route response for the filesystem page pipeline.
|
|
464
517
|
*
|
package/src/react.plugin.js
CHANGED
|
@@ -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
|
}
|
|
@@ -4,6 +4,7 @@ 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";
|
|
7
8
|
import { createUseSyncExternalStoreShimPlugin } from "../utils/use-sync-external-store-shim-plugin.js";
|
|
8
9
|
import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
|
|
9
10
|
import { ReactRuntimeBundleService } from "./react-runtime-bundle.service.js";
|
|
@@ -48,6 +49,10 @@ class ReactBundleService {
|
|
|
48
49
|
declaredModules,
|
|
49
50
|
alwaysAllowSpecifiers: getReactClientGraphAllowSpecifiers([], this.config.routerAdapter)
|
|
50
51
|
});
|
|
52
|
+
const foreignJsxOverridePlugin = createForeignJsxOverridePlugin(this.config.nonReactExtensions ?? [], {
|
|
53
|
+
name: "react-renderer-foreign-jsx-override",
|
|
54
|
+
jsxImportSource: this.config.jsxImportSource ?? "react"
|
|
55
|
+
});
|
|
51
56
|
const runtimeAliasPlugin = this.createRuntimeAliasPlugin(runtimeSpecifierMap);
|
|
52
57
|
const useSyncExternalStoreShimPlugin = createUseSyncExternalStoreShimPlugin({
|
|
53
58
|
name: "react-renderer-use-sync-external-store-shim",
|
|
@@ -56,9 +61,20 @@ class ReactBundleService {
|
|
|
56
61
|
if (isMdx && this.config.mdxCompilerOptions) {
|
|
57
62
|
const { createReactMdxLoaderPlugin } = await import("../utils/react-mdx-loader-plugin.js");
|
|
58
63
|
const mdxPlugin = createReactMdxLoaderPlugin(this.config.mdxCompilerOptions);
|
|
59
|
-
options.plugins = [
|
|
64
|
+
options.plugins = [
|
|
65
|
+
foreignJsxOverridePlugin,
|
|
66
|
+
graphBoundaryPlugin,
|
|
67
|
+
runtimeAliasPlugin,
|
|
68
|
+
mdxPlugin,
|
|
69
|
+
useSyncExternalStoreShimPlugin
|
|
70
|
+
];
|
|
60
71
|
} else {
|
|
61
|
-
options.plugins = [
|
|
72
|
+
options.plugins = [
|
|
73
|
+
foreignJsxOverridePlugin,
|
|
74
|
+
graphBoundaryPlugin,
|
|
75
|
+
runtimeAliasPlugin,
|
|
76
|
+
useSyncExternalStoreShimPlugin
|
|
77
|
+
];
|
|
62
78
|
}
|
|
63
79
|
return options;
|
|
64
80
|
}
|
|
@@ -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.
|
|
@@ -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 [];
|
|
@@ -0,0 +1,19 @@
|
|
|
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 {};
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
};
|
|
@@ -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
|
}
|