@ecopages/react 0.2.0-alpha.5 → 0.2.0-alpha.7

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +22 -41
  2. package/README.md +135 -29
  3. package/package.json +3 -3
  4. package/src/react-hmr-strategy.d.ts +22 -30
  5. package/src/react-hmr-strategy.js +57 -120
  6. package/src/react-hmr-strategy.ts +76 -145
  7. package/src/react-renderer.d.ts +130 -11
  8. package/src/react-renderer.js +368 -64
  9. package/src/react-renderer.ts +490 -90
  10. package/src/react.plugin.d.ts +17 -5
  11. package/src/react.plugin.js +44 -13
  12. package/src/react.plugin.ts +49 -14
  13. package/src/router-adapter.d.ts +2 -2
  14. package/src/router-adapter.ts +2 -2
  15. package/src/services/react-bundle.service.d.ts +2 -30
  16. package/src/services/react-bundle.service.js +19 -94
  17. package/src/services/react-bundle.service.ts +20 -129
  18. package/src/services/react-hydration-asset.service.js +3 -3
  19. package/src/services/react-hydration-asset.service.ts +7 -4
  20. package/src/services/react-page-module.service.d.ts +3 -0
  21. package/src/services/react-page-module.service.js +20 -16
  22. package/src/services/react-page-module.service.ts +27 -17
  23. package/src/services/react-runtime-bundle.service.d.ts +12 -12
  24. package/src/services/react-runtime-bundle.service.js +98 -180
  25. package/src/services/react-runtime-bundle.service.ts +112 -211
  26. package/src/utils/client-graph-boundary-plugin.js +78 -1
  27. package/src/utils/client-graph-boundary-plugin.ts +122 -1
  28. package/src/utils/hydration-scripts.d.ts +18 -1
  29. package/src/utils/hydration-scripts.js +83 -32
  30. package/src/utils/hydration-scripts.ts +159 -38
  31. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  32. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  33. package/src/utils/react-dom-runtime-interop-plugin.ts +33 -0
  34. package/src/utils/react-mdx-loader-plugin.js +13 -5
  35. package/src/utils/react-mdx-loader-plugin.ts +28 -5
  36. package/src/utils/react-runtime-specifier-map.d.ts +6 -0
  37. package/src/utils/react-runtime-specifier-map.js +37 -0
  38. package/src/utils/react-runtime-specifier-map.ts +45 -0
  39. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  40. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  41. package/src/utils/use-sync-external-store-shim-plugin.ts +45 -0
@@ -1,14 +1,22 @@
1
1
  import path from "node:path";
2
- import { pathToFileURL } from "node:url";
3
2
  import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
4
- import { defaultBuildAdapter } from "@ecopages/core/build/build-adapter";
3
+ import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
5
4
  import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
6
5
  import { Logger } from "@ecopages/logger";
7
6
  import { injectHmrHandler } from "./utils/hmr-scripts.js";
8
7
  import { createClientGraphBoundaryPlugin } from "./utils/client-graph-boundary-plugin.js";
9
8
  import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from "./utils/declared-modules.js";
9
+ import { getReactClientGraphAllowSpecifiers } from "./utils/react-runtime-specifier-map.js";
10
+ import { createUseSyncExternalStoreShimPlugin } from "./utils/use-sync-external-store-shim-plugin.js";
10
11
  const appLogger = new Logger("[ReactHmrStrategy]");
