@ecopages/react 0.2.0-alpha.47 → 0.2.0-alpha.49

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.47",
3
+ "version": "0.2.0-alpha.49",
4
4
  "description": "React integration for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -114,6 +114,21 @@ export declare class ReactHmrStrategy extends HmrStrategy {
114
114
  * @returns True if the file is in the layouts directory
115
115
  */
116
116
  private isLayoutFile;
117
+ private isPageEntrypoint;
118
+ private getEntrypointOutput;
119
+ private getGroupedTempOutputPattern;
120
+ private collectReactPageBuildTargets;
121
+ private getRequestedTargets;
122
+ /**
123
+ * Expands one HMR request into the full React page build cohort when needed.
124
+ *
125
+ * @remarks
126
+ * Page and layout changes need one shared rebuild pass so sibling routes keep
127
+ * a consistent client module graph. Non-page changes that do not touch a page
128
+ * cohort can stay scoped to the originally requested targets.
129
+ */
130
+ private resolveBuildTargets;
131
+ private partitionBuildTargets;
117
132
  /**
118
133
  * Processes a React file change by rebuilding all React entrypoints.
119
134
  *
@@ -134,12 +149,15 @@ export declare class ReactHmrStrategy extends HmrStrategy {
134
149
  * @returns True if bundling was successful
135
150
  */
136
151
  private bundleReactEntrypoint;
152
+ private bundleReactEntrypoints;
137
153
  private resolveTempOutputPath;
138
154
  /**
139
155
  * Encodes dynamic route segments (brackets) in file paths.
140
156
  * Converts `[slug]` to `_slug_` to avoid filesystem issues.
141
157
  */
142
158
  private encodeDynamicSegments;
