@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 CHANGED
@@ -6,17 +6,18 @@ All notable changes to `@ecopages/react` are documented here.
6
6
 
7
7
  ## [UNRELEASED] — TBD
8
8
 
9
- ### Features & Performance
9
+ ### Bug Fixes
10
10
 
11
- - **Performance Hydration**: Introduced static reachability analysis to enforce explicit hydration boundaries and optimized HMR via metadata caching.
12
- - **Service-Oriented Internals**: Refactored the integration into focused core-backed services for bundling, hydration, and page-module loading.
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
- ### Bug Fixes & Refactoring
14
+ ### Features
16
15
 
17
- - **Handoff Stability**: Standardized router-backed page payloads and document owner markers for mixed-router stability during navigation.
18
- - **Hydration Hardening**: Fixed island remount races, prop collisions, and layout metadata resolution during development and route handoffs.
19
- - **Architecture**: Centralized runtime specifiers and consolidated browser-side integration state under `window.__ECO_PAGES__`.
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.10",
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.10",
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.10",
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, HmrStrategyType, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
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 = HmrStrategyType.INTEGRATION;
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 processed = await this.processOutput(tempFile, outputPath, outputUrl);
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.
@@ -2,7 +2,7 @@
2
2
  * This module contains the React renderer
3
3
  * @module
4
4
  */
5
- import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, EcoComponentConfig, EcoPageFile, IntegrationRendererRenderOptions, RouteRendererBody } from '@ecopages/core';
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 {};
@@ -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("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&#x27;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
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
- return renderToReadableStream(
238
- createElement(
239
- this.asReactComponent(options.HtmlTemplate),
240
- {
241
- metadata: options.metadata,
242
- pageProps: options.pageProps
243
- },
244
- options.contentNode
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 element = input.children === void 0 ? createElement(Component, input.props) : createElement(Component, input.props, input.children);
276
- let html = renderToString(element);
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 { config } = await this.importPageFile(pagePath);
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
  *
@@ -38,6 +38,7 @@ class ReactPlugin extends IntegrationPlugin {
38
38
  super({
39
39
  name: PLUGIN_NAME,
40
40
  extensions,
41
+ jsxImportSource: "react",
41
42
  ...restOptions
42
43
  });
43
44
  this.mdxEnabled = options?.mdx?.enabled ?? false;
@@ -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("packages/core/src/build/build-types.ts").EcoBuildPlugin | null;
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 = [graphBoundaryPlugin, runtimeAliasPlugin, mdxPlugin, useSyncExternalStoreShimPlugin];
64
+ options.plugins = [
65
+ foreignJsxOverridePlugin,
66
+ graphBoundaryPlugin,
67
+ runtimeAliasPlugin,
68
+ mdxPlugin,
69
+ useSyncExternalStoreShimPlugin
70
+ ];
60
71
  } else {
61
- options.plugins = [graphBoundaryPlugin, runtimeAliasPlugin, useSyncExternalStoreShimPlugin];
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(pathToFileURL(compiledOutput).href);
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(pagePath);
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?.register({
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
- root.render(createTree(NewPage, props));
105
- console.log("[ecopages] ${getComponentType(isMdx)} component updated");
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
  }