@ecopages/react 0.2.0-alpha.9 → 0.2.1-beta.0

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 (77) hide show
  1. package/README.md +30 -13
  2. package/package.json +23 -12
  3. package/src/eco-embed.d.ts +11 -0
  4. package/src/eco-embed.js +11 -0
  5. package/src/react-hmr-strategy.d.ts +102 -18
  6. package/src/react-hmr-strategy.js +427 -50
  7. package/src/react-renderer.d.ts +100 -92
  8. package/src/react-renderer.js +356 -340
  9. package/src/react.constants.d.ts +1 -0
  10. package/src/react.constants.js +4 -0
  11. package/src/react.plugin.d.ts +25 -107
  12. package/src/react.plugin.js +109 -61
  13. package/src/react.types.d.ts +88 -0
  14. package/src/react.types.js +0 -0
  15. package/src/router-adapter.d.ts +7 -14
  16. package/src/runtime/use-sync-external-store-with-selector.d.ts +3 -0
  17. package/src/runtime/use-sync-external-store-with-selector.js +56 -0
  18. package/src/services/pages-index.d.ts +64 -0
  19. package/src/services/pages-index.js +73 -0
  20. package/src/services/react-bundle.service.d.ts +24 -9
  21. package/src/services/react-bundle.service.js +35 -24
  22. package/src/services/react-hmr-page-metadata-cache.d.ts +10 -1
  23. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  24. package/src/services/react-hydration-asset.service.d.ts +28 -19
  25. package/src/services/react-hydration-asset.service.js +83 -64
  26. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  27. package/src/services/react-mdx-config-dependency.service.js +122 -0
  28. package/src/services/react-page-module.service.d.ts +8 -3
  29. package/src/services/react-page-module.service.js +33 -26
  30. package/src/services/react-page-payload.service.d.ts +46 -0
  31. package/src/services/react-page-payload.service.js +67 -0
  32. package/src/services/react-runtime-bundle.service.d.ts +9 -2
  33. package/src/services/react-runtime-bundle.service.js +77 -16
  34. package/src/utils/client-graph-boundary-cache.d.ts +108 -0
  35. package/src/utils/client-graph-boundary-cache.js +116 -0
  36. package/src/utils/client-graph-boundary-plugin.d.ts +13 -5
  37. package/src/utils/client-graph-boundary-plugin.js +63 -5
  38. package/src/utils/component-config-traversal.d.ts +36 -0
  39. package/src/utils/component-config-traversal.js +54 -0
  40. package/src/utils/declared-modules.d.ts +1 -1
  41. package/src/utils/declared-modules.js +7 -16
  42. package/src/utils/dynamic.test.browser.d.ts +1 -0
  43. package/src/utils/dynamic.test.browser.js +33 -0
  44. package/src/utils/hydration-scripts.d.ts +9 -5
  45. package/src/utils/hydration-scripts.js +119 -34
  46. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  47. package/src/utils/hydration-scripts.test.browser.js +198 -0
  48. package/src/utils/react-dom-runtime-interop-plugin.d.ts +1 -1
  49. package/src/utils/react-dom-runtime-interop-plugin.js +9 -0
  50. package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
  51. package/src/utils/{react-runtime-specifier-map.d.ts → react-runtime-alias-map.d.ts} +3 -1
  52. package/src/utils/react-runtime-alias-map.js +90 -0
  53. package/CHANGELOG.md +0 -27
  54. package/src/react-hmr-strategy.ts +0 -386
  55. package/src/react-renderer.ts +0 -803
  56. package/src/react.plugin.ts +0 -276
  57. package/src/router-adapter.ts +0 -95
  58. package/src/services/react-bundle.service.ts +0 -108
  59. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  60. package/src/services/react-hydration-asset.service.ts +0 -263
  61. package/src/services/react-page-module.service.ts +0 -224
  62. package/src/services/react-runtime-bundle.service.ts +0 -172
  63. package/src/utils/client-graph-boundary-plugin.ts +0 -831
  64. package/src/utils/client-only.ts +0 -27
  65. package/src/utils/declared-modules.ts +0 -99
  66. package/src/utils/dynamic.ts +0 -27
  67. package/src/utils/hmr-scripts.ts +0 -47
  68. package/src/utils/html-boundary.ts +0 -66
  69. package/src/utils/hydration-scripts.ts +0 -459
  70. package/src/utils/reachability-analyzer.ts +0 -593
  71. package/src/utils/react-dom-runtime-interop-plugin.ts +0 -33
  72. package/src/utils/react-mdx-loader-plugin.ts +0 -63
  73. package/src/utils/react-runtime-specifier-map.js +0 -37
  74. package/src/utils/react-runtime-specifier-map.ts +0 -45
  75. package/src/utils/use-sync-external-store-shim-plugin.d.ts +0 -5
  76. package/src/utils/use-sync-external-store-shim-plugin.js +0 -41
  77. package/src/utils/use-sync-external-store-shim-plugin.ts +0 -45
