@ecopages/react 0.2.0-alpha.8 → 0.2.0-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.
- package/README.md +30 -13
- package/package.json +23 -12
- package/src/eco-embed.d.ts +11 -0
- package/src/eco-embed.js +11 -0
- package/src/react-hmr-strategy.d.ts +102 -18
- package/src/react-hmr-strategy.js +427 -50
- package/src/react-renderer.d.ts +100 -92
- package/src/react-renderer.js +356 -340
- package/src/react.constants.d.ts +1 -0
- package/src/react.constants.js +4 -0
- package/src/react.plugin.d.ts +25 -107
- package/src/react.plugin.js +109 -61
- package/src/react.types.d.ts +88 -0
- package/src/react.types.js +0 -0
- package/src/router-adapter.d.ts +7 -14
- package/src/runtime/use-sync-external-store-with-selector.d.ts +3 -0
- package/src/runtime/use-sync-external-store-with-selector.js +56 -0
- package/src/services/pages-index.d.ts +64 -0
- package/src/services/pages-index.js +73 -0
- package/src/services/react-bundle.service.d.ts +24 -9
- package/src/services/react-bundle.service.js +35 -24
- package/src/services/react-hmr-page-metadata-cache.d.ts +10 -1
- package/src/services/react-hmr-page-metadata-cache.js +18 -2
- package/src/services/react-hydration-asset.service.d.ts +28 -19
- package/src/services/react-hydration-asset.service.js +83 -64
- package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
- package/src/services/react-mdx-config-dependency.service.js +122 -0
- package/src/services/react-page-module.service.d.ts +8 -3
- package/src/services/react-page-module.service.js +33 -26
- package/src/services/react-page-payload.service.d.ts +46 -0
- package/src/services/react-page-payload.service.js +67 -0
- package/src/services/react-runtime-bundle.service.d.ts +9 -2
- package/src/services/react-runtime-bundle.service.js +77 -16
- package/src/utils/client-graph-boundary-cache.d.ts +108 -0
- package/src/utils/client-graph-boundary-cache.js +116 -0
- package/src/utils/client-graph-boundary-plugin.d.ts +13 -5
- package/src/utils/client-graph-boundary-plugin.js +63 -5
- package/src/utils/component-config-traversal.d.ts +36 -0
- package/src/utils/component-config-traversal.js +54 -0
- package/src/utils/declared-modules.d.ts +1 -1
- package/src/utils/declared-modules.js +7 -16
- package/src/utils/dynamic.test.browser.d.ts +1 -0
- package/src/utils/dynamic.test.browser.js +33 -0
- package/src/utils/hydration-scripts.d.ts +9 -5
- package/src/utils/hydration-scripts.js +119 -34
- package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
- package/src/utils/hydration-scripts.test.browser.js +198 -0
- package/src/utils/react-dom-runtime-interop-plugin.d.ts +1 -1
- package/src/utils/react-dom-runtime-interop-plugin.js +9 -0
- package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
- package/src/utils/{react-runtime-specifier-map.d.ts → react-runtime-alias-map.d.ts} +3 -1
- package/src/utils/react-runtime-alias-map.js +90 -0
- package/CHANGELOG.md +0 -27
- package/src/react-hmr-strategy.ts +0 -386
- package/src/react-renderer.ts +0 -803
- package/src/react.plugin.ts +0 -276
- package/src/router-adapter.ts +0 -95
- package/src/services/react-bundle.service.ts +0 -108
- package/src/services/react-hmr-page-metadata-cache.ts +0 -24
- package/src/services/react-hydration-asset.service.ts +0 -263
- package/src/services/react-page-module.service.ts +0 -224
- package/src/services/react-runtime-bundle.service.ts +0 -172
- package/src/utils/client-graph-boundary-plugin.ts +0 -831
- package/src/utils/client-only.ts +0 -27
- package/src/utils/declared-modules.ts +0 -99
- package/src/utils/dynamic.ts +0 -27
- package/src/utils/hmr-scripts.ts +0 -47
- package/src/utils/html-boundary.ts +0 -66
- package/src/utils/hydration-scripts.ts +0 -459
- package/src/utils/reachability-analyzer.ts +0 -593
- package/src/utils/react-dom-runtime-interop-plugin.ts +0 -33
- package/src/utils/react-mdx-loader-plugin.ts +0 -63
- package/src/utils/react-runtime-specifier-map.js +0 -37
- package/src/utils/react-runtime-specifier-map.ts +0 -45
- package/src/utils/use-sync-external-store-shim-plugin.d.ts +0 -5
- package/src/utils/use-sync-external-store-shim-plugin.js +0 -41
- 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 {
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
32
|
+
runtimeManifest;
|
|
33
|
+
clientGraphBoundaryCache;
|
|
34
|
+
pagesIndex;
|
|
35
|
+
constructor(options) {
|
|
37
36
|
super();
|
|
38
|
-
this.context = context;
|
|
39
|
-
this.pageMetadataCache = pageMetadataCache;
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
42
|
-
this.
|
|
43
|
-
this.
|
|
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(
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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 ${
|
|
158
|
-
const success = await this.bundleReactEntrypoint(
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
});
|