11
12
  class ReactHmrStrategy extends HmrStrategy {
13
+ type = HmrStrategyType.INTEGRATION;
14
+ mdxCompilerOptions;
15
+ ownedTemplateExtensions;
16
+ allTemplateExtensions;
17
+ async importNodePageModule(entrypointPath) {
18
+ return await this.context.importServerModule(entrypointPath);
19
+ }
12
20
  /**
13
21
  * Creates a new React HMR strategy instance.
14
22
  *
@@ -22,86 +30,17 @@ class ReactHmrStrategy extends HmrStrategy {
22
30
  * @param explicitGraphEnabled - Enables explicit graph mode for React HMR bundling.
23
31
  * In explicit mode, HMR builds omit AST server-only stripping plugins in React paths.
24
32
  */
25
- constructor(context, pageMetadataCache, mdxCompilerOptions, explicitGraphEnabled = false) {
33
+ context;
34
+ pageMetadataCache;
35
+ explicitGraphEnabled;
36
+ constructor(context, pageMetadataCache, mdxCompilerOptions, ownedTemplateExtensions = [".tsx"], allTemplateExtensions = [".tsx"], explicitGraphEnabled = false) {
26
37
  super();
27
38
  this.context = context;
28
39
  this.pageMetadataCache = pageMetadataCache;
29
40
  this.explicitGraphEnabled = explicitGraphEnabled;
30
41
  this.mdxCompilerOptions = mdxCompilerOptions;
31
- }
32
- type = HmrStrategyType.INTEGRATION;
33
- mdxCompilerOptions;
34
- knownEntrypoints = /* @__PURE__ */ new Set();
35
- async importNodePageModule(entrypointPath) {
36
- const srcDir = this.context.getSrcDir();
37
- const rootDir = path.dirname(srcDir);
38
- const outdir = path.join(path.resolve(this.context.getDistDir(), "..", ".."), ".server-modules");
39
- const fileBaseName = path.basename(entrypointPath, path.extname(entrypointPath));
40
- const fileHash = fileSystem.hash(entrypointPath);
41
- const outputFileName = `${fileBaseName}-${fileHash}.js`;
42
- const buildResult = await defaultBuildAdapter.build({
43
- entrypoints: [entrypointPath],
44
- root: rootDir,
45
- outdir,
46
- target: "node",
47
- format: "esm",
48
- sourcemap: "none",
49
- splitting: false,
50
- minify: false,
51
- naming: outputFileName
52
- });
53
- if (!buildResult.success) {
54
- const details = buildResult.logs.map((log) => log.message).join(" | ");
55
- throw new Error(`Error transpiling React HMR page module: ${details}`);
56
- }
57
- const preferredOutputPath = path.join(outdir, outputFileName);
58
- const compiledOutput = buildResult.outputs.find((output) => output.path === preferredOutputPath)?.path ?? buildResult.outputs.find((output) => output.path.endsWith(".js"))?.path;
59
- if (!compiledOutput) {
60
- throw new Error(`No transpiled output generated for React HMR page module: ${entrypointPath}`);
61
- }
62
- return await import(pathToFileURL(compiledOutput).href);
63
- }
64
- /**
65
- * Redirects `use-sync-external-store/shim` imports to React's built-in
66
- * `useSyncExternalStore`.
67
- *
68
- * Libraries like React Aria still list `use-sync-external-store` as a
69
- * dependency to support React 16/17. On React 18+ the `/shim` export is
70
- * already a pass-through, but without this plugin esbuild would bundle
71
- * the full CJS shim (including `process.env` branching) into the browser
72
- * bundle. The plugin short-circuits the resolution so only a single clean
73
- * ESM re-export is emitted.
74
- */
75
- createUseSyncExternalStoreShimPlugin() {
76
- return {
77
- name: "react-hmr-use-sync-external-store-shim",
78
- setup(build) {
79
- build.onResolve({ filter: /^use-sync-external-store\/shim(?:\/index\.js)?$/ }, () => ({
80
- path: "use-sync-external-store/shim",
81
- namespace: "ecopages-react-hmr-shim"
82
- }));
83
- build.onLoad(
84
- { filter: /^use-sync-external-store\/shim$/, namespace: "ecopages-react-hmr-shim" },
85
- () => ({
86
- contents: "export { useSyncExternalStore } from 'react';",
87
- loader: "js"
88
- })
89
- );
90
- build.onLoad({ filter: /[\\/]use-sync-external-store[\\/]shim[\\/]index\.js$/ }, () => ({
91
- contents: "export { useSyncExternalStore } from 'react';",
92
- loader: "js"
93
- }));
94
- build.onLoad(
95
- {
96
- filter: /[\\/]use-sync-external-store[\\/]cjs[\\/]use-sync-external-store-shim\.development\.js$/
97
- },
98
- () => ({
99
- contents: "export { useSyncExternalStore } from 'react';",
100
- loader: "js"
101
- })
102
- );
103
- }
104
- };
42
+ this.ownedTemplateExtensions = new Set(ownedTemplateExtensions);
43
+ this.allTemplateExtensions = [...allTemplateExtensions].sort((a, b) => b.length - a.length);
105
44
  }
106
45
  /**
107
46
  * Returns build plugins for React HMR bundling.
@@ -110,30 +49,53 @@ class ReactHmrStrategy extends HmrStrategy {
110
49
  * (including `node:*`) from breaking the browser bundle.
111
50
  */
112
51
  getBuildPlugins(declaredModules) {
113
- const allowSpecifiers = [
114
- "@ecopages/core",
115
- "react",
116
- "react-dom",
117
- "react/jsx-runtime",
118
- "react/jsx-dev-runtime",
119
- "react-dom/client",
120
- ...Array.from(this.context.getSpecifierMap().keys())
121
- ];
52
+ const allowSpecifiers = getReactClientGraphAllowSpecifiers(this.context.getSpecifierMap().keys());
53
+ const runtimeAliasPlugin = createRuntimeSpecifierAliasPlugin(this.context.getSpecifierMap(), {
54
+ name: "react-hmr-runtime-specifier-alias"
55
+ });
122
56
  return [
123
57
  createClientGraphBoundaryPlugin({
124
58
  absWorkingDir: path.dirname(this.context.getSrcDir()),
125
59
  alwaysAllowSpecifiers: allowSpecifiers,
126
60
  declaredModules
127
61
  }),
62
+ ...runtimeAliasPlugin ? [runtimeAliasPlugin] : [],
128
63
  ...this.context.getPlugins(),
129
- this.createUseSyncExternalStoreShimPlugin()
64
+ createUseSyncExternalStoreShimPlugin({
65
+ name: "react-hmr-use-sync-external-store-shim",
66
+ namespace: "ecopages-react-hmr-shim"
67
+ })
130
68
  ];
131
69
  }
132
70
  isReactEntrypoint(filePath) {
133
- if (filePath.endsWith(".tsx")) {
71
+ if (filePath.endsWith(".mdx")) {
72
+ return this.mdxCompilerOptions !== void 0;
73
+ }
74
+ if (!filePath.endsWith(".tsx")) {
75
+ return false;
76
+ }
77
+ if (!this.isRouteTemplate(filePath)) {
134
78
  return true;
135
79
  }
136
- return filePath.endsWith(".mdx") && this.mdxCompilerOptions !== void 0;
80
+ const templateExtension = this.resolveTemplateExtension(filePath);
81
+ if (!templateExtension) {
82
+ return false;
83
+ }
84
+ return this.ownedTemplateExtensions.has(templateExtension);
85
+ }
86
+ /**
87
+ * Returns true when a route file uses a compound extension like `page.foo.tsx`.
88
+ *
89
+ * @remarks
90
+ * React integration owns plain `.tsx` route templates. Compound extensions in
91
+ * pages/layouts are integration-specific route templates and should not be
92
+ * claimed by React HMR strategy.
93
+ */
94
+ isRouteTemplate(filePath) {
95
+ return filePath.startsWith(this.context.getPagesDir()) || filePath.startsWith(this.context.getLayoutsDir());
96
+ }
97
+ resolveTemplateExtension(filePath) {
98
+ return this.allTemplateExtensions.find((extension) => filePath.endsWith(extension));
137
99
  }
138
100
  /**
139
101
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
@@ -185,8 +147,8 @@ class ReactHmrStrategy extends HmrStrategy {
185
147
  if (isLayout) {
186
148
  appLogger.debug(`Detected layout file change: ${_filePath}`);
187
149
  }
188
- const entrypointsToBuild = !this.knownEntrypoints.has(_filePath) && watchedFiles.has(_filePath) ? [[_filePath, watchedFiles.get(_filePath)]] : watchedFiles.entries();
189
- this.knownEntrypoints.add(_filePath);
150
+ const changedEntrypointOutput = watchedFiles.get(_filePath);
151
+ const entrypointsToBuild = changedEntrypointOutput ? [[_filePath, changedEntrypointOutput]] : watchedFiles.entries();
190
152
  const updates = [];
191
153
  for (const [entrypoint, outputUrl] of entrypointsToBuild) {
192
154
  if (!this.isReactEntrypoint(entrypoint)) {
@@ -246,16 +208,13 @@ class ReactHmrStrategy extends HmrStrategy {
246
208
  const mdxPlugin = createReactMdxLoaderPlugin(this.mdxCompilerOptions);
247
209
  plugins.unshift(mdxPlugin);
248
210
  }
249
- const result = await defaultBuildAdapter.build({
211
+ const result = await this.context.getBrowserBundleService().bundle({
212
+ profile: "hmr-entrypoint",
250
213
  entrypoints: [entrypointPath],
251
214
  outdir: tempDir,
252
215
  naming: `[name].[hash].tmp`,
253
- target: "browser",
254
- format: "esm",
255
- sourcemap: "none",
256
216
  plugins,
257
- minify: false,
258
- external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom/client"]
217
+ minify: false
259
218
  });
260
219
  if (!result.success) {
261
220
  appLogger.error(`Failed to build ${entrypointPath}:`, result.logs);
@@ -281,7 +240,7 @@ class ReactHmrStrategy extends HmrStrategy {
281
240
  return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
282
241
  }
283
242
  /**
284
- * Processes bundled output by replacing specifiers and injecting HMR handler.
243
+ * Processes bundled output and injects the React HMR handler.
285
244
  * Writes to temp file first, then renames atomically to avoid conflicts.
286
245
  *
287
246
  * @param tempPath - Path to the temporary bundled file
@@ -296,7 +255,6 @@ class ReactHmrStrategy extends HmrStrategy {
296
255
  }
297
256
  try {
298
257
  let code = await fileSystem.readFile(tempPath);
299
- code = this.replaceBareSpecifiers(code);
300
258
  code = injectHmrHandler(code);
301
259
  await fileSystem.writeAsync(finalPath, code);
302
260
  await fileSystem.removeAsync(tempPath).catch(() => {
@@ -316,27 +274,6 @@ class ReactHmrStrategy extends HmrStrategy {
316
274
  return false;
317
275
  }
318
276
  }
319
- /**
320
- * Replaces bare specifiers with runtime URLs.
321
- *
322
- * Handles both static imports and dynamic imports.
323
- *
324
- * @param code - The bundled code to transform
325
- * @returns The transformed code with runtime URLs
326
- */
327
- replaceBareSpecifiers(code) {
328
- const specifierMap = this.context.getSpecifierMap();
329
- if (specifierMap.size === 0) {
330
- return code;
331
- }
332
- let result = code;
333
- for (const [bareSpec, runtimeUrl] of specifierMap.entries()) {
334
- const escaped = bareSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
335
- result = result.replace(new RegExp(`from\\s*["']${escaped}["']`, "g"), `from "${runtimeUrl}"`);
336
- result = result.replace(new RegExp(`import\\(["']${escaped}["']\\)`, "g"), `import("${runtimeUrl}")`);
337
- }
338
- return result;
339
- }
340
277
  }
341
278
  export {
342
279
  ReactHmrStrategy
@@ -8,18 +8,19 @@
8
8
  */
9
9
 
10
10
  import path from 'node:path';
11
- import { pathToFileURL } from 'node:url';
12
11
 
13
12
  import { HmrStrategy, HmrStrategyType, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
14
- import { defaultBuildAdapter } from '@ecopages/core/build/build-adapter';
15
13
  import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
14
+ import { createRuntimeSpecifierAliasPlugin } from '@ecopages/core/build/runtime-specifier-alias-plugin';
16
15
  import { FileNotFoundError, fileSystem } from '@ecopages/file-system';
17
16
  import { Logger } from '@ecopages/logger';
18
- import type { DefaultHmrContext, EcoComponentConfig } from '@ecopages/core';
17
+ import type { DefaultHmrContext } from '@ecopages/core';
19
18
  import type { CompileOptions } from '@mdx-js/mdx';
20
19
  import { injectHmrHandler } from './utils/hmr-scripts.ts';
21
20
  import { createClientGraphBoundaryPlugin } from './utils/client-graph-boundary-plugin.ts';
22
21
  import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from './utils/declared-modules.ts';
22
+ import { getReactClientGraphAllowSpecifiers } from './utils/react-runtime-specifier-map.ts';
23
+ import { createUseSyncExternalStoreShimPlugin } from './utils/use-sync-external-store-shim-plugin.ts';
23
24
  import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.ts';
24
25
 
25
26
  const appLogger = new Logger('[ReactHmrStrategy]');
@@ -33,9 +34,11 @@ const appLogger = new Logger('[ReactHmrStrategy]');
33
34
  * The processing steps are:
34
35
  * 1. Check if any React entrypoints are registered
35
36
  * 2. Rebuild all React entrypoints (the changed file could be a dependency)
36
- * 3. Replace bare specifiers with runtime URLs
37
- * 4. Inject HMR acceptance handler
38
- * 5. Broadcast update events for each rebuilt entrypoint
37
+ * 3. Rebuild browser output through the shared browser bundle service while
38
+ * preserving React-specific runtime aliases and graph policy
39
+ * 4. Read page config metadata through the shared server-module loading path
40
+ * 5. Inject HMR acceptance handler
41
+ * 6. Broadcast update events for each rebuilt entrypoint
39
42
  *
40
43
  * @remarks
41
44
  * This strategy has higher priority than generic JsHmrStrategy, allowing it
@@ -63,95 +66,13 @@ const appLogger = new Logger('[ReactHmrStrategy]');
63
66
  export class ReactHmrStrategy extends HmrStrategy {
64
67
  readonly type = HmrStrategyType.INTEGRATION;
65
68
  private mdxCompilerOptions?: CompileOptions;
66
- private readonly knownEntrypoints = new Set<string>();
67
-
69
+ private readonly ownedTemplateExtensions: Set<string>;
70
+ private readonly allTemplateExtensions: string[];
68
71
  private async importNodePageModule(entrypointPath: string): Promise<{
69
- default?: { config?: EcoComponentConfig };
70
- config?: EcoComponentConfig;
72
+ default?: { config?: Record<string, unknown> };
73
+ config?: Record<string, unknown>;
71
74
  }> {
72
- const srcDir = this.context.getSrcDir();
73
- const rootDir = path.dirname(srcDir);
74
- const outdir = path.join(path.resolve(this.context.getDistDir(), '..', '..'), '.server-modules');
75
- const fileBaseName = path.basename(entrypointPath, path.extname(entrypointPath));
76
- const fileHash = fileSystem.hash(entrypointPath);
77
- const outputFileName = `${fileBaseName}-${fileHash}.js`;
78
-
79
- const buildResult = await defaultBuildAdapter.build({
80
- entrypoints: [entrypointPath],
81
- root: rootDir,
82
- outdir,
83
- target: 'node',
84
- format: 'esm',
85
- sourcemap: 'none',
86
- splitting: false,
87
- minify: false,
88
- naming: outputFileName,
89
- });
90
-
91
- if (!buildResult.success) {
92
- const details = buildResult.logs.map((log) => log.message).join(' | ');
93
- throw new Error(`Error transpiling React HMR page module: ${details}`);
94
- }
95
-
96
- const preferredOutputPath = path.join(outdir, outputFileName);
97
- const compiledOutput =
98
- buildResult.outputs.find((output) => output.path === preferredOutputPath)?.path ??
99
- buildResult.outputs.find((output) => output.path.endsWith('.js'))?.path;
100
-
101
- if (!compiledOutput) {
102
- throw new Error(`No transpiled output generated for React HMR page module: ${entrypointPath}`);
103
- }
104
-
105
- return (await import(pathToFileURL(compiledOutput).href)) as {
106
- default?: { config?: EcoComponentConfig };
107
- config?: EcoComponentConfig;
108
- };
109
- }
110
-
111
- /**
112
- * Redirects `use-sync-external-store/shim` imports to React's built-in
113
- * `useSyncExternalStore`.
114
- *
115
- * Libraries like React Aria still list `use-sync-external-store` as a
116
- * dependency to support React 16/17. On React 18+ the `/shim` export is
117
- * already a pass-through, but without this plugin esbuild would bundle
118
- * the full CJS shim (including `process.env` branching) into the browser
119
- * bundle. The plugin short-circuits the resolution so only a single clean
120
- * ESM re-export is emitted.
121
- */
122
- private createUseSyncExternalStoreShimPlugin(): EcoBuildPlugin {
123
- return {
124
- name: 'react-hmr-use-sync-external-store-shim',
125
- setup(build) {
126
- build.onResolve({ filter: /^use-sync-external-store\/shim(?:\/index\.js)?$/ }, () => ({
127
- path: 'use-sync-external-store/shim',
128
- namespace: 'ecopages-react-hmr-shim',
129
- }));
130
-
131
- build.onLoad(
132
- { filter: /^use-sync-external-store\/shim$/, namespace: 'ecopages-react-hmr-shim' },
133
- () => ({
134
- contents: "export { useSyncExternalStore } from 'react';",
135
- loader: 'js',
136
- }),
137
- );
138
-
139
- build.onLoad({ filter: /[\\/]use-sync-external-store[\\/]shim[\\/]index\.js$/ }, () => ({
140
- contents: "export { useSyncExternalStore } from 'react';",
141
- loader: 'js',
142
- }));
143
-
144
- build.onLoad(
145
- {
146
- filter: /[\\/]use-sync-external-store[\\/]cjs[\\/]use-sync-external-store-shim\.development\.js$/,
147
- },
148
- () => ({
149
- contents: "export { useSyncExternalStore } from 'react';",
150
- loader: 'js',
151
- }),
152
- );
153
- },
154
- };
75
+ return await this.context.importServerModule(entrypointPath);
155
76
  }
156
77
 
157
78
  /**
@@ -167,14 +88,25 @@ export class ReactHmrStrategy extends HmrStrategy {
167
88
  * @param explicitGraphEnabled - Enables explicit graph mode for React HMR bundling.
168
89
  * In explicit mode, HMR builds omit AST server-only stripping plugins in React paths.
169
90
  */
91
+ private context: DefaultHmrContext;
92
+ private pageMetadataCache: ReactHmrPageMetadataCache;
93
+ private explicitGraphEnabled: boolean;
94
+
170
95
  constructor(
171
- private context: DefaultHmrContext,
172
- private pageMetadataCache: ReactHmrPageMetadataCache,
96
+ context: DefaultHmrContext,
97
+ pageMetadataCache: ReactHmrPageMetadataCache,
173
98
  mdxCompilerOptions?: CompileOptions,
174
- private explicitGraphEnabled = false,
99
+ ownedTemplateExtensions: string[] = ['.tsx'],
100
+ allTemplateExtensions: string[] = ['.tsx'],
101
+ explicitGraphEnabled = false,
175
102
  ) {
176
103
  super();
104
+ this.context = context;
105
+ this.pageMetadataCache = pageMetadataCache;
106
+ this.explicitGraphEnabled = explicitGraphEnabled;
177
107
  this.mdxCompilerOptions = mdxCompilerOptions;
108
+ this.ownedTemplateExtensions = new Set(ownedTemplateExtensions);
109
+ this.allTemplateExtensions = [...allTemplateExtensions].sort((a, b) => b.length - a.length);
178
110
  }
179
111
 
180
112
  /**
@@ -184,15 +116,11 @@ export class ReactHmrStrategy extends HmrStrategy {
184
116
  * (including `node:*`) from breaking the browser bundle.
185
117
  */
186
118
  private getBuildPlugins(declaredModules?: string[]): EcoBuildPlugin[] {
187
- const allowSpecifiers = [
188
- '@ecopages/core',
189
- 'react',
190
- 'react-dom',
191
- 'react/jsx-runtime',
192
- 'react/jsx-dev-runtime',
193
- 'react-dom/client',
194
- ...Array.from(this.context.getSpecifierMap().keys()),
195
- ];
119
+ const allowSpecifiers = getReactClientGraphAllowSpecifiers(this.context.getSpecifierMap().keys());
120
+
121
+ const runtimeAliasPlugin = createRuntimeSpecifierAliasPlugin(this.context.getSpecifierMap(), {
122
+ name: 'react-hmr-runtime-specifier-alias',
123
+ });
196
124
 
197
125
  return [
198
126
  createClientGraphBoundaryPlugin({
@@ -200,17 +128,50 @@ export class ReactHmrStrategy extends HmrStrategy {
200
128
  alwaysAllowSpecifiers: allowSpecifiers,
201
129
  declaredModules,
202
130
  }),
131
+ ...(runtimeAliasPlugin ? [runtimeAliasPlugin] : []),
203
132
  ...this.context.getPlugins(),
204
- this.createUseSyncExternalStoreShimPlugin(),
133
+ createUseSyncExternalStoreShimPlugin({
134
+ name: 'react-hmr-use-sync-external-store-shim',
135
+ namespace: 'ecopages-react-hmr-shim',
136
+ }),
205
137
  ];
206
138
  }
207
139
 
208
140
  private isReactEntrypoint(filePath: string): boolean {
209
- if (filePath.endsWith('.tsx')) {
141
+ if (filePath.endsWith('.mdx')) {
142
+ return this.mdxCompilerOptions !== undefined;
143
+ }
144
+
145
+ if (!filePath.endsWith('.tsx')) {
146
+ return false;
147
+ }
148
+
149
+ if (!this.isRouteTemplate(filePath)) {
210
150
  return true;
211
151
  }
212
152
 
213
- return filePath.endsWith('.mdx') && this.mdxCompilerOptions !== undefined;
153
+ const templateExtension = this.resolveTemplateExtension(filePath);
154
+ if (!templateExtension) {
155
+ return false;
156
+ }
157
+
158
+ return this.ownedTemplateExtensions.has(templateExtension);
159
+ }
160
+
161
+ /**
162
+ * Returns true when a route file uses a compound extension like `page.foo.tsx`.
163
+ *
164
+ * @remarks
165
+ * React integration owns plain `.tsx` route templates. Compound extensions in
166
+ * pages/layouts are integration-specific route templates and should not be
167
+ * claimed by React HMR strategy.
168
+ */
169
+ private isRouteTemplate(filePath: string): boolean {
170
+ return filePath.startsWith(this.context.getPagesDir()) || filePath.startsWith(this.context.getLayoutsDir());
171
+ }
172
+
173
+ private resolveTemplateExtension(filePath: string): string | undefined {
174
+ return this.allTemplateExtensions.find((extension) => filePath.endsWith(extension));
214
175
  }
215
176
 
216
177
  /**
@@ -269,11 +230,10 @@ export class ReactHmrStrategy extends HmrStrategy {
269
230
  appLogger.debug(`Detected layout file change: ${_filePath}`);
270
231
  }
271
232
 
272
- const entrypointsToBuild =
273
- !this.knownEntrypoints.has(_filePath) && watchedFiles.has(_filePath)
274
- ? [[_filePath, watchedFiles.get(_filePath)!]]
275
- : watchedFiles.entries();
276
- this.knownEntrypoints.add(_filePath);
233
+ const changedEntrypointOutput = watchedFiles.get(_filePath);
234
+ const entrypointsToBuild = changedEntrypointOutput
235
+ ? [[_filePath, changedEntrypointOutput]]
236
+ : watchedFiles.entries();
277
237
 
278
238
  const updates: string[] = [];
279
239
  for (const [entrypoint, outputUrl] of entrypointsToBuild) {
@@ -346,16 +306,13 @@ export class ReactHmrStrategy extends HmrStrategy {
346
306
  plugins.unshift(mdxPlugin);
347
307
  }
348
308
 
349
- const result = await defaultBuildAdapter.build({
309
+ const result = await this.context.getBrowserBundleService().bundle({
310
+ profile: 'hmr-entrypoint',
350
311
  entrypoints: [entrypointPath],
351
312
  outdir: tempDir,
352
313
  naming: `[name].[hash].tmp`,
353
- target: 'browser',
354
- format: 'esm',
355
- sourcemap: 'none',
356
314
  plugins,
357
315
  minify: false,
358
- external: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom/client'],
359
316
  });
360
317
 
361
318
  if (!result.success) {
@@ -386,7 +343,7 @@ export class ReactHmrStrategy extends HmrStrategy {
386
343
  }
387
344
 
388
345
  /**
389
- * Processes bundled output by replacing specifiers and injecting HMR handler.
346
+ * Processes bundled output and injects the React HMR handler.
390
347
  * Writes to temp file first, then renames atomically to avoid conflicts.
391
348
  *
392
349
  * @param tempPath - Path to the temporary bundled file
@@ -403,7 +360,6 @@ export class ReactHmrStrategy extends HmrStrategy {
403
360
  try {
404
361
  let code = await fileSystem.readFile(tempPath);
405
362
 
406
- code = this.replaceBareSpecifiers(code);
407
363
  code = injectHmrHandler(code);
408
364
 
409
365
  await fileSystem.writeAsync(finalPath, code);
@@ -427,29 +383,4 @@ export class ReactHmrStrategy extends HmrStrategy {
427
383
  return false;
428
384
  }
429
385
  }
430
-
431
- /**
432
- * Replaces bare specifiers with runtime URLs.
433
- *
434
- * Handles both static imports and dynamic imports.
435
- *
436
- * @param code - The bundled code to transform
437
- * @returns The transformed code with runtime URLs
438
- */
439
- private replaceBareSpecifiers(code: string): string {
440
- const specifierMap = this.context.getSpecifierMap();
441
-
442
- if (specifierMap.size === 0) {
443
- return code;
444
- }
445
-
446
- let result = code;
447
- for (const [bareSpec, runtimeUrl] of specifierMap.entries()) {
448
- const escaped = bareSpec.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
449
- result = result.replace(new RegExp(`from\\s*["']${escaped}["']`, 'g'), `from "${runtimeUrl}"`);
450
- result = result.replace(new RegExp(`import\\(["']${escaped}["']\\)`, 'g'), `import("${runtimeUrl}")`);
451
- }
452
-
453
- return result;
454
- }
455
386
  }