@ecopages/react 0.2.0-alpha.51 → 0.2.0-alpha.53

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react",
3
- "version": "0.2.0-alpha.51",
3
+ "version": "0.2.0-alpha.53",
4
4
  "description": "React integration for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -69,14 +69,14 @@
69
69
  "directory": "packages/integrations/react"
70
70
  },
71
71
  "peerDependencies": {
72
- "@ecopages/core": "0.2.0-alpha.51",
72
+ "@ecopages/core": "0.2.0-alpha.53",
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.51",
79
+ "@ecopages/file-system": "0.2.0-alpha.53",
80
80
  "@ecopages/logger": "^0.2.3",
81
81
  "@mdx-js/esbuild": "^3.1.1",
82
82
  "@mdx-js/mdx": "^3.1.1",
@@ -100,11 +100,21 @@ export declare class ReactHmrStrategy extends HmrStrategy {
100
100
  private isRouteTemplate;
101
101
  private resolveTemplateExtension;
102
102
  private ownsWatchedEntrypoint;
103
+ private configContainsFile;
104
+ private pageModuleRequiresLayoutRefresh;
105
+ private hasLayoutOwnedDependencyTarget;
103
106
  /**
104
107
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
105
108
  *
109
+ * Uses a three-way decision strategy for selective invalidation:
110
+ * 1. If the file is a watched entrypoint, check if React owns it
111
+ * 2. If the file is a dependency of watched entrypoints (via dependency graph),
112
+ * check if any affected entrypoints are React-owned. Returns false if hits
113
+ * exist but none are owned (prevents unnecessary rebuilds).
114
+ * 3. Otherwise, check if the file itself is a React entrypoint template
115
+ *
106
116
  * @param filePath - Absolute path to the changed file
107
- * @returns True if this is a registered React or MDX entrypoint
117
+ * @returns True if this file should trigger React HMR rebuilds
108
118
  */
109
119
  matches(filePath: string): boolean;
110
120
  /**
@@ -123,7 +133,6 @@ export declare class ReactHmrStrategy extends HmrStrategy {
123
133
  private getEntrypointOutput;
124
134
  private getGroupedTempOutputPattern;
125
135
  private collectReactPageBuildTargets;
126
- private getRequestedTargets;
127
136
  /**
128
137
  * Expands one HMR request into the full React page build cohort when needed.
129
138
  *
@@ -135,12 +144,17 @@ export declare class ReactHmrStrategy extends HmrStrategy {
135
144
  private resolveBuildTargets;
136
145
  private partitionBuildTargets;
137
146
  /**
138
- * Processes a React file change by rebuilding all React entrypoints.
147
+ * Processes a React file change by rebuilding affected React entrypoints.
148
+ *
149
+ * Uses a three-way decision strategy for selective invalidation:
150
+ * 1. Changed file is a watched entrypoint: rebuild only that entrypoint
151
+ * 2. Dependency graph has hits: rebuild only affected React-owned entrypoints.
152
+ * If hits exist but none map to React-owned entrypoints, return 'none' to
153
+ * prevent unnecessary rebuilds.
154
+ * 3. Dependency graph miss: fall back to rebuilding all watched entrypoints
139
155
  *
140
156
  * For layout files, broadcasts a 'layout-update' event to trigger full page reload.
141
157
  * For regular components/pages, broadcasts 'update' events for module-level HMR.
142
- * When a page entrypoint is first registered, only that entrypoint is built.
143
- * Subsequent file updates rebuild all watched React entrypoints as usual.
144
158
  *
145
159
  * @param _filePath - Absolute path to the changed file
146
160
  * @returns Action to broadcast update events (layout-update for layouts, update for components)
@@ -149,11 +163,26 @@ export declare class ReactHmrStrategy extends HmrStrategy {
149
163
  /**
150
164
  * Bundles a single React/MDX entrypoint with HMR support.
151
165
  *
166
+ * After successful bundling, populates the entrypoint dependency graph with
167
+ * the build's dependency metadata. This enables selective invalidation on
168
+ * subsequent file changes, so only entrypoints affected by a changed
169
+ * dependency are rebuilt.
170
+ *
152
171
  * @param entrypointPath - Absolute path to the source file
153
172
  * @param outputUrl - URL path for the bundled file
154
173
  * @returns True if bundling was successful
155
174
  */
156
175
  private bundleReactEntrypoint;
176
+ /**
177
+ * Bundles multiple React/MDX entrypoints in a single build pass.
178
+ *
179
+ * Uses code splitting to share common dependencies across entrypoints.
180
+ * After successful bundling, populates the entrypoint dependency graph with
181
+ * the build's dependency metadata for selective invalidation.
182
+ *
183
+ * @param entrypoints - Array of entrypoint paths and their output URLs
184
+ * @returns Array of output URLs that were successfully built
185
+ */
157
186
  private bundleReactEntrypoints;
158
187
  private resolveTempOutputPath;
159
188
  /**
@@ -7,6 +7,7 @@ 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
9
  import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from "./utils/declared-modules.js";
10
+ import { someInConfigTree } from "./utils/component-config-traversal.js";
10
11
  import { createReactMdxLoaderPlugin } from "./utils/react-mdx-loader-plugin.js";
11
12
  import { getReactClientGraphAllowSpecifiers } from "./utils/react-runtime-alias-map.js";
12
13
  const appLogger = new Logger("[ReactHmrStrategy]");
@@ -103,11 +104,44 @@ class ReactHmrStrategy extends HmrStrategy {
103
104
  ownsWatchedEntrypoint(filePath) {
104
105
  return this.pageMetadataCache.ownsEntrypoint(filePath);
105
106
  }
107
+ configContainsFile(config, filePath) {
108
+ const resolvedFilePath = path.resolve(filePath);
109
+ return someInConfigTree(config, (node) => {
110
+ if (!node.__eco?.file) {
111
+ return false;
112
+ }
113
+ return path.resolve(node.__eco.file) === resolvedFilePath;
114
+ });
115
+ }
116
+ pageModuleRequiresLayoutRefresh(pageModule, filePath) {
117
+ return [pageModule.default?.config, pageModule.config].some((config) => {
118
+ return this.configContainsFile(config?.layout?.config, filePath);
119
+ });
120
+ }
121
+ async hasLayoutOwnedDependencyTarget(changedFilePath, requestedTargets) {
122
+ for (const target of requestedTargets) {
123
+ if (!this.isPageEntrypoint(target.entrypointPath)) {
124
+ continue;
125
+ }
126
+ const pageModule = await this.importNodePageModule(target.entrypointPath);
127
+ if (this.pageModuleRequiresLayoutRefresh(pageModule, changedFilePath)) {
128
+ return true;
129
+ }
130
+ }
131
+ return false;
132
+ }
106
133
  /**
107
134
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
108
135
  *
136
+ * Uses a three-way decision strategy for selective invalidation:
137
+ * 1. If the file is a watched entrypoint, check if React owns it
138
+ * 2. If the file is a dependency of watched entrypoints (via dependency graph),
139
+ * check if any affected entrypoints are React-owned. Returns false if hits
140
+ * exist but none are owned (prevents unnecessary rebuilds).
141
+ * 3. Otherwise, check if the file itself is a React entrypoint template
142
+ *
109
143
  * @param filePath - Absolute path to the changed file
110
- * @returns True if this is a registered React or MDX entrypoint
144
+ * @returns True if this file should trigger React HMR rebuilds
111
145
  */
112
146
  matches(filePath) {
113
147
  const watchedFiles = this.context.getWatchedFiles();
@@ -118,6 +152,15 @@ class ReactHmrStrategy extends HmrStrategy {
118
152
  if (watchedFiles.has(filePath)) {
119
153
  return this.ownsWatchedEntrypoint(filePath);
120
154
  }
155
+ const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(filePath);
156
+ if (dependencyHits.size > 0) {
157
+ for (const entrypoint of dependencyHits) {
158
+ if (this.ownsWatchedEntrypoint(entrypoint)) {
159
+ return true;
160
+ }
161
+ }
162
+ return false;
163
+ }
121
164
  return this.isReactEntrypoint(filePath);
122
165
  }
123
166
  /**
@@ -180,13 +223,6 @@ class ReactHmrStrategy extends HmrStrategy {
180
223
  (left, right) => left.entrypointPath.localeCompare(right.entrypointPath)
181
224
  );
182
225
  }
183
- getRequestedTargets(changedFilePath, changedEntrypointOutput, watchedFiles) {
184
- const requestedEntries = changedEntrypointOutput ? [[changedFilePath, changedEntrypointOutput]] : Array.from(watchedFiles.entries());
185
- return requestedEntries.map(([entrypointPath, outputUrl]) => ({
186
- entrypointPath,
187
- outputUrl
188
- }));
189
- }
190
226
  /**
191
227
  * Expands one HMR request into the full React page build cohort when needed.
192
228
  *
@@ -225,12 +261,17 @@ class ReactHmrStrategy extends HmrStrategy {
225
261
  };
226
262
  }
227
263
  /**
228
- * Processes a React file change by rebuilding all React entrypoints.
264
+ * Processes a React file change by rebuilding affected React entrypoints.
265
+ *
266
+ * Uses a three-way decision strategy for selective invalidation:
267
+ * 1. Changed file is a watched entrypoint: rebuild only that entrypoint
268
+ * 2. Dependency graph has hits: rebuild only affected React-owned entrypoints.
269
+ * If hits exist but none map to React-owned entrypoints, return 'none' to
270
+ * prevent unnecessary rebuilds.
271
+ * 3. Dependency graph miss: fall back to rebuilding all watched entrypoints
229
272
  *
230
273
  * For layout files, broadcasts a 'layout-update' event to trigger full page reload.
231
274
  * For regular components/pages, broadcasts 'update' events for module-level HMR.
232
- * When a page entrypoint is first registered, only that entrypoint is built.
233
- * Subsequent file updates rebuild all watched React entrypoints as usual.
234
275
  *
235
276
  * @param _filePath - Absolute path to the changed file
236
277
  * @returns Action to broadcast update events (layout-update for layouts, update for components)
@@ -243,6 +284,7 @@ class ReactHmrStrategy extends HmrStrategy {
243
284
  return { type: "none" };
244
285
  }
245
286
  const isLayout = this.isLayoutFile(_filePath);
287
+ const isChangedPageEntrypoint = this.isPageEntrypoint(_filePath);
246
288
  if (isLayout) {
247
289
  appLogger.debug(`Detected layout file change: ${_filePath}`);
248
290
  }
@@ -251,9 +293,42 @@ class ReactHmrStrategy extends HmrStrategy {
251
293
  appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
252
294
  return { type: "none" };
253
295
  }
254
- const requestedTargets = this.getRequestedTargets(_filePath, changedEntrypointOutput, watchedFiles);
296
+ const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(_filePath);
297
+ const hasDependencyHits = dependencyHits.size > 0;
298
+ const affectedEntrypoints = /* @__PURE__ */ new Map();
299
+ let hasOwnedLayoutDependencyHit = false;
300
+ let layoutOwnedPageTargets = [];
301
+ let hasLayoutOwnedRequestedTarget = false;
302
+ if (hasDependencyHits && !changedEntrypointOutput) {
303
+ for (const entrypoint of dependencyHits) {
304
+ const outputUrl = watchedFiles.get(entrypoint);
305
+ if (outputUrl && this.ownsWatchedEntrypoint(entrypoint)) {
306
+ affectedEntrypoints.set(entrypoint, outputUrl);
307
+ continue;
308
+ }
309
+ if (this.isLayoutFile(entrypoint) && this.ownsWatchedEntrypoint(entrypoint)) {
310
+ hasOwnedLayoutDependencyHit = true;
311
+ }
312
+ }
313
+ if (affectedEntrypoints.size === 0 && !hasOwnedLayoutDependencyHit) {
314
+ appLogger.debug(`Dependency hits found but none map to React-owned watched entrypoints`);
315
+ return { type: "none" };
316
+ }
317
+ }
318
+ if (changedEntrypointOutput && !isLayout && !isChangedPageEntrypoint) {
319
+ layoutOwnedPageTargets = await this.collectReactPageBuildTargets();
320
+ hasLayoutOwnedRequestedTarget = await this.hasLayoutOwnedDependencyTarget(
321
+ _filePath,
322
+ layoutOwnedPageTargets
323
+ );
324
+ }
325
+ 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 }));
255
326
  const groupedPageTargets = await this.resolveBuildTargets(requestedTargets, _filePath);