@@ -1,13 +1,17 @@
1
1
  import path from "node:path";
2
2
  import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
3
- import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
3
+ import { RESOLVED_ASSETS_DIR } from "@ecopages/core/constants";
4
+ import { createBrowserRuntimePlugin } from "@ecopages/core/build/browser-runtime-plugin";
4
5
  import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
5
6
  import { Logger } from "@ecopages/logger";
6
7
  import { injectHmrHandler } from "./utils/hmr-scripts.js";
7
8
  import { createClientGraphBoundaryPlugin } from "./utils/client-graph-boundary-plugin.js";
9
+ import { ClientGraphBoundaryCache } from "./utils/client-graph-boundary-cache.js";
8
10
  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";
11
+ import { someInConfigTree } from "./utils/component-config-traversal.js";
12
+ import { createReactMdxLoaderPlugin } from "./utils/react-mdx-loader-plugin.js";
13
+ import { getReactClientGraphAllowSpecifiers } from "./utils/react-runtime-alias-map.js";
14
+ import { PagesIndex } from "./services/pages-index.js";
11
15
  const appLogger = new Logger("[ReactHmrStrategy]");
12
16
  class ReactHmrStrategy extends HmrStrategy {
13
17
  type = HmrStrategyType.INTEGRATION;
@@ -20,51 +24,59 @@ class ReactHmrStrategy extends HmrStrategy {
20
24
  /**
21
25
  * Creates a new React HMR strategy instance.
22
26
  *
23
- * @param context - The HMR context providing access to watched files, plugins, build directories,
24
- * and the layouts directory for detecting layout file changes that require full
25
- * page reloads instead of module-level HMR updates.
26
- * @param pageMetadataCache - React-only cache of declared browser modules discovered during
27
- * server rendering. This avoids re-importing unchanged page modules
28
- * during save-time Fast Refresh rebuilds.
29
- * @param mdxCompilerOptions - Optional MDX compiler options for processing .mdx files
30
- * @param explicitGraphEnabled - Enables explicit graph mode for React HMR bundling.
31
- * In explicit mode, HMR builds omit AST server-only stripping plugins in React paths.
27
+ * @param options - React HMR runtime services and behavior flags.
32
28
  */
33
29
  context;
34
30
  pageMetadataCache;
35
31
  explicitGraphEnabled;
36
- constructor(context, pageMetadataCache, mdxCompilerOptions, ownedTemplateExtensions = [".tsx"], allTemplateExtensions = [".tsx"], explicitGraphEnabled = false) {
32
+ runtimeManifest;
33
+ clientGraphBoundaryCache;
34
+ pagesIndex;
35
+ constructor(options) {
37
36
  super();
38
- this.context = context;
39
- this.pageMetadataCache = pageMetadataCache;
40
- this.explicitGraphEnabled = explicitGraphEnabled;
41
- this.mdxCompilerOptions = mdxCompilerOptions;
42
- this.ownedTemplateExtensions = new Set(ownedTemplateExtensions);
43
- this.allTemplateExtensions = [...allTemplateExtensions].sort((a, b) => b.length - a.length);
37
+ this.context = options.context;
38
+ this.pageMetadataCache = options.pageMetadataCache;
39
+ this.runtimeManifest = options.runtimeManifest;
40
+ this.explicitGraphEnabled = options.explicitGraphEnabled ?? false;
41
+ this.clientGraphBoundaryCache = options.clientGraphBoundaryCache ?? new ClientGraphBoundaryCache();
42
+ this.pagesIndex = new PagesIndex({
43
+ pagesDir: this.context.getPagesDir(),
44
+ extensions: options.allTemplateExtensions,
45
+ isPageEntrypoint: (file) => this.isPageEntrypoint(file)
46
+ });
47
+ this.mdxCompilerOptions = options.mdxCompilerOptions;
48
+ this.ownedTemplateExtensions = new Set(options.ownedTemplateExtensions ?? [".tsx"]);
49
+ this.allTemplateExtensions = [...options.allTemplateExtensions ?? [".tsx"]].sort(
50
+ (a, b) => b.length - a.length
51
+ );
44
52
  }
45
53
  /**
46
54
  * Returns build plugins for React HMR bundling.
47
55
  *
48
56
  * Includes the client graph boundary plugin to prevent undeclared imports
49
57
  * (including `node:*`) from breaking the browser bundle.
58
+ *
59
+ * @remarks
60
+ * HMR builds receive the React runtime manifest and rewrite manifest-owned
61
+ * runtime imports to concrete asset URLs before module resolution.
50
62
  */
51
63
  getBuildPlugins(declaredModules) {
52
- const allowSpecifiers = getReactClientGraphAllowSpecifiers(this.context.getSpecifierMap().keys());
53
- const runtimeAliasPlugin = createRuntimeSpecifierAliasPlugin(this.context.getSpecifierMap(), {
54
- name: "react-hmr-runtime-specifier-alias"
64
+ const allowSpecifiers = getReactClientGraphAllowSpecifiers(
65
+ this.runtimeManifest.assets.map((asset) => asset.specifier)
66
+ );
67
+ const runtimeRewritePlugin = createBrowserRuntimePlugin({
68
+ name: "react-hmr-runtime-import-rewrite",
69
+ manifest: this.runtimeManifest
55
70
  });
56
71
  return [
57
72
  createClientGraphBoundaryPlugin({
58
73
  absWorkingDir: path.dirname(this.context.getSrcDir()),
59
74
  alwaysAllowSpecifiers: allowSpecifiers,
60
- declaredModules
75
+ declaredModules,
76
+ cache: this.clientGraphBoundaryCache
61
77
  }),
62
- ...runtimeAliasPlugin ? [runtimeAliasPlugin] : [],
63
- ...this.context.getPlugins(),
64
- createUseSyncExternalStoreShimPlugin({
65
- name: "react-hmr-use-sync-external-store-shim",
66
- namespace: "ecopages-react-hmr-shim"
67
- })
78
+ ...runtimeRewritePlugin ? [runtimeRewritePlugin] : [],
79
+ ...this.context.getPlugins()
68
80
  ];
69
81
  }
70
82
  isReactEntrypoint(filePath) {
@@ -74,10 +86,13 @@ class ReactHmrStrategy extends HmrStrategy {
74
86
  if (!filePath.endsWith(".tsx")) {
75
87
  return false;
76
88
  }
89
+ const templateExtension = this.resolveTemplateExtension(filePath);
90
+ if (templateExtension && templateExtension !== ".tsx") {
91
+ return this.ownedTemplateExtensions.has(templateExtension);
92
+ }
77
93
  if (!this.isRouteTemplate(filePath)) {
78
94
  return true;
79
95
  }
80
- const templateExtension = this.resolveTemplateExtension(filePath);
81
96
  if (!templateExtension) {
82
97
  return false;
83
98
  }
@@ -97,11 +112,47 @@ class ReactHmrStrategy extends HmrStrategy {
97
112
  resolveTemplateExtension(filePath) {
98
113
  return this.allTemplateExtensions.find((extension) => filePath.endsWith(extension));
99
114
  }
115
+ ownsWatchedEntrypoint(filePath) {
116
+ return this.pageMetadataCache.ownsEntrypoint(filePath);
117
+ }
118
+ configContainsFile(config, filePath) {
119
+ const resolvedFilePath = path.resolve(filePath);
120
+ return someInConfigTree(config, (node) => {
121
+ if (!node.__eco?.file) {
122
+ return false;
123
+ }
124
+ return path.resolve(node.__eco.file) === resolvedFilePath;
125
+ });
126
+ }
127
+ pageModuleRequiresLayoutRefresh(pageModule, filePath) {
128
+ return [pageModule.default?.config, pageModule.config].some((config) => {
129
+ return this.configContainsFile(config?.layout?.config, filePath);
130
+ });
131
+ }
132
+ async hasLayoutOwnedDependencyTarget(changedFilePath, requestedTargets) {
133
+ for (const target of requestedTargets) {
134
+ if (!this.isPageEntrypoint(target.entrypointPath)) {
135
+ continue;
136
+ }
137
+ const pageModule = await this.importNodePageModule(target.entrypointPath);
138
+ if (this.pageModuleRequiresLayoutRefresh(pageModule, changedFilePath)) {
139
+ return true;
140
+ }
141
+ }
142
+ return false;
143
+ }
100
144
  /**
101
145
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
102
146
  *
147
+ * Uses a three-way decision strategy for selective invalidation:
148
+ * 1. If the file is a watched entrypoint, check if React owns it
149
+ * 2. If the file is a dependency of watched entrypoints (via dependency graph),
150
+ * check if any affected entrypoints are React-owned. Returns false if hits
151
+ * exist but none are owned (prevents unnecessary rebuilds).
152
+ * 3. Otherwise, check if the file itself is a React entrypoint template
153
+ *
103
154
  * @param filePath - Absolute path to the changed file
104
- * @returns True if this is a registered React or MDX entrypoint
155
+ * @returns True if this file should trigger React HMR rebuilds
105
156
  */
106
157
  matches(filePath) {
107
158
  const watchedFiles = this.context.getWatchedFiles();
@@ -109,6 +160,18 @@ class ReactHmrStrategy extends HmrStrategy {
109
160
  if (watchedFiles.size === 0) {
110
161
  return false;
111
162
  }
163
+ if (watchedFiles.has(filePath)) {
164
+ return this.ownsWatchedEntrypoint(filePath);
165
+ }
166
+ const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(filePath);
167
+ if (dependencyHits.size > 0) {
168
+ for (const entrypoint of dependencyHits) {
169
+ if (this.ownsWatchedEntrypoint(entrypoint)) {
170
+ return true;
171
+ }
172
+ }
173
+ return false;
174
+ }
112
175
  return this.isReactEntrypoint(filePath);
113
176
  }
114
177
  /**
@@ -125,13 +188,95 @@ class ReactHmrStrategy extends HmrStrategy {
125
188
  isLayoutFile(filePath) {
126
189
  return filePath.startsWith(this.context.getLayoutsDir());
127
190
  }
191
+ isPageEntrypoint(filePath) {
192
+ return filePath.startsWith(this.context.getPagesDir()) && this.isReactEntrypoint(filePath);
193
+ }
194
+ getEntrypointOutput(entrypointPath) {
195
+ const srcDir = this.context.getSrcDir();
196
+ const relativePath = path.relative(srcDir, entrypointPath);
197
+ const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, ".js");
198
+ const encodedPathJs = this.encodeDynamicSegments(relativePathJs);
199
+ const outputPath = path.join(this.context.getDistDir(), encodedPathJs);
200
+ const outputUrl = `/${path.join(RESOLVED_ASSETS_DIR, "_hmr", encodedPathJs).split(path.sep).join("/")}`;
201
+ return { outputPath, outputUrl };
202
+ }
203
+ getRolldownEntryKey(entrypointPath) {
204
+ const srcDir = this.context.getSrcDir();
205
+ const relativePath = path.relative(srcDir, entrypointPath);
206
+ const relativePathNoExt = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, "");
207
+ return relativePathNoExt;
208
+ }
209
+ getTempFileBasename(entrypointPath) {
210
+ const srcDir = this.context.getSrcDir();
211
+ const relativePath = path.relative(srcDir, entrypointPath);
212
+ const relativePathNoExt = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, "");
213
+ const encodedPath = this.encodeDynamicSegments(relativePathNoExt);
214
+ return path.basename(encodedPath);
215
+ }
216
+ async collectReactPageBuildTargets() {
217
+ await this.pagesIndex.refresh();
218
+ const indexed = this.pagesIndex.list();
219
+ const targets = /* @__PURE__ */ new Map();
220
+ for (const entrypointPath of indexed) {
221
+ this.pageMetadataCache.markOwnedEntrypoint(entrypointPath);
222
+ targets.set(entrypointPath, {
223
+ entrypointPath,
224
+ outputUrl: this.getEntrypointOutput(entrypointPath).outputUrl
225
+ });
226
+ }
227
+ return Array.from(targets.values()).sort(
228
+ (left, right) => left.entrypointPath.localeCompare(right.entrypointPath)
229
+ );
230
+ }
128
231
  /**
129
- * Processes a React file change by rebuilding all React entrypoints.
232
+ * Expands one HMR request into the full React page build cohort when needed.
233
+ *
234
+ * @remarks
235
+ * Page and layout changes need one shared rebuild pass so sibling routes keep
236
+ * a consistent client module graph. Non-page changes that do not touch a page
237
+ * cohort can stay scoped to the originally requested targets.
238
+ */
239
+ async resolveBuildTargets(requestedTargets, changedFilePath) {
240
+ const requestedPageTargets = requestedTargets.filter((target) => this.isPageEntrypoint(target.entrypointPath));
241
+ const shouldGroupPageBuilds = this.isLayoutFile(changedFilePath) || requestedPageTargets.length > 0;
242
+ if (!shouldGroupPageBuilds) {
243
+ return [];
244
+ }
245
+ const groupedTargets = new Map(requestedPageTargets.map((target) => [target.entrypointPath, target]));
246
+ for (const target of await this.collectReactPageBuildTargets()) {
247
+ groupedTargets.set(target.entrypointPath, target);
248
+ }
249
+ return Array.from(groupedTargets.values()).sort(
250
+ (left, right) => left.entrypointPath.localeCompare(right.entrypointPath)
251
+ );
252
+ }
253
+ partitionBuildTargets(requestedTargets, groupedPageTargets) {
254
+ if (groupedPageTargets.length === 0) {
255
+ return {
256
+ pageTargets: [],
257
+ nonPageTargets: requestedTargets
258
+ };
259
+ }
260
+ const groupedPageEntrypoints = new Set(groupedPageTargets.map((target) => target.entrypointPath));
261
+ return {
262
+ pageTargets: groupedPageTargets,
263
+ nonPageTargets: requestedTargets.filter(
264
+ (target) => !groupedPageEntrypoints.has(target.entrypointPath) && !this.isPageEntrypoint(target.entrypointPath)
265
+ )
266
+ };
267
+ }
268
+ /**
269
+ * Processes a React file change by rebuilding affected React entrypoints.
270
+ *
271
+ * Uses a three-way decision strategy for selective invalidation:
272
+ * 1. Changed file is a watched entrypoint: rebuild only that entrypoint
273
+ * 2. Dependency graph has hits: rebuild only affected React-owned entrypoints.
274
+ * If hits exist but none map to React-owned entrypoints, return 'none' to
275
+ * prevent unnecessary rebuilds.
276
+ * 3. Dependency graph miss: fall back to rebuilding all watched entrypoints
130
277
  *
131
278
  * For layout files, broadcasts a 'layout-update' event to trigger full page reload.
132
279
  * For regular components/pages, broadcasts 'update' events for module-level HMR.
133
- * When a page entrypoint is first registered, only that entrypoint is built.
134
- * Subsequent file updates rebuild all watched React entrypoints as usual.
135
280
  *
136
281
  * @param _filePath - Absolute path to the changed file
137
282
  * @returns Action to broadcast update events (layout-update for layouts, update for components)
@@ -144,24 +289,86 @@ class ReactHmrStrategy extends HmrStrategy {
144
289
  return { type: "none" };
145
290
  }
146
291
  const isLayout = this.isLayoutFile(_filePath);
292
+ const isChangedPageEntrypoint = this.isPageEntrypoint(_filePath);
147
293
  if (isLayout) {
148
294
  appLogger.debug(`Detected layout file change: ${_filePath}`);
149
295
  }
150
296
  const changedEntrypointOutput = watchedFiles.get(_filePath);
151
- const entrypointsToBuild = changedEntrypointOutput ? [[_filePath, changedEntrypointOutput]] : watchedFiles.entries();
297
+ if (changedEntrypointOutput && !this.ownsWatchedEntrypoint(_filePath)) {
298
+ if (this.isReactEntrypoint(_filePath)) {
299
+ this.pageMetadataCache.markOwnedEntrypoint(_filePath);
300
+ } else {
301
+ appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
302
+ return { type: "none" };
303
+ }
304
+ }
305
+ const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(_filePath);
306
+ const hasDependencyHits = dependencyHits.size > 0;
307
+ const affectedEntrypoints = /* @__PURE__ */ new Map();
308
+ let hasOwnedLayoutDependencyHit = false;
309
+ let layoutOwnedPageTargets = [];
310
+ let hasLayoutOwnedRequestedTarget = false;
311
+ if (hasDependencyHits && !changedEntrypointOutput) {
312
+ for (const entrypoint of dependencyHits) {
313
+ const outputUrl = watchedFiles.get(entrypoint);
314
+ if (outputUrl && this.ownsWatchedEntrypoint(entrypoint)) {
315
+ affectedEntrypoints.set(entrypoint, outputUrl);
316
+ continue;
317
+ }
318
+ if (this.isLayoutFile(entrypoint) && this.ownsWatchedEntrypoint(entrypoint)) {
319
+ hasOwnedLayoutDependencyHit = true;
320
+ }
321
+ }
322
+ if (affectedEntrypoints.size === 0 && !hasOwnedLayoutDependencyHit) {
323
+ appLogger.debug(`Dependency hits found but none map to React-owned watched entrypoints`);
324
+ return { type: "none" };
325
+ }
326
+ }
327
+ if (changedEntrypointOutput && !isLayout && !isChangedPageEntrypoint) {
328
+ layoutOwnedPageTargets = await this.collectReactPageBuildTargets();
329
+ hasLayoutOwnedRequestedTarget = await this.hasLayoutOwnedDependencyTarget(
330
+ _filePath,
331
+ layoutOwnedPageTargets
332
+ );
333
+ }
334
+ const requestedTargets = changedEntrypointOutput ? hasLayoutOwnedRequestedTarget ? [{ entrypointPath: _filePath, outputUrl: changedEntrypointOutput }, ...layoutOwnedPageTargets] : [{ entrypointPath: _filePath, outputUrl: changedEntrypointOutput }] : hasOwnedLayoutDependencyHit ? await this.collectReactPageBuildTargets() : hasDependencyHits ? Array.from(affectedEntrypoints, ([entrypointPath, outputUrl]) => ({ entrypointPath, outputUrl })) : Array.from(watchedFiles, ([entrypointPath, outputUrl]) => ({ entrypointPath, outputUrl }));
335
+ const groupedPageTargets = await this.resolveBuildTargets(requestedTargets, _filePath);
336
+ const { pageTargets, nonPageTargets } = this.partitionBuildTargets(requestedTargets, groupedPageTargets);
337
+ if (!changedEntrypointOutput) {
338
+ hasLayoutOwnedRequestedTarget = await this.hasLayoutOwnedDependencyTarget(_filePath, requestedTargets);
339
+ }
340
+ const requiresLayoutRefresh = isLayout || hasOwnedLayoutDependencyHit || hasLayoutOwnedRequestedTarget;
152
341
  const updates = [];
153
- for (const [entrypoint, outputUrl] of entrypointsToBuild) {
154
- if (!this.isReactEntrypoint(entrypoint)) {
342
+ const requestedOutputUrls = new Set(requestedTargets.map((target) => target.outputUrl));
343
+ if (pageTargets.length > 1) {
344
+ appLogger.debug(`Bundling ${pageTargets.length} React page entrypoints together`);
345
+ const rebuiltOutputs = await this.bundleReactEntrypoints(pageTargets);
346
+ for (const outputUrl of rebuiltOutputs) {
347
+ if (requestedOutputUrls.has(outputUrl)) {
348
+ updates.push(outputUrl);
349
+ }
350
+ }
351
+ } else {
352
+ for (const { entrypointPath, outputUrl } of pageTargets) {
353
+ appLogger.debug(`Bundling ${entrypointPath}`);
354
+ const success = await this.bundleReactEntrypoint(entrypointPath, outputUrl);
355
+ if (success && requestedOutputUrls.has(outputUrl)) {
356
+ updates.push(outputUrl);
357
+ }
358
+ }
359
+ }
360
+ for (const { entrypointPath, outputUrl } of nonPageTargets) {
361
+ if (!this.isReactEntrypoint(entrypointPath)) {
155
362
  continue;
156
363
  }
157
- appLogger.debug(`Bundling ${entrypoint}`);
158
- const success = await this.bundleReactEntrypoint(entrypoint, outputUrl);
159
- if (success) {
364
+ appLogger.debug(`Bundling ${entrypointPath}`);
365
+ const success = await this.bundleReactEntrypoint(entrypointPath, outputUrl);
366
+ if (success && requestedOutputUrls.has(outputUrl)) {
160
367
  updates.push(outputUrl);
161
368
  }
162
369
  }
163
370
  if (updates.length > 0) {
164
- if (isLayout) {
371
+ if (requiresLayoutRefresh) {
165
372
  appLogger.debug(`Layout update detected, sending layout-update event`);
166
373
  return {
167
374
  type: "broadcast",
@@ -188,6 +395,11 @@ class ReactHmrStrategy extends HmrStrategy {
188
395
  /**
189
396
  * Bundles a single React/MDX entrypoint with HMR support.
190
397
  *
398
+ * After successful bundling, populates the entrypoint dependency graph with
399
+ * the build's dependency metadata. This enables selective invalidation on
400
+ * subsequent file changes, so only entrypoints affected by a changed
401
+ * dependency are rebuilt.
402
+ *
191
403
  * @param entrypointPath - Absolute path to the source file
192
404
  * @param outputUrl - URL path for the bundled file
193
405
  * @returns True if bundling was successful
@@ -195,19 +407,25 @@ class ReactHmrStrategy extends HmrStrategy {
195
407
  async bundleReactEntrypoint(entrypointPath, outputUrl) {
196
408
  try {
197
409
  const isMdx = entrypointPath.endsWith(".mdx");
198
- const srcDir = this.context.getSrcDir();
199
- const relativePath = path.relative(srcDir, entrypointPath);
200
- const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, ".js");
201
- const encodedPathJs = this.encodeDynamicSegments(relativePathJs);
202
- const outputPath = path.join(this.context.getDistDir(), encodedPathJs);
410
+ const { outputPath } = this.getEntrypointOutput(entrypointPath);
203
411
  const tempDir = path.dirname(outputPath);
204
- const declaredModules = this.pageMetadataCache.getDeclaredModules(entrypointPath) ? this.pageMetadataCache.getDeclaredModules(entrypointPath) : isMdx ? await collectPageDeclaredModules(entrypointPath) : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
412
+ const cachedDeclared = this.pageMetadataCache.getDeclaredModules(entrypointPath);
413
+ let entrypointDeclaredModules;
414
+ let declaredModules;
415
+ if (cachedDeclared) {
416
+ entrypointDeclaredModules = cachedDeclared;
417
+ declaredModules = cachedDeclared;
418
+ } else {
419
+ entrypointDeclaredModules = isMdx ? await collectPageDeclaredModules(entrypointPath) : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
420
+ this.pageMetadataCache.setDeclaredModules(entrypointPath, entrypointDeclaredModules);
421
+ declaredModules = entrypointDeclaredModules;
422
+ }
205
423
  const plugins = this.getBuildPlugins(declaredModules);
206
424
  if (isMdx && this.mdxCompilerOptions) {
207
- const { createReactMdxLoaderPlugin } = await import("./utils/react-mdx-loader-plugin.js");
208
425
  const mdxPlugin = createReactMdxLoaderPlugin(this.mdxCompilerOptions);
209
426
  plugins.unshift(mdxPlugin);
210
427
  }
428
+ await this.clearHmrOutdir(tempDir);
211
429
  const result = await this.context.getBrowserBundleService().bundle({
212
430
  profile: "hmr-entrypoint",
213
431
  entrypoints: [entrypointPath],
@@ -220,18 +438,154 @@ class ReactHmrStrategy extends HmrStrategy {
220
438
  appLogger.error(`Failed to build ${entrypointPath}:`, result.logs);
221
439
  return false;
222
440
  }
441
+ if (result.dependencyGraph?.entrypoints) {
442
+ const dependencyGraph = this.context.getEntrypointDependencyGraph();
443
+ for (const [entrypoint, deps] of Object.entries(result.dependencyGraph.entrypoints)) {
444
+ dependencyGraph.setEntrypointDependencies(entrypoint, deps);
445
+ }
446
+ }
223
447
  const tempFile = result.outputs[0]?.path;
224
448
  if (!tempFile) {
225
449
  appLogger.error(`No output file generated for ${entrypointPath}`);
226
450
  return false;
227
451
  }
228
- const processed = await this.processOutput(tempFile, outputPath, outputUrl);
452
+ const resolvedTempFile = await this.resolveTempOutputPath(tempFile);
453
+ if (!resolvedTempFile) {
454
+ appLogger.debug(`Skipping stale temp output for ${outputUrl}: ${tempFile}`);
455
+ return false;
456
+ }
457
+ const processed = await this.processOutput(resolvedTempFile, outputPath, outputUrl);
229
458
  return processed;
230
459
  } catch (error) {
231
460
  appLogger.error(`Error bundling ${entrypointPath}:`, error);
232
461
  return false;
233
462
  }
234
463
  }
464
+ /**
465
+ * Bundles multiple React/MDX entrypoints in a single build pass.
466
+ *
467
+ * Uses code splitting to share common dependencies across entrypoints.
468
+ * After successful bundling, populates the entrypoint dependency graph with
469
+ * the build's dependency metadata for selective invalidation.
470
+ *
471
+ * @param entrypoints - Array of entrypoint paths and their output URLs
472
+ * @returns Array of output URLs that were successfully built
473
+ */
474
+ async bundleReactEntrypoints(entrypoints) {
475
+ try {
476
+ const declaredModules = /* @__PURE__ */ new Set();
477
+ let shouldEnableMdx = false;
478
+ for (const { entrypointPath } of entrypoints) {
479
+ const entrypointDeclaredModules = this.pageMetadataCache.getDeclaredModules(entrypointPath) ? this.pageMetadataCache.getDeclaredModules(entrypointPath) : entrypointPath.endsWith(".mdx") ? await collectPageDeclaredModules(entrypointPath) : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
480
+ this.pageMetadataCache.setDeclaredModules(entrypointPath, entrypointDeclaredModules);
481
+ for (const declaredModule of entrypointDeclaredModules) {
482
+ declaredModules.add(declaredModule);
483
+ }
484
+ if (entrypointPath.endsWith(".mdx")) {
485
+ shouldEnableMdx = true;
486
+ }
487
+ }
488
+ const plugins = this.getBuildPlugins([...declaredModules]);
489
+ if (shouldEnableMdx && this.mdxCompilerOptions) {
490
+ plugins.unshift(createReactMdxLoaderPlugin(this.mdxCompilerOptions));
491
+ }
492
+ await this.clearHmrOutdir(this.context.getDistDir());
493
+ const entryNameByPath = /* @__PURE__ */ new Map();
494
+ for (const { entrypointPath } of entrypoints) {
495
+ entryNameByPath.set(entrypointPath, {
496
+ key: this.getRolldownEntryKey(entrypointPath),
497
+ basename: this.getTempFileBasename(entrypointPath)
498
+ });
499
+ }
500
+ const result = await this.context.getBrowserBundleService().bundle({
501
+ profile: "hmr-entrypoint",
502
+ entrypoints: Object.fromEntries(
503
+ entrypoints.map(({ entrypointPath }) => [entryNameByPath.get(entrypointPath).key, entrypointPath])
504
+ ),
505
+ outdir: this.context.getDistDir(),
506
+ naming: "[name].[hash].tmp",
507
+ splitting: true,
508
+ plugins,
509
+ minify: false
510
+ });
511
+ if (!result.success) {
512
+ appLogger.error(`Failed to build grouped React entrypoints:`, result.logs);
513
+ return [];
514
+ }
515
+ if (result.dependencyGraph?.entrypoints) {
516
+ const dependencyGraph = this.context.getEntrypointDependencyGraph();
517
+ for (const [entrypoint, deps] of Object.entries(result.dependencyGraph.entrypoints)) {
518
+ dependencyGraph.setEntrypointDependencies(entrypoint, deps);
519
+ }
520
+ }
521
+ const updatedOutputs = [];
522
+ for (const { entrypointPath, outputUrl } of entrypoints) {
523
+ const { outputPath } = this.getEntrypointOutput(entrypointPath);
524
+ const { basename: tempBasename, key: entryKey } = entryNameByPath.get(entrypointPath);
525
+ const expectedSubdir = path.join(this.context.getDistDir(), path.dirname(entryKey));
526
+ const tempOutput = result.outputs.find((output) => {
527
+ return path.dirname(output.path) === expectedSubdir && path.basename(output.path).startsWith(`${tempBasename}.`) && path.basename(output.path).includes(".tmp");
528
+ })?.path;
529
+ const resolvedTempOutput = tempOutput ? await this.resolveTempOutputPath(tempOutput) : await this.resolveTempOutputPath(path.join(expectedSubdir, `${tempBasename}.[hash].tmp.js`));
530
+ if (!resolvedTempOutput) {
531
+ appLogger.debug(`Missing grouped temp output for ${outputUrl}`);
532
+ continue;
533
+ }
534
+ const processed = await this.processOutput(resolvedTempOutput, outputPath, outputUrl);
535
+ if (processed) {
536
+ updatedOutputs.push(outputUrl);
537
+ }
538
+ }
539
+ return updatedOutputs;
540
+ } catch (error) {
541
+ appLogger.error(`Error bundling grouped React entrypoints:`, error);
542
+ return [];
543
+ }
544
+ }
545
+ async resolveTempOutputPath(tempPath) {
546
+ if (fileSystem.exists(tempPath)) {
547
+ return tempPath;
548
+ }
549
+ if (!tempPath.includes("[hash]")) {
550
+ return null;
551
+ }
552
+ const directory = path.dirname(tempPath);
553
+ const pattern = path.basename(tempPath).replaceAll("[hash]", "*");
554
+ const matches = await fileSystem.glob([pattern], { cwd: directory });
555
+ if (matches.length === 0) {
556
+ return null;
557
+ }
558
+ return path.isAbsolute(matches[0]) ? matches[0] : path.join(directory, matches[0]);
559
+ }
560
+ /**
561
+ * Clears stale HMR output from a directory before a rebuild.
562
+ *
563
+ * Only removes:
564
+ * - `*.tmp.js` files (the per-build bundler output the strategy owns)
565
+ * - the `chunks/` subdirectory (the bundler's splitting target)
566
+ *
567
+ * The HMR runtime script (`_hmr_runtime.js`) and any user-authored
568
+ * assets in the outdir are preserved. This is the minimal set of
569
+ * files that, if left from a previous build, can cause the bundler
570
+ * to emit `ENOENT` for chunk references that point to entrypoints
571
+ * whose hash has since changed.
572
+ */
573
+ async clearHmrOutdir(outdir) {
574
+ if (!fileSystem.exists(outdir)) {
575
+ return;
576
+ }
577
+ const tempFiles = await fileSystem.glob(["**/*.tmp.js"], { cwd: outdir });
578
+ await Promise.all(
579
+ tempFiles.map((relativePath) => {
580
+ const absolutePath = path.isAbsolute(relativePath) ? relativePath : path.join(outdir, relativePath);
581
+ return fileSystem.removeAsync(absolutePath).catch(() => void 0);
582
+ })
583
+ );
584
+ const chunksDir = path.join(outdir, "chunks");
585
+ if (fileSystem.exists(chunksDir)) {
586
+ await fileSystem.removeAsync(chunksDir).catch(() => void 0);
587
+ }
588
+ }
235
589
  /**
236
590
  * Encodes dynamic route segments (brackets) in file paths.
237
591
  * Converts `[slug]` to `_slug_` to avoid filesystem issues.
@@ -239,6 +593,28 @@ class ReactHmrStrategy extends HmrStrategy {
239
593
  encodeDynamicSegments(filepath) {
240
594
  return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
241
595
  }
596
+ rewriteChunkImportUrls(code) {
597
+ const hmrChunkBaseUrl = `/${path.join(RESOLVED_ASSETS_DIR, "_hmr").split(path.sep).join("/")}`;
598
+ return code.replace(/(['"])(?:\.\.\/)+(chunk-[^'"]+\.js)\1/g, (_match, quote, chunkFile) => {
599
+ return `${quote}${hmrChunkBaseUrl}/${chunkFile}${quote}`;
600
+ });
601
+ }
602
+ isMissingTempOutputError(error) {
603
+ if (error instanceof FileNotFoundError) {
604
+ return true;
605
+ }
606
+ if (!(error instanceof Error)) {
607
+ return false;
608
+ }
609
+ if (error.message.includes("not found") || error.message.includes("ENOENT")) {
610
+ return true;
611
+ }
612
+ const errorCause = error.cause;
613
+ if (errorCause instanceof FileNotFoundError) {
614
+ return true;
615
+ }
616
+ return typeof errorCause === "object" && errorCause !== null && "code" in errorCause && errorCause.code === "ENOENT";
617
+ }
242
618
  /**
243
619
  * Processes bundled output and injects the React HMR handler.
244
620
  * Writes to temp file first, then renames atomically to avoid conflicts.
@@ -255,6 +631,7 @@ class ReactHmrStrategy extends HmrStrategy {
255
631
  }
256
632
  try {
257
633
  let code = await fileSystem.readFile(tempPath);
634
+ code = this.rewriteChunkImportUrls(code);
258
635
  code = injectHmrHandler(code);
259
636
  await fileSystem.writeAsync(finalPath, code);
260
637
  await fileSystem.removeAsync(tempPath).catch(() => {
@@ -262,7 +639,7 @@ class ReactHmrStrategy extends HmrStrategy {
262
639
  appLogger.debug(`Processed ${url} with HMR handler`);
263
640
  return true;
264
641
  } catch (error) {
265
- if (error instanceof FileNotFoundError || error instanceof Error && error.message.includes("not found") || error instanceof Error && "code" in error && error.code === "ENOENT") {
642
+ if (this.isMissingTempOutputError(error)) {
266
643
  appLogger.debug(`Skipping stale temp output for ${url}: ${tempPath}`);
267
644
  await fileSystem.removeAsync(tempPath).catch(() => {
268
645
  });