159
+ private rewriteChunkImportUrls;
160
+ private isMissingTempOutputError;
143
161
  /**
144
162
  * Processes bundled output and injects the React HMR handler.
145
163
  * Writes to temp file first, then renames atomically to avoid conflicts.
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
3
+ import { RESOLVED_ASSETS_DIR } from "@ecopages/core/constants";
3
4
  import { rewriteRuntimeSpecifierAliases } from "@ecopages/core/build/runtime-specifier-aliases";
4
5
  import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
5
6
  import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
@@ -132,6 +133,96 @@ class ReactHmrStrategy extends HmrStrategy {
132
133
  isLayoutFile(filePath) {
133
134
  return filePath.startsWith(this.context.getLayoutsDir());
134
135
  }
136
+ isPageEntrypoint(filePath) {
137
+ return filePath.startsWith(this.context.getPagesDir()) && this.isReactEntrypoint(filePath);
138
+ }
139
+ getEntrypointOutput(entrypointPath) {
140
+ const srcDir = this.context.getSrcDir();
141
+ const relativePath = path.relative(srcDir, entrypointPath);
142
+ const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, ".js");
143
+ const encodedPathJs = this.encodeDynamicSegments(relativePathJs);
144
+ const outputPath = path.join(this.context.getDistDir(), encodedPathJs);
145
+ const outputUrl = `/${path.join(RESOLVED_ASSETS_DIR, "_hmr", encodedPathJs).split(path.sep).join("/")}`;
146
+ return { outputPath, outputUrl };
147
+ }
148
+ getGroupedTempOutputPattern(entrypointPath) {
149
+ const srcDir = this.context.getSrcDir();
150
+ const relativePath = path.relative(srcDir, entrypointPath);
151
+ const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, ".js");
152
+ return {
153
+ outputDir: path.join(this.context.getDistDir(), path.dirname(relativePathJs)),
154
+ outputBaseName: path.basename(relativePathJs, ".js")
155
+ };
156
+ }
157
+ async collectReactPageBuildTargets() {
158
+ const pagesDir = this.context.getPagesDir();
159
+ const scannedFiles = await fileSystem.glob(
160
+ this.allTemplateExtensions.map((extension) => `**/*${extension}`),
161
+ { cwd: pagesDir }
162
+ );
163
+ const targets = /* @__PURE__ */ new Map();
164
+ for (const file of scannedFiles) {
165
+ if (file.includes(".ecopages-node.")) {
166
+ continue;
167
+ }
168
+ const entrypointPath = path.join(pagesDir, file);
169
+ if (!this.isPageEntrypoint(entrypointPath)) {
170
+ continue;
171
+ }
172
+ this.pageMetadataCache.markOwnedEntrypoint(entrypointPath);
173
+ targets.set(entrypointPath, {
174
+ entrypointPath,
175
+ outputUrl: this.getEntrypointOutput(entrypointPath).outputUrl
176
+ });
177
+ }
178
+ return Array.from(targets.values()).sort(
179
+ (left, right) => left.entrypointPath.localeCompare(right.entrypointPath)
180
+ );
181
+ }
182
+ getRequestedTargets(changedFilePath, changedEntrypointOutput, watchedFiles) {
183
+ const requestedEntries = changedEntrypointOutput ? [[changedFilePath, changedEntrypointOutput]] : Array.from(watchedFiles.entries());
184
+ return requestedEntries.map(([entrypointPath, outputUrl]) => ({
185
+ entrypointPath,
186
+ outputUrl
187
+ }));
188
+ }
189
+ /**
190
+ * Expands one HMR request into the full React page build cohort when needed.
191
+ *
192
+ * @remarks
193
+ * Page and layout changes need one shared rebuild pass so sibling routes keep
194
+ * a consistent client module graph. Non-page changes that do not touch a page
195
+ * cohort can stay scoped to the originally requested targets.
196
+ */
197
+ async resolveBuildTargets(requestedTargets, changedFilePath) {
198
+ const requestedPageTargets = requestedTargets.filter((target) => this.isPageEntrypoint(target.entrypointPath));
199
+ const shouldGroupPageBuilds = this.isLayoutFile(changedFilePath) || requestedPageTargets.length > 0;
200
+ if (!shouldGroupPageBuilds) {
201
+ return [];
202
+ }
203
+ const groupedTargets = new Map(requestedPageTargets.map((target) => [target.entrypointPath, target]));
204
+ for (const target of await this.collectReactPageBuildTargets()) {
205
+ groupedTargets.set(target.entrypointPath, target);
206
+ }
207
+ return Array.from(groupedTargets.values()).sort(
208
+ (left, right) => left.entrypointPath.localeCompare(right.entrypointPath)
209
+ );
210
+ }
211
+ partitionBuildTargets(requestedTargets, groupedPageTargets) {
212
+ if (groupedPageTargets.length === 0) {
213
+ return {
214
+ pageTargets: [],
215
+ nonPageTargets: requestedTargets
216
+ };
217
+ }
218
+ const groupedPageEntrypoints = new Set(groupedPageTargets.map((target) => target.entrypointPath));
219
+ return {
220
+ pageTargets: groupedPageTargets,
221
+ nonPageTargets: requestedTargets.filter(
222
+ (target) => !groupedPageEntrypoints.has(target.entrypointPath) && !this.isPageEntrypoint(target.entrypointPath)
223
+ )
224
+ };
225
+ }
135
226
  /**
136
227
  * Processes a React file change by rebuilding all React entrypoints.
137
228
  *
@@ -159,15 +250,35 @@ class ReactHmrStrategy extends HmrStrategy {
159
250
  appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
160
251
  return { type: "none" };
161
252
  }
162
- const entrypointsToBuild = changedEntrypointOutput ? [[_filePath, changedEntrypointOutput]] : watchedFiles.entries();
253
+ const requestedTargets = this.getRequestedTargets(_filePath, changedEntrypointOutput, watchedFiles);
254
+ const groupedPageTargets = await this.resolveBuildTargets(requestedTargets, _filePath);
255
+ const { pageTargets, nonPageTargets } = this.partitionBuildTargets(requestedTargets, groupedPageTargets);
163
256
  const updates = [];
164
- for (const [entrypoint, outputUrl] of entrypointsToBuild) {
165
- if (!this.isReactEntrypoint(entrypoint)) {
257
+ const requestedOutputUrls = new Set(requestedTargets.map((target) => target.outputUrl));
258
+ if (pageTargets.length > 1) {
259
+ appLogger.debug(`Bundling ${pageTargets.length} React page entrypoints together`);
260
+ const rebuiltOutputs = await this.bundleReactEntrypoints(pageTargets);
261
+ for (const outputUrl of rebuiltOutputs) {
262
+ if (requestedOutputUrls.has(outputUrl)) {
263
+ updates.push(outputUrl);
264
+ }
265
+ }
266
+ } else {
267
+ for (const { entrypointPath, outputUrl } of pageTargets) {
268
+ appLogger.debug(`Bundling ${entrypointPath}`);
269
+ const success = await this.bundleReactEntrypoint(entrypointPath, outputUrl);
270
+ if (success && requestedOutputUrls.has(outputUrl)) {
271
+ updates.push(outputUrl);
272
+ }
273
+ }
274
+ }
275
+ for (const { entrypointPath, outputUrl } of nonPageTargets) {
276
+ if (!this.isReactEntrypoint(entrypointPath)) {
166
277
  continue;
167
278
  }
168
- appLogger.debug(`Bundling ${entrypoint}`);
169
- const success = await this.bundleReactEntrypoint(entrypoint, outputUrl);
170
- if (success) {
279
+ appLogger.debug(`Bundling ${entrypointPath}`);
280
+ const success = await this.bundleReactEntrypoint(entrypointPath, outputUrl);
281
+ if (success && requestedOutputUrls.has(outputUrl)) {
171
282
  updates.push(outputUrl);
172
283
  }
173
284
  }
@@ -206,11 +317,7 @@ class ReactHmrStrategy extends HmrStrategy {
206
317
  async bundleReactEntrypoint(entrypointPath, outputUrl) {
207
318
  try {
208
319
  const isMdx = entrypointPath.endsWith(".mdx");
209
- const srcDir = this.context.getSrcDir();
210
- const relativePath = path.relative(srcDir, entrypointPath);
211
- const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, ".js");
212
- const encodedPathJs = this.encodeDynamicSegments(relativePathJs);
213
- const outputPath = path.join(this.context.getDistDir(), encodedPathJs);
320
+ const { outputPath } = this.getEntrypointOutput(entrypointPath);
214
321
  const tempDir = path.dirname(outputPath);
215
322
  const declaredModules = this.pageMetadataCache.getDeclaredModules(entrypointPath) ? this.pageMetadataCache.getDeclaredModules(entrypointPath) : isMdx ? await collectPageDeclaredModules(entrypointPath) : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
216
323
  const plugins = this.getBuildPlugins(declaredModules);
@@ -247,6 +354,61 @@ class ReactHmrStrategy extends HmrStrategy {
247
354
  return false;
248
355
  }
249
356
  }
357
+ async bundleReactEntrypoints(entrypoints) {
358
+ try {
359
+ const declaredModules = /* @__PURE__ */ new Set();
360
+ let shouldEnableMdx = false;
361
+ for (const { entrypointPath } of entrypoints) {
362
+ const entrypointDeclaredModules = this.pageMetadataCache.getDeclaredModules(entrypointPath) ? this.pageMetadataCache.getDeclaredModules(entrypointPath) : entrypointPath.endsWith(".mdx") ? await collectPageDeclaredModules(entrypointPath) : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
363
+ this.pageMetadataCache.setDeclaredModules(entrypointPath, entrypointDeclaredModules);
364
+ for (const declaredModule of entrypointDeclaredModules) {
365
+ declaredModules.add(declaredModule);
366
+ }
367
+ if (entrypointPath.endsWith(".mdx")) {
368
+ shouldEnableMdx = true;
369
+ }
370
+ }
371
+ const plugins = this.getBuildPlugins([...declaredModules]);
372
+ if (shouldEnableMdx && this.mdxCompilerOptions) {
373
+ plugins.unshift(createReactMdxLoaderPlugin(this.mdxCompilerOptions));
374
+ }
375
+ const result = await this.context.getBrowserBundleService().bundle({
376
+ profile: "hmr-entrypoint",
377
+ entrypoints: entrypoints.map(({ entrypointPath }) => entrypointPath),
378
+ outdir: this.context.getDistDir(),
379
+ outbase: this.context.getSrcDir(),
380
+ naming: "[dir]/[name].[hash].tmp",
381
+ splitting: true,
382
+ plugins,
383
+ minify: false
384
+ });
385
+ if (!result.success) {
386
+ appLogger.error(`Failed to build grouped React entrypoints:`, result.logs);
387
+ return [];
388
+ }
389
+ const updatedOutputs = [];
390
+ for (const { entrypointPath, outputUrl } of entrypoints) {
391
+ const { outputPath } = this.getEntrypointOutput(entrypointPath);
392
+ const { outputDir, outputBaseName } = this.getGroupedTempOutputPattern(entrypointPath);
393
+ const tempOutput = result.outputs.find((output) => {
394
+ return path.dirname(output.path) === outputDir && path.basename(output.path).startsWith(`${outputBaseName}.`) && path.basename(output.path).includes(".tmp");
395
+ })?.path;
396
+ const resolvedTempOutput = tempOutput ? await this.resolveTempOutputPath(tempOutput) : await this.resolveTempOutputPath(path.join(outputDir, `${outputBaseName}.[hash].tmp.js`));
397
+ if (!resolvedTempOutput) {
398
+ appLogger.debug(`Missing grouped temp output for ${outputUrl}`);
399
+ continue;
400
+ }
401
+ const processed = await this.processOutput(resolvedTempOutput, outputPath, outputUrl);
402
+ if (processed) {
403
+ updatedOutputs.push(outputUrl);
404
+ }
405
+ }
406
+ return updatedOutputs;
407
+ } catch (error) {
408
+ appLogger.error(`Error bundling grouped React entrypoints:`, error);
409
+ return [];
410
+ }
411
+ }
250
412
  async resolveTempOutputPath(tempPath) {
251
413
  if (fileSystem.exists(tempPath)) {
252
414
  return tempPath;
@@ -269,6 +431,28 @@ class ReactHmrStrategy extends HmrStrategy {
269
431
  encodeDynamicSegments(filepath) {
270
432
  return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
271
433
  }
434
+ rewriteChunkImportUrls(code) {
435
+ const hmrChunkBaseUrl = `/${path.join(RESOLVED_ASSETS_DIR, "_hmr").split(path.sep).join("/")}`;
436
+ return code.replace(/(['"])(?:\.\.\/)+(chunk-[^'"]+\.js)\1/g, (_match, quote, chunkFile) => {
437
+ return `${quote}${hmrChunkBaseUrl}/${chunkFile}${quote}`;
438
+ });
439
+ }
440
+ isMissingTempOutputError(error) {
441
+ if (error instanceof FileNotFoundError) {
442
+ return true;
443
+ }
444
+ if (!(error instanceof Error)) {
445
+ return false;
446
+ }
447
+ if (error.message.includes("not found") || error.message.includes("ENOENT")) {
448
+ return true;
449
+ }
450
+ const errorCause = error.cause;
451
+ if (errorCause instanceof FileNotFoundError) {
452
+ return true;
453
+ }
454
+ return typeof errorCause === "object" && errorCause !== null && "code" in errorCause && errorCause.code === "ENOENT";
455
+ }
272
456
  /**
273
457
  * Processes bundled output and injects the React HMR handler.
274
458
  * Writes to temp file first, then renames atomically to avoid conflicts.
@@ -286,6 +470,7 @@ class ReactHmrStrategy extends HmrStrategy {
286
470
  try {
287
471
  let code = await fileSystem.readFile(tempPath);
288
472
  code = rewriteRuntimeSpecifierAliases(code, this.runtimeAliasMap);
473
+ code = this.rewriteChunkImportUrls(code);
289
474
  code = injectHmrHandler(code);
290
475
  await fileSystem.writeAsync(finalPath, code);
291
476
  await fileSystem.removeAsync(tempPath).catch(() => {
@@ -293,7 +478,7 @@ class ReactHmrStrategy extends HmrStrategy {
293
478
  appLogger.debug(`Processed ${url} with HMR handler`);
294
479
  return true;
295
480
  } catch (error) {
296
- if (error instanceof FileNotFoundError || error instanceof Error && error.message.includes("not found") || error instanceof Error && "code" in error && error.code === "ENOENT") {
481
+ if (this.isMissingTempOutputError(error)) {
297
482
  appLogger.debug(`Skipping stale temp output for ${url}: ${tempPath}`);
298
483
  await fileSystem.removeAsync(tempPath).catch(() => {
299
484
  });
@@ -28,6 +28,10 @@ export interface ReactClientBundleOptions {
28
28
  * rewriting them to external runtime specifiers.
29
29
  */
30
30
  includeRuntime?: boolean;
31
+ /**
32
+ * When set, overrides the build adapter chunk splitting mode for this entry.
33
+ */
34
+ splitting?: boolean;
31
35
  }
32
36
  /**
33
37
  * Manages esbuild bundle configuration and plugin creation for React page/component builds.
@@ -40,14 +40,18 @@ class ReactBundleService {
40
40
  naming: `${componentName}.[ext]`,
41
41
  ...import.meta.env?.NODE_ENV === "production" && {
42
42
  minify: true,
43
- splitting: false,
44
43
  treeshaking: true
45
- }
44
+ },
45
+ ...bundleOptions.splitting === void 0 ? {} : { splitting: bundleOptions.splitting }
46
46
  };
47
47
  if (!bundleOptions.includeRuntime) {
48
+ const reactRuntimeSpecifiers = new Set(getReactRuntimeExternalSpecifiers());
48
49
  options.external = [
49
- ...getReactRuntimeExternalSpecifiers(),
50
- ...Object.values(runtimeImports).filter((specifier) => Boolean(specifier))
50
+ ...Object.values(runtimeImports).filter(
51
+ (specifier) => Boolean(specifier) && !reactRuntimeSpecifiers.has(
52
+ specifier
53
+ )
54
+ )
51
55
  ];
52
56
  }
53
57
  const graphBoundaryPlugin = createClientGraphBoundaryPlugin({
@@ -28,9 +28,11 @@ export declare function getReactIslandComponentKey(componentFile: string, config
28
28
  */
29
29
  export declare class ReactHydrationAssetService {
30
30
  private readonly config;
31
+ private static readonly ROUTER_PAGE_GROUPED_BUNDLE_ID;
31
32
  constructor(config: ReactHydrationAssetServiceConfig);
32
33
  private getIslandBundleName;
33
34
  private getIslandHydrationName;
35
+ private getRouterPageGroupedEntryName;
34
36
  /**
35
37
  * Resolves the browser import path used for a React-owned page or island module.
36
38
  * Uses HMR manager for development or constructs static path for production.
@@ -11,6 +11,7 @@ function getReactIslandComponentKey(componentFile, config) {
11
11
  }
12
12
  class ReactHydrationAssetService {
13
13
  config;
14
+ static ROUTER_PAGE_GROUPED_BUNDLE_ID = "ecopages-react-router-pages";
14
15
  constructor(config) {
15
16
  this.config = config;
16
17
  }
@@ -20,6 +21,10 @@ class ReactHydrationAssetService {
20
21
  getIslandHydrationName(bundleName, componentKey) {
21
22
  return `${bundleName}-hydration-${componentKey}`;
22
23
  }
24
+ getRouterPageGroupedEntryName(pagePath) {
25
+ const relativePath = path.relative(this.config.srcDir, pagePath);
26
+ return relativePath.replace(/\.(tsx?|jsx?|mdx?)$/, "").replace(/[\\/]+/g, "__").replace(/\[([^\]]+)\]/g, "_$1_");
27
+ }
23
28
  /**
24
29
  * Resolves the browser import path used for a React-owned page or island module.
25
30
  * Uses HMR manager for development or constructs static path for production.
@@ -48,6 +53,10 @@ class ReactHydrationAssetService {
48
53
  */
49
54
  createPageDependencies(pagePath, componentName, importPath, pageModuleUrlExpression, bundleOptions, isDevelopment, useBrowserRuntimeImports, isMdx) {
50
55
  const runtimeImports = this.config.bundleService.getRuntimeImports();
56
+ const groupedBundle = this.config.routerAdapter ? {
57
+ id: ReactHydrationAssetService.ROUTER_PAGE_GROUPED_BUNDLE_ID,
58
+ entryName: this.getRouterPageGroupedEntryName(pagePath)
59
+ } : void 0;
51
60
  return [
52
61
  AssetFactory.createContentScript({
53
62
  position: "head",
@@ -65,12 +74,14 @@ class ReactHydrationAssetService {
65
74
  name: componentName,
66
75
  packageRole: "page-script",
67
76
  bundle: !isDevelopment,
77
+ groupedBundle,
68
78
  bundleOptions,
69
79
  attributes: {
70
80
  type: "module",
71
81
  defer: "",
72
82
  "data-eco-rerun": "true",
73
83
  "data-eco-script-id": componentName,
84
+ ...this.config.routerAdapter ? { "data-eco-page-bootstrap": "react-router" } : {},
74
85
  "data-eco-persist": "true"
75
86
  }
76
87
  })
@@ -159,17 +170,18 @@ class ReactHydrationAssetService {
159
170
  const hmrManager = this.config.assetProcessingService?.getHmrManager();
160
171
  const isDevelopment = hmrManager?.isEnabled() ?? false;
161
172
  const isHostedDevelopment = !isDevelopment && process.env.NODE_ENV !== "production";
162
- const useBrowserRuntimeImports = isDevelopment || isHostedDevelopment;
173
+ const usesRouterRuntime = Boolean(this.config.routerAdapter);
174
+ const useBrowserRuntimeImports = isDevelopment || isHostedDevelopment || usesRouterRuntime;
163
175
  if (isDevelopment) {
164
176
  this.config.hmrPageMetadataCache?.setDeclaredModules(pagePath, declaredModules);
165
177
  }
166
178
  const importPath = await this.resolveAssetImportPath(pagePath, componentName);
167
- const pageModuleUrlExpression = "import.meta.url";
179
+ const pageModuleUrlExpression = isDevelopment ? JSON.stringify(importPath) : "import.meta.url";
168
180
  const bundleOptions = await this.config.bundleService.createBundleOptions(
169
181
  componentName,
170
182
  isMdx,
171
183
  declaredModules,
172
- { includeRuntime: !useBrowserRuntimeImports }
184
+ { includeRuntime: !useBrowserRuntimeImports, splitting: usesRouterRuntime }
173
185
  );
174
186
  const dependencies = this.createPageDependencies(
175
187
  pagePath,