256
327
  const { pageTargets, nonPageTargets } = this.partitionBuildTargets(requestedTargets, groupedPageTargets);
328
+ if (!changedEntrypointOutput) {
329
+ hasLayoutOwnedRequestedTarget = await this.hasLayoutOwnedDependencyTarget(_filePath, requestedTargets);
330
+ }
331
+ const requiresLayoutRefresh = isLayout || hasOwnedLayoutDependencyHit || hasLayoutOwnedRequestedTarget;
257
332
  const updates = [];
258
333
  const requestedOutputUrls = new Set(requestedTargets.map((target) => target.outputUrl));
259
334
  if (pageTargets.length > 1) {
@@ -284,7 +359,7 @@ class ReactHmrStrategy extends HmrStrategy {
284
359
  }
285
360
  }
286
361
  if (updates.length > 0) {
287
- if (isLayout) {
362
+ if (requiresLayoutRefresh) {
288
363
  appLogger.debug(`Layout update detected, sending layout-update event`);
289
364
  return {
290
365
  type: "broadcast",
@@ -311,6 +386,11 @@ class ReactHmrStrategy extends HmrStrategy {
311
386
  /**
312
387
  * Bundles a single React/MDX entrypoint with HMR support.
313
388
  *
389
+ * After successful bundling, populates the entrypoint dependency graph with
390
+ * the build's dependency metadata. This enables selective invalidation on
391
+ * subsequent file changes, so only entrypoints affected by a changed
392
+ * dependency are rebuilt.
393
+ *
314
394
  * @param entrypointPath - Absolute path to the source file
315
395
  * @param outputUrl - URL path for the bundled file
316
396
  * @returns True if bundling was successful
@@ -338,6 +418,12 @@ class ReactHmrStrategy extends HmrStrategy {
338
418
  appLogger.error(`Failed to build ${entrypointPath}:`, result.logs);
339
419
  return false;
340
420
  }
421
+ if (result.dependencyGraph?.entrypoints) {
422
+ const dependencyGraph = this.context.getEntrypointDependencyGraph();
423
+ for (const [entrypoint, deps] of Object.entries(result.dependencyGraph.entrypoints)) {
424
+ dependencyGraph.setEntrypointDependencies(entrypoint, deps);
425
+ }
426
+ }
341
427
  const tempFile = result.outputs[0]?.path;
342
428
  if (!tempFile) {
343
429
  appLogger.error(`No output file generated for ${entrypointPath}`);
@@ -355,6 +441,16 @@ class ReactHmrStrategy extends HmrStrategy {
355
441
  return false;
356
442
  }
357
443
  }
444
+ /**
445
+ * Bundles multiple React/MDX entrypoints in a single build pass.
446
+ *
447
+ * Uses code splitting to share common dependencies across entrypoints.
448
+ * After successful bundling, populates the entrypoint dependency graph with
449
+ * the build's dependency metadata for selective invalidation.
450
+ *
451
+ * @param entrypoints - Array of entrypoint paths and their output URLs
452
+ * @returns Array of output URLs that were successfully built
453
+ */
358
454
  async bundleReactEntrypoints(entrypoints) {
359
455
  try {
360
456
  const declaredModules = /* @__PURE__ */ new Set();
@@ -387,6 +483,12 @@ class ReactHmrStrategy extends HmrStrategy {
387
483
  appLogger.error(`Failed to build grouped React entrypoints:`, result.logs);
388
484
  return [];
389
485
  }
486
+ if (result.dependencyGraph?.entrypoints) {
487
+ const dependencyGraph = this.context.getEntrypointDependencyGraph();
488
+ for (const [entrypoint, deps] of Object.entries(result.dependencyGraph.entrypoints)) {
489
+ dependencyGraph.setEntrypointDependencies(entrypoint, deps);
490
+ }
491
+ }
390
492
  const updatedOutputs = [];
391
493
  for (const { entrypointPath, outputUrl } of entrypoints) {
392
494
  const { outputPath } = this.getEntrypointOutput(entrypointPath);