@ecopages/react 0.2.0-alpha.52 → 0.2.0-alpha.54
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/package.json +3 -4
- package/src/react-hmr-strategy.d.ts +29 -1
- package/src/react-hmr-strategy.js +140 -35
- package/src/react.plugin.d.ts +1 -0
- package/src/react.plugin.js +5 -1
- 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 +3 -3
- package/src/services/react-bundle.service.js +3 -3
- package/src/services/react-hmr-page-metadata-cache.d.ts +1 -1
- package/src/services/react-page-module.service.d.ts +1 -1
- package/src/services/react-page-module.service.js +4 -1
- package/src/services/react-runtime-bundle.service.js +29 -17
- 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 +12 -4
- package/src/utils/client-graph-boundary-plugin.js +61 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/react",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.54",
|
|
4
4
|
"description": "React integration for Ecopages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -69,16 +69,15 @@
|
|
|
69
69
|
"directory": "packages/integrations/react"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@ecopages/core": "0.2.0-alpha.
|
|
72
|
+
"@ecopages/core": "0.2.0-alpha.54",
|
|
73
73
|
"@types/react": "^19",
|
|
74
74
|
"@types/react-dom": "^19",
|
|
75
75
|
"react": "^19",
|
|
76
76
|
"react-dom": "^19"
|
|
77
77
|
},
|
|
78
78
|
"dependencies": {
|
|
79
|
-
"@ecopages/file-system": "0.2.0-alpha.
|
|
79
|
+
"@ecopages/file-system": "0.2.0-alpha.54",
|
|
80
80
|
"@ecopages/logger": "^0.2.3",
|
|
81
|
-
"@mdx-js/esbuild": "^3.1.1",
|
|
82
81
|
"@mdx-js/mdx": "^3.1.1",
|
|
83
82
|
"oxc-parser": "^0.124.0",
|
|
84
83
|
"oxc-transform": "^0.124.0",
|
|
@@ -10,6 +10,7 @@ import { HmrStrategy, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
|
|
|
10
10
|
import type { BrowserRuntimeManifest } from '@ecopages/core/build/browser-runtime-manifest';
|
|
11
11
|
import type { DefaultHmrContext } from '@ecopages/core';
|
|
12
12
|
import type { CompileOptions } from '@mdx-js/mdx';
|
|
13
|
+
import { ClientGraphBoundaryCache } from './utils/client-graph-boundary-cache.js';
|
|
13
14
|
import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.js';
|
|
14
15
|
export interface ReactHmrStrategyOptions {
|
|
15
16
|
context: DefaultHmrContext;
|
|
@@ -19,6 +20,13 @@ export interface ReactHmrStrategyOptions {
|
|
|
19
20
|
ownedTemplateExtensions?: string[];
|
|
20
21
|
allTemplateExtensions?: string[];
|
|
21
22
|
explicitGraphEnabled?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Per-app cache for client-graph-boundary transform results. Owned by
|
|
25
|
+
* the React plugin for the app's lifetime. When omitted, the strategy
|
|
26
|
+
* uses a fresh in-memory cache that does not persist across HMR
|
|
27
|
+
* rebuilds.
|
|
28
|
+
*/
|
|
29
|
+
clientGraphBoundaryCache?: ClientGraphBoundaryCache;
|
|
22
30
|
}
|
|
23
31
|
/**
|
|
24
32
|
* Strategy for handling React component HMR updates.
|
|
@@ -76,6 +84,8 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
76
84
|
private pageMetadataCache;
|
|
77
85
|
private explicitGraphEnabled;
|
|
78
86
|
private readonly runtimeManifest;
|
|
87
|
+
private readonly clientGraphBoundaryCache;
|
|
88
|
+
private readonly pagesIndex;
|
|
79
89
|
constructor(options: ReactHmrStrategyOptions);
|
|
80
90
|
/**
|
|
81
91
|
* Returns build plugins for React HMR bundling.
|
|
@@ -100,6 +110,9 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
100
110
|
private isRouteTemplate;
|
|
101
111
|
private resolveTemplateExtension;
|
|
102
112
|
private ownsWatchedEntrypoint;
|
|
113
|
+
private configContainsFile;
|
|
114
|
+
private pageModuleRequiresLayoutRefresh;
|
|
115
|
+
private hasLayoutOwnedDependencyTarget;
|
|
103
116
|
/**
|
|
104
117
|
* Determines if the file is a React/MDX entrypoint that's registered for HMR.
|
|
105
118
|
*
|
|
@@ -128,7 +141,8 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
128
141
|
private isLayoutFile;
|
|
129
142
|
private isPageEntrypoint;
|
|
130
143
|
private getEntrypointOutput;
|
|
131
|
-
private
|
|
144
|
+
private getRolldownEntryKey;
|
|
145
|
+
private getTempFileBasename;
|
|
132
146
|
private collectReactPageBuildTargets;
|
|
133
147
|
/**
|
|
134
148
|
* Expands one HMR request into the full React page build cohort when needed.
|
|
@@ -182,6 +196,20 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
182
196
|
*/
|
|
183
197
|
private bundleReactEntrypoints;
|
|
184
198
|
private resolveTempOutputPath;
|
|
199
|
+
/**
|
|
200
|
+
* Clears stale HMR output from a directory before a rebuild.
|
|
201
|
+
*
|
|
202
|
+
* Only removes:
|
|
203
|
+
* - `*.tmp.js` files (the per-build bundler output the strategy owns)
|
|
204
|
+
* - the `chunks/` subdirectory (the bundler's splitting target)
|
|
205
|
+
*
|
|
206
|
+
* The HMR runtime script (`_hmr_runtime.js`) and any user-authored
|
|
207
|
+
* assets in the outdir are preserved. This is the minimal set of
|
|
208
|
+
* files that, if left from a previous build, can cause the bundler
|
|
209
|
+
* to emit `ENOENT` for chunk references that point to entrypoints
|
|
210
|
+
* whose hash has since changed.
|
|
211
|
+
*/
|
|
212
|
+
private clearHmrOutdir;
|
|
185
213
|
/**
|
|
186
214
|
* Encodes dynamic route segments (brackets) in file paths.
|
|
187
215
|
* Converts `[slug]` to `_slug_` to avoid filesystem issues.
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
|
|
3
3
|
import { RESOLVED_ASSETS_DIR } from "@ecopages/core/constants";
|
|
4
|
-
import {
|
|
4
|
+
import { createBrowserRuntimePlugin } from "@ecopages/core/build/browser-runtime-plugin";
|
|
5
5
|
import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
|
|
6
6
|
import { Logger } from "@ecopages/logger";
|
|
7
7
|
import { injectHmrHandler } from "./utils/hmr-scripts.js";
|
|
8
8
|
import { createClientGraphBoundaryPlugin } from "./utils/client-graph-boundary-plugin.js";
|
|
9
|
+
import { ClientGraphBoundaryCache } from "./utils/client-graph-boundary-cache.js";
|
|
9
10
|
import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from "./utils/declared-modules.js";
|
|
11
|
+
import { someInConfigTree } from "./utils/component-config-traversal.js";
|
|
10
12
|
import { createReactMdxLoaderPlugin } from "./utils/react-mdx-loader-plugin.js";
|
|
11
13
|
import { getReactClientGraphAllowSpecifiers } from "./utils/react-runtime-alias-map.js";
|
|
14
|
+
import { PagesIndex } from "./services/pages-index.js";
|
|
12
15
|
const appLogger = new Logger("[ReactHmrStrategy]");
|
|
13
16
|
class ReactHmrStrategy extends HmrStrategy {
|
|
14
17
|
type = HmrStrategyType.INTEGRATION;
|
|
@@ -27,12 +30,20 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
27
30
|
pageMetadataCache;
|
|
28
31
|
explicitGraphEnabled;
|
|
29
32
|
runtimeManifest;
|
|
33
|
+
clientGraphBoundaryCache;
|
|
34
|
+
pagesIndex;
|
|
30
35
|
constructor(options) {
|
|
31
36
|
super();
|
|
32
37
|
this.context = options.context;
|
|
33
38
|
this.pageMetadataCache = options.pageMetadataCache;
|
|
34
39
|
this.runtimeManifest = options.runtimeManifest;
|
|
35
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
|
+
});
|
|
36
47
|
this.mdxCompilerOptions = options.mdxCompilerOptions;
|
|
37
48
|
this.ownedTemplateExtensions = new Set(options.ownedTemplateExtensions ?? [".tsx"]);
|
|
38
49
|
this.allTemplateExtensions = [...options.allTemplateExtensions ?? [".tsx"]].sort(
|
|
@@ -53,7 +64,7 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
53
64
|
const allowSpecifiers = getReactClientGraphAllowSpecifiers(
|
|
54
65
|
this.runtimeManifest.assets.map((asset) => asset.specifier)
|
|
55
66
|
);
|
|
56
|
-
const runtimeRewritePlugin =
|
|
67
|
+
const runtimeRewritePlugin = createBrowserRuntimePlugin({
|
|
57
68
|
name: "react-hmr-runtime-import-rewrite",
|
|
58
69
|
manifest: this.runtimeManifest
|
|
59
70
|
});
|
|
@@ -61,7 +72,8 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
61
72
|
createClientGraphBoundaryPlugin({
|
|
62
73
|
absWorkingDir: path.dirname(this.context.getSrcDir()),
|
|
63
74
|
alwaysAllowSpecifiers: allowSpecifiers,
|
|
64
|
-
declaredModules
|
|
75
|
+
declaredModules,
|
|
76
|
+
cache: this.clientGraphBoundaryCache
|
|
65
77
|
}),
|
|
66
78
|
...runtimeRewritePlugin ? [runtimeRewritePlugin] : [],
|
|
67
79
|
...this.context.getPlugins()
|
|
@@ -103,6 +115,32 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
103
115
|
ownsWatchedEntrypoint(filePath) {
|
|
104
116
|
return this.pageMetadataCache.ownsEntrypoint(filePath);
|
|
105
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
|
+
}
|
|
106
144
|
/**
|
|
107
145
|
* Determines if the file is a React/MDX entrypoint that's registered for HMR.
|
|
108
146
|
*
|
|
@@ -162,30 +200,24 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
162
200
|
const outputUrl = `/${path.join(RESOLVED_ASSETS_DIR, "_hmr", encodedPathJs).split(path.sep).join("/")}`;
|
|
163
201
|
return { outputPath, outputUrl };
|
|
164
202
|
}
|
|
165
|
-
|
|
203
|
+
getRolldownEntryKey(entrypointPath) {
|
|
166
204
|
const srcDir = this.context.getSrcDir();
|
|
167
205
|
const relativePath = path.relative(srcDir, entrypointPath);
|
|
168
|
-
const
|
|
169
|
-
return
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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);
|
|
173
215
|
}
|
|
174
216
|
async collectReactPageBuildTargets() {
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
this.allTemplateExtensions.map((extension) => `**/*${extension}`),
|
|
178
|
-
{ cwd: pagesDir }
|
|
179
|
-
);
|
|
217
|
+
await this.pagesIndex.refresh();
|
|
218
|
+
const indexed = this.pagesIndex.list();
|
|
180
219
|
const targets = /* @__PURE__ */ new Map();
|
|
181
|
-
for (const
|
|
182
|
-
if (file.includes(".ecopages-node.")) {
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
const entrypointPath = path.join(pagesDir, file);
|
|
186
|
-
if (!this.isPageEntrypoint(entrypointPath)) {
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
220
|
+
for (const entrypointPath of indexed) {
|
|
189
221
|
this.pageMetadataCache.markOwnedEntrypoint(entrypointPath);
|
|
190
222
|
targets.set(entrypointPath, {
|
|
191
223
|
entrypointPath,
|
|
@@ -257,32 +289,55 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
257
289
|
return { type: "none" };
|
|
258
290
|
}
|
|
259
291
|
const isLayout = this.isLayoutFile(_filePath);
|
|
292
|
+
const isChangedPageEntrypoint = this.isPageEntrypoint(_filePath);
|
|
260
293
|
if (isLayout) {
|
|
261
294
|
appLogger.debug(`Detected layout file change: ${_filePath}`);
|
|
262
295
|
}
|
|
263
296
|
const changedEntrypointOutput = watchedFiles.get(_filePath);
|
|
264
297
|
if (changedEntrypointOutput && !this.ownsWatchedEntrypoint(_filePath)) {
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
}
|
|
267
304
|
}
|
|
268
305
|
const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(_filePath);
|
|
269
306
|
const hasDependencyHits = dependencyHits.size > 0;
|
|
270
307
|
const affectedEntrypoints = /* @__PURE__ */ new Map();
|
|
308
|
+
let hasOwnedLayoutDependencyHit = false;
|
|
309
|
+
let layoutOwnedPageTargets = [];
|
|
310
|
+
let hasLayoutOwnedRequestedTarget = false;
|
|
271
311
|
if (hasDependencyHits && !changedEntrypointOutput) {
|
|
272
312
|
for (const entrypoint of dependencyHits) {
|
|
273
313
|
const outputUrl = watchedFiles.get(entrypoint);
|
|
274
314
|
if (outputUrl && this.ownsWatchedEntrypoint(entrypoint)) {
|
|
275
315
|
affectedEntrypoints.set(entrypoint, outputUrl);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (this.isLayoutFile(entrypoint) && this.ownsWatchedEntrypoint(entrypoint)) {
|
|
319
|
+
hasOwnedLayoutDependencyHit = true;
|
|
276
320
|
}
|
|
277
321
|
}
|
|
278
|
-
if (affectedEntrypoints.size === 0) {
|
|
322
|
+
if (affectedEntrypoints.size === 0 && !hasOwnedLayoutDependencyHit) {
|
|
279
323
|
appLogger.debug(`Dependency hits found but none map to React-owned watched entrypoints`);
|
|
280
324
|
return { type: "none" };
|
|
281
325
|
}
|
|
282
326
|
}
|
|
283
|
-
|
|
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 }));
|
|
284
335
|
const groupedPageTargets = await this.resolveBuildTargets(requestedTargets, _filePath);
|
|
285
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;
|
|
286
341
|
const updates = [];
|
|
287
342
|
const requestedOutputUrls = new Set(requestedTargets.map((target) => target.outputUrl));
|
|
288
343
|
if (pageTargets.length > 1) {
|
|
@@ -313,7 +368,7 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
313
368
|
}
|
|
314
369
|
}
|
|
315
370
|
if (updates.length > 0) {
|
|
316
|
-
if (
|
|
371
|
+
if (requiresLayoutRefresh) {
|
|
317
372
|
appLogger.debug(`Layout update detected, sending layout-update event`);
|
|
318
373
|
return {
|
|
319
374
|
type: "broadcast",
|
|
@@ -354,12 +409,23 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
354
409
|
const isMdx = entrypointPath.endsWith(".mdx");
|
|
355
410
|
const { outputPath } = this.getEntrypointOutput(entrypointPath);
|
|
356
411
|
const tempDir = path.dirname(outputPath);
|
|
357
|
-
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
|
+
}
|
|
358
423
|
const plugins = this.getBuildPlugins(declaredModules);
|
|
359
424
|
if (isMdx && this.mdxCompilerOptions) {
|
|
360
425
|
const mdxPlugin = createReactMdxLoaderPlugin(this.mdxCompilerOptions);
|
|
361
426
|
plugins.unshift(mdxPlugin);
|
|
362
427
|
}
|
|
428
|
+
await this.clearHmrOutdir(tempDir);
|
|
363
429
|
const result = await this.context.getBrowserBundleService().bundle({
|
|
364
430
|
profile: "hmr-entrypoint",
|
|
365
431
|
entrypoints: [entrypointPath],
|
|
@@ -423,12 +489,21 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
423
489
|
if (shouldEnableMdx && this.mdxCompilerOptions) {
|
|
424
490
|
plugins.unshift(createReactMdxLoaderPlugin(this.mdxCompilerOptions));
|
|
425
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
|
+
}
|
|
426
500
|
const result = await this.context.getBrowserBundleService().bundle({
|
|
427
501
|
profile: "hmr-entrypoint",
|
|
428
|
-
entrypoints:
|
|
502
|
+
entrypoints: Object.fromEntries(
|
|
503
|
+
entrypoints.map(({ entrypointPath }) => [entryNameByPath.get(entrypointPath).key, entrypointPath])
|
|
504
|
+
),
|
|
429
505
|
outdir: this.context.getDistDir(),
|
|
430
|
-
|
|
431
|
-
naming: "[dir]/[name].[hash].tmp",
|
|
506
|
+
naming: "[name].[hash].tmp",
|
|
432
507
|
splitting: true,
|
|
433
508
|
plugins,
|
|
434
509
|
minify: false
|
|
@@ -446,11 +521,12 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
446
521
|
const updatedOutputs = [];
|
|
447
522
|
for (const { entrypointPath, outputUrl } of entrypoints) {
|
|
448
523
|
const { outputPath } = this.getEntrypointOutput(entrypointPath);
|
|
449
|
-
const {
|
|
524
|
+
const { basename: tempBasename, key: entryKey } = entryNameByPath.get(entrypointPath);
|
|
525
|
+
const expectedSubdir = path.join(this.context.getDistDir(), path.dirname(entryKey));
|
|
450
526
|
const tempOutput = result.outputs.find((output) => {
|
|
451
|
-
return path.dirname(output.path) ===
|
|
527
|
+
return path.dirname(output.path) === expectedSubdir && path.basename(output.path).startsWith(`${tempBasename}.`) && path.basename(output.path).includes(".tmp");
|
|
452
528
|
})?.path;
|
|
453
|
-
const resolvedTempOutput = tempOutput ? await this.resolveTempOutputPath(tempOutput) : await this.resolveTempOutputPath(path.join(
|
|
529
|
+
const resolvedTempOutput = tempOutput ? await this.resolveTempOutputPath(tempOutput) : await this.resolveTempOutputPath(path.join(expectedSubdir, `${tempBasename}.[hash].tmp.js`));
|
|
454
530
|
if (!resolvedTempOutput) {
|
|
455
531
|
appLogger.debug(`Missing grouped temp output for ${outputUrl}`);
|
|
456
532
|
continue;
|
|
@@ -471,7 +547,7 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
471
547
|
return tempPath;
|
|
472
548
|
}
|
|
473
549
|
if (!tempPath.includes("[hash]")) {
|
|
474
|
-
return
|
|
550
|
+
return null;
|
|
475
551
|
}
|
|
476
552
|
const directory = path.dirname(tempPath);
|
|
477
553
|
const pattern = path.basename(tempPath).replaceAll("[hash]", "*");
|
|
@@ -481,6 +557,35 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
481
557
|
}
|
|
482
558
|
return path.isAbsolute(matches[0]) ? matches[0] : path.join(directory, matches[0]);
|
|
483
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
|
+
}
|
|
484
589
|
/**
|
|
485
590
|
* Encodes dynamic route segments (brackets) in file paths.
|
|
486
591
|
* Converts `[slug]` to `_slug_` to avoid filesystem issues.
|
package/src/react.plugin.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare class ReactPlugin extends IntegrationPlugin<React.ReactNode> {
|
|
|
22
22
|
private mdxLoaderPlugin;
|
|
23
23
|
private readonly runtimeBundleService;
|
|
24
24
|
private readonly hmrPageMetadataCache;
|
|
25
|
+
private readonly clientGraphBoundaryCache;
|
|
25
26
|
private runtimeDependenciesInitialized;
|
|
26
27
|
/**
|
|
27
28
|
* Indicates whether React explicit graph mode is enabled for renderer/HMR behavior.
|
package/src/react.plugin.js
CHANGED
|
@@ -8,6 +8,7 @@ import { ReactHmrStrategy } from "./react-hmr-strategy.js";
|
|
|
8
8
|
import { ReactRuntimeBundleService } from "./services/react-runtime-bundle.service.js";
|
|
9
9
|
import { ReactHmrPageMetadataCache } from "./services/react-hmr-page-metadata-cache.js";
|
|
10
10
|
import { createReactMdxLoaderPlugin } from "./utils/react-mdx-loader-plugin.js";
|
|
11
|
+
import { ClientGraphBoundaryCache } from "./utils/client-graph-boundary-cache.js";
|
|
11
12
|
const appLogger = new Logger("[ReactPlugin]");
|
|
12
13
|
const PLUGIN_NAME = REACT_PLUGIN_NAME;
|
|
13
14
|
const mergePluginLists = (...lists) => {
|
|
@@ -72,6 +73,7 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
72
73
|
mdxLoaderPlugin;
|
|
73
74
|
runtimeBundleService;
|
|
74
75
|
hmrPageMetadataCache;
|
|
76
|
+
clientGraphBoundaryCache;
|
|
75
77
|
runtimeDependenciesInitialized = false;
|
|
76
78
|
/**
|
|
77
79
|
* Indicates whether React explicit graph mode is enabled for renderer/HMR behavior.
|
|
@@ -93,6 +95,7 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
93
95
|
this.mdxEnabled = Boolean(rendererConfig.mdxCompilerOptions);
|
|
94
96
|
this.mdxExtensions = rendererConfig.mdxExtensions ?? [".mdx"];
|
|
95
97
|
this.hmrPageMetadataCache = rendererConfig.hmrPageMetadataCache ?? new ReactHmrPageMetadataCache();
|
|
98
|
+
this.clientGraphBoundaryCache = new ClientGraphBoundaryCache();
|
|
96
99
|
this.explicitGraphEnabled = rendererConfig.explicitGraphEnabled ?? false;
|
|
97
100
|
this.rendererConfig = {
|
|
98
101
|
...rendererConfig,
|
|
@@ -187,7 +190,8 @@ class ReactPlugin extends IntegrationPlugin {
|
|
|
187
190
|
mdxCompilerOptions: this.mdxCompilerOptions,
|
|
188
191
|
ownedTemplateExtensions: this.extensions,
|
|
189
192
|
allTemplateExtensions: this.appConfig.templatesExt,
|
|
190
|
-
explicitGraphEnabled: this.explicitGraphEnabled
|
|
193
|
+
explicitGraphEnabled: this.explicitGraphEnabled,
|
|
194
|
+
clientGraphBoundaryCache: this.clientGraphBoundaryCache
|
|
191
195
|
});
|
|
192
196
|
}
|
|
193
197
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-app index of React-owned page entrypoints.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* The HMR strategy previously called `fileSystem.glob` on every
|
|
6
|
+
* layout change. For apps with many pages this is wasteful: O(pages)
|
|
7
|
+
* on every HMR event, and the result rarely changes between HMR
|
|
8
|
+
* rebuilds.
|
|
9
|
+
*
|
|
10
|
+
* `PagesIndex` maintains a `Set` of owned page entrypoint paths. The
|
|
11
|
+
* initial population is a single glob; subsequent mutations go through
|
|
12
|
+
* `add` / `remove` (called by the HMR strategy when it observes a
|
|
13
|
+
* file create/delete under `pagesDir`). The strategy reads via
|
|
14
|
+
* `list()` for an O(1) snapshot of the current set.
|
|
15
|
+
*
|
|
16
|
+
* Phase 1 ships the class and the `list()` consumer. Watcher-driven
|
|
17
|
+
* `add` / `remove` integration lands in a follow-up — for now the
|
|
18
|
+
* strategy calls `refresh()` lazily, which is equivalent to the old
|
|
19
|
+
* glob behavior but with a stable API for the future incremental
|
|
20
|
+
* path.
|
|
21
|
+
*/
|
|
22
|
+
export type PagesIndexOptions = {
|
|
23
|
+
pagesDir: string;
|
|
24
|
+
extensions?: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Optional predicate to filter out non-page entrypoints (e.g.
|
|
27
|
+
* test fixtures, generated files). Defaults to including every
|
|
28
|
+
* file matching an extension.
|
|
29
|
+
*/
|
|
30
|
+
isPageEntrypoint?: (absolutePath: string) => boolean;
|
|
31
|
+
};
|
|
32
|
+
export declare class PagesIndex {
|
|
33
|
+
private readonly pagesDir;
|
|
34
|
+
private readonly extensions;
|
|
35
|
+
private readonly isPageEntrypoint;
|
|
36
|
+
private readonly pages;
|
|
37
|
+
private lastRefreshAt;
|
|
38
|
+
constructor(options: PagesIndexOptions);
|
|
39
|
+
/**
|
|
40
|
+
* Rescan the pages directory and rebuild the index.
|
|
41
|
+
*
|
|
42
|
+
* Cheap to call multiple times in sequence (just a glob). The
|
|
43
|
+
* real value of `PagesIndex` is the `add` / `remove` path that
|
|
44
|
+
* callers can wire to the file watcher; for now this is the
|
|
45
|
+
* fallback used on layout changes.
|
|
46
|
+
*/
|
|
47
|
+
refresh(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Add a single entrypoint. Idempotent.
|
|
50
|
+
*/
|
|
51
|
+
add(absolutePath: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Remove a single entrypoint. Idempotent.
|
|
54
|
+
*/
|
|
55
|
+
remove(absolutePath: string): void;
|
|
56
|
+
/** True if the index contains `absolutePath`. */
|
|
57
|
+
has(absolutePath: string): boolean;
|
|
58
|
+
/** Snapshot of the current set, sorted by absolute path. */
|
|
59
|
+
list(): string[];
|
|
60
|
+
/** Number of indexed entrypoints. */
|
|
61
|
+
get size(): number;
|
|
62
|
+
/** Last refresh timestamp (ms since epoch). */
|
|
63
|
+
get refreshedAt(): number;
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileSystem } from "@ecopages/file-system";
|
|
3
|
+
const DEFAULT_EXTENSIONS = [".tsx", ".kita.tsx", ".lit.tsx", ".eco.tsx", ".mdx", ".react.tsx"];
|
|
4
|
+
class PagesIndex {
|
|
5
|
+
pagesDir;
|
|
6
|
+
extensions;
|
|
7
|
+
isPageEntrypoint;
|
|
8
|
+
pages = /* @__PURE__ */ new Set();
|
|
9
|
+
lastRefreshAt = 0;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.pagesDir = options.pagesDir;
|
|
12
|
+
this.extensions = options.extensions ?? DEFAULT_EXTENSIONS;
|
|
13
|
+
this.isPageEntrypoint = options.isPageEntrypoint ?? (() => true);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Rescan the pages directory and rebuild the index.
|
|
17
|
+
*
|
|
18
|
+
* Cheap to call multiple times in sequence (just a glob). The
|
|
19
|
+
* real value of `PagesIndex` is the `add` / `remove` path that
|
|
20
|
+
* callers can wire to the file watcher; for now this is the
|
|
21
|
+
* fallback used on layout changes.
|
|
22
|
+
*/
|
|
23
|
+
async refresh() {
|
|
24
|
+
const files = await fileSystem.glob(
|
|
25
|
+
this.extensions.map((ext) => `**/*${ext}`),
|
|
26
|
+
{
|
|
27
|
+
cwd: this.pagesDir
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
const next = /* @__PURE__ */ new Set();
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const absolutePath = path.join(this.pagesDir, file);
|
|
33
|
+
if (!this.isPageEntrypoint(absolutePath)) continue;
|
|
34
|
+
if (file.includes(".ecopages-node.")) continue;
|
|
35
|
+
next.add(absolutePath);
|
|
36
|
+
}
|
|
37
|
+
this.pages.clear();
|
|
38
|
+
for (const p of next) this.pages.add(p);
|
|
39
|
+
this.lastRefreshAt = Date.now();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Add a single entrypoint. Idempotent.
|
|
43
|
+
*/
|
|
44
|
+
add(absolutePath) {
|
|
45
|
+
if (!this.isPageEntrypoint(absolutePath)) return;
|
|
46
|
+
this.pages.add(absolutePath);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove a single entrypoint. Idempotent.
|
|
50
|
+
*/
|
|
51
|
+
remove(absolutePath) {
|
|
52
|
+
this.pages.delete(absolutePath);
|
|
53
|
+
}
|
|
54
|
+
/** True if the index contains `absolutePath`. */
|
|
55
|
+
has(absolutePath) {
|
|
56
|
+
return this.pages.has(absolutePath);
|
|
57
|
+
}
|
|
58
|
+
/** Snapshot of the current set, sorted by absolute path. */
|
|
59
|
+
list() {
|
|
60
|
+
return Array.from(this.pages).sort();
|
|
61
|
+
}
|
|
62
|
+
/** Number of indexed entrypoints. */
|
|
63
|
+
get size() {
|
|
64
|
+
return this.pages.size;
|
|
65
|
+
}
|
|
66
|
+
/** Last refresh timestamp (ms since epoch). */
|
|
67
|
+
get refreshedAt() {
|
|
68
|
+
return this.lastRefreshAt;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
PagesIndex
|
|
73
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bundle configuration service for React integration.
|
|
3
3
|
*
|
|
4
|
-
* Encapsulates all
|
|
4
|
+
* Encapsulates all build plugin creation and bundle options
|
|
5
5
|
* for client-side React component builds.
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
@@ -34,7 +34,7 @@ export interface ReactClientBundleOptions {
|
|
|
34
34
|
splitting?: boolean;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Manages
|
|
37
|
+
* Manages bundle configuration and plugin creation for React page/component builds.
|
|
38
38
|
*/
|
|
39
39
|
export declare class ReactBundleService {
|
|
40
40
|
private readonly runtimeBundleService;
|
|
@@ -45,7 +45,7 @@ export declare class ReactBundleService {
|
|
|
45
45
|
*/
|
|
46
46
|
getRuntimeImports(): ReactRuntimeImports;
|
|
47
47
|
/**
|
|
48
|
-
* Creates
|
|
48
|
+
* Creates bundle options for a page or component entry.
|
|
49
49
|
*
|
|
50
50
|
* @remarks
|
|
51
51
|
* React derives runtime specifier mappings from the core browser runtime manifest
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
getReactClientGraphAllowSpecifiers,
|
|
4
4
|
getReactRuntimeExternalSpecifiers
|
|
5
5
|
} from "../utils/react-runtime-alias-map.js";
|
|
6
|
-
import {
|
|
6
|
+
import { createBrowserRuntimePlugin } from "@ecopages/core/build/browser-runtime-plugin";
|
|
7
7
|
import { createForeignJsxOverridePlugin } from "@ecopages/core/plugins/foreign-jsx-override-plugin";
|
|
8
8
|
import { ReactRuntimeBundleService } from "./react-runtime-bundle.service.js";
|
|
9
9
|
import { createReactMdxLoaderPlugin } from "../utils/react-mdx-loader-plugin.js";
|
|
@@ -24,7 +24,7 @@ class ReactBundleService {
|
|
|
24
24
|
return this.runtimeBundleService.getRuntimeImports();
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
* Creates
|
|
27
|
+
* Creates bundle options for a page or component entry.
|
|
28
28
|
*
|
|
29
29
|
* @remarks
|
|
30
30
|
* React derives runtime specifier mappings from the core browser runtime manifest
|
|
@@ -67,7 +67,7 @@ class ReactBundleService {
|
|
|
67
67
|
foreignExtensions: this.config.nonReactExtensions ?? []
|
|
68
68
|
});
|
|
69
69
|
const runtimeManifest = this.runtimeBundleService.getRuntimeManifest();
|
|
70
|
-
const runtimeRewritePlugin =
|
|
70
|
+
const runtimeRewritePlugin = createBrowserRuntimePlugin({
|
|
71
71
|
name: "react-renderer-runtime-import-rewrite",
|
|
72
72
|
manifest: runtimeManifest
|
|
73
73
|
});
|
|
@@ -14,7 +14,7 @@ export declare class ReactHmrPageMetadataCache {
|
|
|
14
14
|
/**
|
|
15
15
|
* Stores the declared browser modules for a page entrypoint.
|
|
16
16
|
*/
|
|
17
|
-
setDeclaredModules(entrypointPath: string, declaredModules: string[]): void;
|
|
17
|
+
setDeclaredModules(entrypointPath: string, declaredModules: readonly string[]): void;
|
|
18
18
|
/**
|
|
19
19
|
* Returns the last known declared browser modules for a page entrypoint.
|
|
20
20
|
*/
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
import type { EcoComponentConfig, EcoPageFile } from '@ecopages/core';
|
|
10
|
-
import type
|
|
10
|
+
import { type BuildExecutor } from '@ecopages/core/build/build-adapter';
|
|
11
11
|
import type { CompileOptions } from '@mdx-js/mdx';
|
|
12
12
|
/**
|
|
13
13
|
* Configuration for the ReactPageModuleService.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { rapidhash } from "@ecopages/core/hash";
|
|
4
3
|
import { build } from "@ecopages/core/build/build-adapter";
|
|
4
|
+
import { normalizeNodeRuntimeBuildOutputFile } from "@ecopages/core/build/runtime-build-output-normalizer";
|
|
5
|
+
import { rapidhash } from "@ecopages/core/hash";
|
|
5
6
|
import { fileSystem } from "@ecopages/file-system";
|
|
6
7
|
import { someInConfigTree } from "../utils/component-config-traversal.js";
|
|
7
8
|
import { collectDeclaredModulesInConfig } from "../utils/declared-modules.js";
|
|
@@ -51,6 +52,7 @@ class ReactPageModuleService {
|
|
|
51
52
|
splitting: false,
|
|
52
53
|
minify: false,
|
|
53
54
|
treeshaking: false,
|
|
55
|
+
externalPackages: true,
|
|
54
56
|
naming: outputNamingTemplate,
|
|
55
57
|
plugins: [mdxPlugin]
|
|
56
58
|
},
|
|
@@ -65,6 +67,7 @@ class ReactPageModuleService {
|
|
|
65
67
|
if (!compiledOutput) {
|
|
66
68
|
throw new Error(`No compiled MDX output generated for page: ${filePath}`);
|
|
67
69
|
}
|
|
70
|
+
normalizeNodeRuntimeBuildOutputFile(compiledOutput, this.config.rootDir);
|
|
68
71
|
const compiledOutputUrl = pathToFileURL(compiledOutput);
|
|
69
72
|
if (process?.env?.NODE_ENV === "development" || options?.cacheScope) {
|
|
70
73
|
compiledOutputUrl.searchParams.set(
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME
|
|
4
|
-
} from "@ecopages/core/build/browser-runtime-import-rewrite-plugin";
|
|
5
|
-
import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
|
|
1
|
+
import { createBrowserRuntimePlugin } from "@ecopages/core/build/browser-runtime-plugin";
|
|
2
|
+
import { DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME } from "@ecopages/core/build/browser-runtime-plugin";
|
|
6
3
|
import {
|
|
7
4
|
buildBrowserRuntimeAssetUrl,
|
|
8
5
|
createBrowserRuntimeModuleAsset,
|
|
@@ -50,7 +47,7 @@ class ReactRuntimeBundleService {
|
|
|
50
47
|
return mode === "development" ? "use-sync-external-store-with-selector.development.js" : "use-sync-external-store-with-selector.js";
|
|
51
48
|
}
|
|
52
49
|
createReactVendorImportRewritePlugin(mode) {
|
|
53
|
-
return
|
|
50
|
+
return createBrowserRuntimePlugin({
|
|
54
51
|
name: `react-plugin-vendor-runtime-import-rewrite-${mode}`,
|
|
55
52
|
manifest: createBrowserRuntimeManifest([
|
|
56
53
|
{
|
|
@@ -93,12 +90,17 @@ class ReactRuntimeBundleService {
|
|
|
93
90
|
const reactDomRuntimeInteropPlugin = createReactDomRuntimeInteropPlugin({
|
|
94
91
|
reactSpecifier: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode))
|
|
95
92
|
});
|
|
96
|
-
const reactRuntimeAliasPlugin =
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
const reactRuntimeAliasPlugin = createBrowserRuntimePlugin({
|
|
94
|
+
name: `react-plugin-runtime-specifier-alias-${mode}`,
|
|
95
|
+
manifest: createBrowserRuntimeManifest([
|
|
96
|
+
{
|
|
97
|
+
specifier: "react",
|
|
98
|
+
owner: "",
|
|
99
|
+
importPath: "react",
|
|
100
|
+
publicPath: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode))
|
|
101
|
+
}
|
|
102
|
+
])
|
|
103
|
+
});
|
|
102
104
|
const reactDomBundlePlugins = [reactRuntimeAliasPlugin, reactDomRuntimeInteropPlugin].filter(
|
|
103
105
|
(plugin) => plugin !== null
|
|
104
106
|
);
|
|
@@ -117,7 +119,7 @@ class ReactRuntimeBundleService {
|
|
|
117
119
|
rootDir: this.config.rootDir,
|
|
118
120
|
bundleOptions: {
|
|
119
121
|
define: this.createRuntimeDefines(mode),
|
|
120
|
-
excludeAppBuildPlugins: [
|
|
122
|
+
excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME]
|
|
121
123
|
}
|
|
122
124
|
}),
|
|
123
125
|
createBrowserRuntimeModuleAsset({
|
|
@@ -128,7 +130,7 @@ class ReactRuntimeBundleService {
|
|
|
128
130
|
rootDir: this.config.rootDir,
|
|
129
131
|
bundleOptions: {
|
|
130
132
|
define: this.createRuntimeDefines(mode),
|
|
131
|
-
excludeAppBuildPlugins: [
|
|
133
|
+
excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME],
|
|
132
134
|
plugins: reactDomBundlePlugins
|
|
133
135
|
}
|
|
134
136
|
}),
|
|
@@ -138,7 +140,7 @@ class ReactRuntimeBundleService {
|
|
|
138
140
|
fileName: this.getUseSyncExternalStoreWithSelectorVendorFileName(mode),
|
|
139
141
|
bundleOptions: {
|
|
140
142
|
define: this.createRuntimeDefines(mode),
|
|
141
|
-
excludeAppBuildPlugins: [
|
|
143
|
+
excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_PLUGIN_NAME],
|
|
142
144
|
plugins: [reactVendorImportRewritePlugin]
|
|
143
145
|
}
|
|
144
146
|
})
|
|
@@ -164,8 +166,18 @@ class ReactRuntimeBundleService {
|
|
|
164
166
|
return dependencies;
|
|
165
167
|
}
|
|
166
168
|
createRuntimeAliasPlugin(mode = this.getCurrentRuntimeMode()) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
const aliasMap = this.getRuntimeAliasMap(mode);
|
|
170
|
+
const manifest = createBrowserRuntimeManifest(
|
|
171
|
+
Object.entries(aliasMap).map(([specifier, publicPath]) => ({
|
|
172
|
+
specifier,
|
|
173
|
+
owner: "",
|
|
174
|
+
importPath: specifier,
|
|
175
|
+
publicPath
|
|
176
|
+
}))
|
|
177
|
+
);
|
|
178
|
+
return createBrowserRuntimePlugin({
|
|
179
|
+
name: `react-plugin-runtime-alias-${mode}`,
|
|
180
|
+
manifest
|
|
169
181
|
});
|
|
170
182
|
}
|
|
171
183
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-app persistent cache for `client-graph-boundary` plugin transforms.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* The plugin's `transformModuleImports` function is the second most
|
|
6
|
+
* expensive operation in the React build path (after `parseSync`).
|
|
7
|
+
* It walks the AST to find reachable exports and prunes forbidden
|
|
8
|
+
* imports. On a no-op rebuild (file unchanged, no plugin signature
|
|
9
|
+
* change) the entire walk can be skipped if the inputs are identical.
|
|
10
|
+
*
|
|
11
|
+
* This cache is owned by the {@link ReactPlugin} for the app's lifetime.
|
|
12
|
+
* It persists across HMR rebuilds but is invalidated per-file when the
|
|
13
|
+
* watcher detects a source change. The `requestedExports` registry
|
|
14
|
+
* remains per-build (it's mutated during cross-file propagation and
|
|
15
|
+
* must not accumulate stale state across builds).
|
|
16
|
+
*
|
|
17
|
+
* The cache is content-hashed (via `rapidhash`) so a `touch`/`utimes`
|
|
18
|
+
* does not invalidate a still-valid entry.
|
|
19
|
+
*
|
|
20
|
+
* **`rulesAdded` semantics:** the map holds the **after-state** of
|
|
21
|
+
* every registry key this transform touched — both newly added keys
|
|
22
|
+
* and keys that were grown (Set union) or promoted to `'*'`. On
|
|
23
|
+
* replay the cache applies each entry via the same merge rules the
|
|
24
|
+
* live transform uses (`mergeRequestedExportRules`), so a key that
|
|
25
|
+
* was already in the live registry when the transform ran and grew
|
|
26
|
+
* during the transform is correctly grown on replay against a fresh
|
|
27
|
+
* registry as well.
|
|
28
|
+
*/
|
|
29
|
+
export type RequestedExportRules = Set<string> | '*';
|
|
30
|
+
export type CachedTransform = {
|
|
31
|
+
/** Hash of the source string at the time the transform was cached. */
|
|
32
|
+
sourceHash: number | bigint;
|
|
33
|
+
/** Hash of the globally-allowed modules map at the time the transform was cached. */
|
|
34
|
+
allowListHash: number | bigint;
|
|
35
|
+
/** The transformed source (or original if `modified` is false). */
|
|
36
|
+
transformed: string;
|
|
37
|
+
/** Whether the transform changed the source. */
|
|
38
|
+
modified: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Map of `requestedExports` keys this transform touched, mapped to
|
|
41
|
+
* their **after-state**. Includes:
|
|
42
|
+
* - newly-added keys
|
|
43
|
+
* - keys whose Set grew (union of pre-existing and newly reachable exports)
|
|
44
|
+
* - keys promoted to `'*'`
|
|
45
|
+
*
|
|
46
|
+
* On cache hit, each entry is replayed via `mergeRequestedExportRules`,
|
|
47
|
+
* which handles all three cases correctly. Defensively copied at
|
|
48
|
+
* `set()` time to prevent later mutation from corrupting the cache.
|
|
49
|
+
*/
|
|
50
|
+
rulesAdded: Map<string, RequestedExportRules>;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* LRU-bounded cache for client-graph-boundary transform results.
|
|
54
|
+
*
|
|
55
|
+
* One instance is owned by the React plugin for the app's lifetime and
|
|
56
|
+
* shared across all builds. The cache key is `(filePath, source, allowList)`
|
|
57
|
+
* so a file whose source or whose global allow list changes gets a fresh
|
|
58
|
+
* entry, while everything else hits.
|
|
59
|
+
*/
|
|
60
|
+
export declare class ClientGraphBoundaryCache {
|
|
61
|
+
private readonly entries;
|
|
62
|
+
private readonly maxEntries;
|
|
63
|
+
private hits;
|
|
64
|
+
private misses;
|
|
65
|
+
constructor(maxEntries?: number);
|
|
66
|
+
/**
|
|
67
|
+
* Look up a cached transform for `filePath`.
|
|
68
|
+
*
|
|
69
|
+
* Returns `undefined` if the source or allow list has changed since
|
|
70
|
+
* the entry was stored. The caller is responsible for re-running the
|
|
71
|
+
* transform in that case and calling `set` to update the entry.
|
|
72
|
+
*/
|
|
73
|
+
get(filePath: string, source: string, globallyAllowedSpecifiers: Iterable<string>): CachedTransform | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Store a transform result.
|
|
76
|
+
*
|
|
77
|
+
* Evicts the least recently used entry if the cache exceeds its
|
|
78
|
+
* capacity. Defensively copies any `Set`-typed rules in `rulesAdded`
|
|
79
|
+
* so later mutations to the caller's sets cannot corrupt the cache.
|
|
80
|
+
*/
|
|
81
|
+
set(filePath: string, source: string, globallyAllowedSpecifiers: Iterable<string>, entry: Omit<CachedTransform, 'sourceHash' | 'allowListHash'>): void;
|
|
82
|
+
/**
|
|
83
|
+
* Invalidate a single file's entry. Call this from the file watcher
|
|
84
|
+
* when `filePath`'s content has changed.
|
|
85
|
+
*/
|
|
86
|
+
invalidate(filePath: string): void;
|
|
87
|
+
/**
|
|
88
|
+
* Invalidate every file whose key matches a prefix. Useful for
|
|
89
|
+
* "anything under `src/pages/` changed" signals.
|
|
90
|
+
*/
|
|
91
|
+
invalidateMatching(predicate: (filePath: string) => boolean): number;
|
|
92
|
+
/** Clear all entries. */
|
|
93
|
+
clear(): void;
|
|
94
|
+
/** Current cache size. */
|
|
95
|
+
get size(): number;
|
|
96
|
+
/** Hit/miss counters for observability. */
|
|
97
|
+
stats(): {
|
|
98
|
+
hits: number;
|
|
99
|
+
misses: number;
|
|
100
|
+
size: number;
|
|
101
|
+
hitRate: number;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Default shared cache. The React plugin owns one of these; tests can
|
|
106
|
+
* create their own.
|
|
107
|
+
*/
|
|
108
|
+
export declare const clientGraphBoundaryCache: ClientGraphBoundaryCache;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { rapidhash } from "@ecopages/core/utils/hash";
|
|
2
|
+
const DEFAULT_MAX_ENTRIES = 5e3;
|
|
3
|
+
function cloneRules(rules) {
|
|
4
|
+
return rules instanceof Set ? new Set(rules) : rules;
|
|
5
|
+
}
|
|
6
|
+
class ClientGraphBoundaryCache {
|
|
7
|
+
entries = /* @__PURE__ */ new Map();
|
|
8
|
+
maxEntries;
|
|
9
|
+
hits = 0;
|
|
10
|
+
misses = 0;
|
|
11
|
+
constructor(maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
12
|
+
if (maxEntries <= 0) {
|
|
13
|
+
throw new Error(`ClientGraphBoundaryCache: maxEntries must be > 0, got ${maxEntries}`);
|
|
14
|
+
}
|
|
15
|
+
this.maxEntries = maxEntries;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Look up a cached transform for `filePath`.
|
|
19
|
+
*
|
|
20
|
+
* Returns `undefined` if the source or allow list has changed since
|
|
21
|
+
* the entry was stored. The caller is responsible for re-running the
|
|
22
|
+
* transform in that case and calling `set` to update the entry.
|
|
23
|
+
*/
|
|
24
|
+
get(filePath, source, globallyAllowedSpecifiers) {
|
|
25
|
+
const sourceHash = rapidhash(source);
|
|
26
|
+
const allowListHash = hashAllowList(globallyAllowedSpecifiers);
|
|
27
|
+
const existing = this.entries.get(filePath);
|
|
28
|
+
if (existing && existing.sourceHash === sourceHash && existing.allowListHash === allowListHash) {
|
|
29
|
+
this.hits += 1;
|
|
30
|
+
this.entries.delete(filePath);
|
|
31
|
+
this.entries.set(filePath, existing);
|
|
32
|
+
return existing;
|
|
33
|
+
}
|
|
34
|
+
this.misses += 1;
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Store a transform result.
|
|
39
|
+
*
|
|
40
|
+
* Evicts the least recently used entry if the cache exceeds its
|
|
41
|
+
* capacity. Defensively copies any `Set`-typed rules in `rulesAdded`
|
|
42
|
+
* so later mutations to the caller's sets cannot corrupt the cache.
|
|
43
|
+
*/
|
|
44
|
+
set(filePath, source, globallyAllowedSpecifiers, entry) {
|
|
45
|
+
const sourceHash = rapidhash(source);
|
|
46
|
+
const allowListHash = hashAllowList(globallyAllowedSpecifiers);
|
|
47
|
+
const rulesAddedCopy = /* @__PURE__ */ new Map();
|
|
48
|
+
for (const [key, rules] of entry.rulesAdded) {
|
|
49
|
+
rulesAddedCopy.set(key, cloneRules(rules));
|
|
50
|
+
}
|
|
51
|
+
const full = {
|
|
52
|
+
sourceHash,
|
|
53
|
+
allowListHash,
|
|
54
|
+
transformed: entry.transformed,
|
|
55
|
+
modified: entry.modified,
|
|
56
|
+
rulesAdded: rulesAddedCopy
|
|
57
|
+
};
|
|
58
|
+
this.entries.set(filePath, full);
|
|
59
|
+
if (this.entries.size > this.maxEntries) {
|
|
60
|
+
const oldestKey = this.entries.keys().next().value;
|
|
61
|
+
if (oldestKey !== void 0) {
|
|
62
|
+
this.entries.delete(oldestKey);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Invalidate a single file's entry. Call this from the file watcher
|
|
68
|
+
* when `filePath`'s content has changed.
|
|
69
|
+
*/
|
|
70
|
+
invalidate(filePath) {
|
|
71
|
+
this.entries.delete(filePath);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Invalidate every file whose key matches a prefix. Useful for
|
|
75
|
+
* "anything under `src/pages/` changed" signals.
|
|
76
|
+
*/
|
|
77
|
+
invalidateMatching(predicate) {
|
|
78
|
+
let removed = 0;
|
|
79
|
+
for (const key of this.entries.keys()) {
|
|
80
|
+
if (predicate(key)) {
|
|
81
|
+
this.entries.delete(key);
|
|
82
|
+
removed += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return removed;
|
|
86
|
+
}
|
|
87
|
+
/** Clear all entries. */
|
|
88
|
+
clear() {
|
|
89
|
+
this.entries.clear();
|
|
90
|
+
this.hits = 0;
|
|
91
|
+
this.misses = 0;
|
|
92
|
+
}
|
|
93
|
+
/** Current cache size. */
|
|
94
|
+
get size() {
|
|
95
|
+
return this.entries.size;
|
|
96
|
+
}
|
|
97
|
+
/** Hit/miss counters for observability. */
|
|
98
|
+
stats() {
|
|
99
|
+
const total = this.hits + this.misses;
|
|
100
|
+
return {
|
|
101
|
+
hits: this.hits,
|
|
102
|
+
misses: this.misses,
|
|
103
|
+
size: this.entries.size,
|
|
104
|
+
hitRate: total === 0 ? 0 : this.hits / total
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const clientGraphBoundaryCache = new ClientGraphBoundaryCache();
|
|
109
|
+
function hashAllowList(specifiers) {
|
|
110
|
+
const sorted = Array.from(specifiers).sort();
|
|
111
|
+
return rapidhash(sorted.join("\n"));
|
|
112
|
+
}
|
|
113
|
+
export {
|
|
114
|
+
ClientGraphBoundaryCache,
|
|
115
|
+
clientGraphBoundaryCache
|
|
116
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module ClientGraphBoundaryPlugin
|
|
3
3
|
*
|
|
4
|
-
* This module defines the primary
|
|
4
|
+
* This module defines the primary build plugin responsible for securing the Ecopages
|
|
5
5
|
* isomorphic compilation pipeline. It ensures that backend-only code, sensitive Node.js APIs,
|
|
6
6
|
* and massive server utilities do not accidentally leak into the browser bundle.
|
|
7
7
|
*
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* inlines `fs.readFileSync(path.resolve(...))` calls to prevent server/client data mismatches.
|
|
16
16
|
*/
|
|
17
17
|
import type { EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
|
|
18
|
+
import { ClientGraphBoundaryCache } from './client-graph-boundary-cache.js';
|
|
18
19
|
/**
|
|
19
|
-
* Configuration options for the Client Graph Boundary
|
|
20
|
+
* Configuration options for the Client Graph Boundary build plugin.
|
|
20
21
|
*
|
|
21
22
|
* This plugin serves as the primary security layer between server-only logic and the client-side JavaScript bundle.
|
|
22
23
|
* It prevents Node.js built-ins (`node:fs`, `node:path`) and backend-exclusive dependencies (e.g. `pg`, `redis`)
|
|
@@ -29,12 +30,19 @@ type ClientGraphBoundaryOptions = {
|
|
|
29
30
|
* Array of module specifiers that are explicitly whitelisted to be bundled in the client code.
|
|
30
31
|
* This is typically populated by parsing `modules: ["..."]` declarations in React/Lit components.
|
|
31
32
|
*/
|
|
32
|
-
declaredModules?: string[];
|
|
33
|
+
declaredModules?: readonly string[];
|
|
33
34
|
/** Array of emergency escape-hatch specifiers that always bypass the boundary checks regardless of component declarations. */
|
|
34
35
|
alwaysAllowSpecifiers?: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Persistent per-app cache for transform results. Owned by the React
|
|
38
|
+
* plugin for the app's lifetime; survives across HMR rebuilds. When
|
|
39
|
+
* omitted, transforms are still memoized inside the plugin for the
|
|
40
|
+
* duration of a single build but not across builds.
|
|
41
|
+
*/
|
|
42
|
+
cache?: ClientGraphBoundaryCache;
|
|
35
43
|
};
|
|
36
44
|
/**
|
|
37
|
-
* Instantiates the client graph boundary
|
|
45
|
+
* Instantiates the client graph boundary build plugin.
|
|
38
46
|
*
|
|
39
47
|
* @param options - Configuration options for the graph boundary.
|
|
40
48
|
* @returns The resulting `EcoBuildPlugin`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, extname, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { cachedParseSync } from "@ecopages/core/cache";
|
|
4
|
+
import { ClientGraphBoundaryCache } from "./client-graph-boundary-cache.js";
|
|
4
5
|
import { analyzeReachability } from "./reachability-analyzer.js";
|
|
5
6
|
const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
|
|
6
7
|
const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -180,10 +181,38 @@ function mergeRequestedExportRules(registry, moduleKey, rules) {
|
|
|
180
181
|
existing.add(rule);
|
|
181
182
|
}
|
|
182
183
|
}
|
|
184
|
+
function cloneRequestedExportRules(rules) {
|
|
185
|
+
return rules === "*" ? rules : new Set(rules);
|
|
186
|
+
}
|
|
187
|
+
function snapshotRegistry(registry) {
|
|
188
|
+
const out = /* @__PURE__ */ new Map();
|
|
189
|
+
for (const [key, rules] of registry) {
|
|
190
|
+
out.set(key, cloneRequestedExportRules(rules));
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
function diffRequestedExportRules(before, after) {
|
|
195
|
+
if (!before) {
|
|
196
|
+
return cloneRequestedExportRules(after);
|
|
197
|
+
}
|
|
198
|
+
if (before === "*") {
|
|
199
|
+
return void 0;
|
|
200
|
+
}
|
|
201
|
+
if (after === "*") {
|
|
202
|
+
return "*";
|
|
203
|
+
}
|
|
204
|
+
const addedRules = /* @__PURE__ */ new Set();
|
|
205
|
+
for (const rule of after) {
|
|
206
|
+
if (!before.has(rule)) {
|
|
207
|
+
addedRules.add(rule);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return addedRules.size > 0 ? addedRules : void 0;
|
|
211
|
+
}
|
|
183
212
|
function transformModuleImports(source, filename, globallyAllowed, requestedExports) {
|
|
184
213
|
let result;
|
|
185
214
|
try {
|
|
186
|
-
result =
|
|
215
|
+
result = cachedParseSync(filename, source, {
|
|
187
216
|
sourceType: "module",
|
|
188
217
|
lang: parserLanguageForFile(filename)
|
|
189
218
|
});
|
|
@@ -414,7 +443,7 @@ function transformModuleImports(source, filename, globallyAllowed, requestedExpo
|
|
|
414
443
|
}
|
|
415
444
|
let reparsedResult;
|
|
416
445
|
try {
|
|
417
|
-
reparsedResult =
|
|
446
|
+
reparsedResult = cachedParseSync(filename, transformed, {
|
|
418
447
|
sourceType: "module",
|
|
419
448
|
lang: parserLanguageForFile(filename)
|
|
420
449
|
});
|
|
@@ -432,11 +461,13 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
432
461
|
name: "ecopages-client-graph-boundary",
|
|
433
462
|
setup(build) {
|
|
434
463
|
const absWorkingDir = options?.absWorkingDir ?? process.cwd();
|
|
464
|
+
const cache = options?.cache;
|
|
435
465
|
const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
|
|
436
466
|
const requestedExports = /* @__PURE__ */ new Map();
|
|
437
467
|
for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
|
|
438
468
|
globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), "*");
|
|
439
469
|
}
|
|
470
|
+
const allowListForCache = Array.from(globallyDeclaredSources.keys()).sort();
|
|
440
471
|
build.onLoad({ filter: SOURCE_FILE_FILTER }, (args) => {
|
|
441
472
|
let source;
|
|
442
473
|
try {
|
|
@@ -444,6 +475,17 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
444
475
|
} catch {
|
|
445
476
|
return void 0;
|
|
446
477
|
}
|
|
478
|
+
if (cache) {
|
|
479
|
+
const cached = cache.get(args.path, source, allowListForCache);
|
|
480
|
+
if (cached) {
|
|
481
|
+
for (const [moduleKey, rules] of cached.rulesAdded) {
|
|
482
|
+
mergeRequestedExportRules(requestedExports, moduleKey, rules);
|
|
483
|
+
}
|
|
484
|
+
if (!cached.modified) return void 0;
|
|
485
|
+
const ext2 = extname(args.path).slice(1);
|
|
486
|
+
return { contents: cached.transformed, loader: ext2, resolveDir: dirname(args.path) };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
447
489
|
let transformed = source;
|
|
448
490
|
let modified = false;
|
|
449
491
|
if (source.includes("readFileSync")) {
|
|
@@ -472,6 +514,7 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
472
514
|
);
|
|
473
515
|
transformed = readFileTransformed;
|
|
474
516
|
}
|
|
517
|
+
const registryBefore = snapshotRegistry(requestedExports);
|
|
475
518
|
const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
|
|
476
519
|
transformed,
|
|
477
520
|
args.path,
|
|
@@ -482,6 +525,21 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
482
525
|
modified = true;
|
|
483
526
|
transformed = oxcTransformed;
|
|
484
527
|
}
|
|
528
|
+
if (cache) {
|
|
529
|
+
const rulesAdded = /* @__PURE__ */ new Map();
|
|
530
|
+
for (const [key, afterRules] of requestedExports) {
|
|
531
|
+
const beforeRules = registryBefore.get(key);
|
|
532
|
+
const diff = diffRequestedExportRules(beforeRules, afterRules);
|
|
533
|
+
if (!diff) continue;
|
|
534
|
+
rulesAdded.set(key, diff);
|
|
535
|
+
}
|
|
536
|
+
const entry = {
|
|
537
|
+
transformed,
|
|
538
|
+
modified,
|
|
539
|
+
rulesAdded
|
|
540
|
+
};
|
|
541
|
+
cache.set(args.path, source, allowListForCache, entry);
|
|
542
|
+
}
|
|
485
543
|
if (!modified) return void 0;
|
|
486
544
|
const ext = extname(args.path).slice(1);
|
|
487
545
|
return { contents: transformed, loader: ext, resolveDir: dirname(args.path) };
|