@ecopages/react 0.2.0-alpha.50 → 0.2.0-alpha.52

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.50",
3
+ "version": "0.2.0-alpha.52",
4
4
  "description": "React integration for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -31,6 +31,10 @@
31
31
  "types": "./src/utils/client-only.d.ts",
32
32
  "default": "./src/utils/client-only.js"
33
33
  },
34
+ "./runtime/use-sync-external-store-with-selector": {
35
+ "types": "./src/runtime/use-sync-external-store-with-selector.d.ts",
36
+ "default": "./src/runtime/use-sync-external-store-with-selector.js"
37
+ },
34
38
  "./router-adapter": {
35
39
  "types": "./src/router-adapter.d.ts",
36
40
  "default": "./src/router-adapter.js"
@@ -50,6 +54,10 @@
50
54
  "types": "./src/utils/client-only.d.ts",
51
55
  "default": "./src/utils/client-only.js"
52
56
  },
57
+ "./runtime/use-sync-external-store-with-selector.ts": {
58
+ "types": "./src/runtime/use-sync-external-store-with-selector.d.ts",
59
+ "default": "./src/runtime/use-sync-external-store-with-selector.js"
60
+ },
53
61
  "./router-adapter.ts": {
54
62
  "types": "./src/router-adapter.d.ts",
55
63
  "default": "./src/router-adapter.js"
@@ -61,14 +69,14 @@
61
69
  "directory": "packages/integrations/react"
62
70
  },
63
71
  "peerDependencies": {
64
- "@ecopages/core": "0.2.0-alpha.50",
72
+ "@ecopages/core": "0.2.0-alpha.52",
65
73
  "@types/react": "^19",
66
74
  "@types/react-dom": "^19",
67
75
  "react": "^19",
68
76
  "react-dom": "^19"
69
77
  },
70
78
  "dependencies": {
71
- "@ecopages/file-system": "0.2.0-alpha.50",
79
+ "@ecopages/file-system": "0.2.0-alpha.52",
72
80
  "@ecopages/logger": "^0.2.3",
73
81
  "@mdx-js/esbuild": "^3.1.1",
74
82
  "@mdx-js/mdx": "^3.1.1",
@@ -7,13 +7,14 @@
7
7
  * @module
8
8
  */
9
9
  import { HmrStrategy, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
10
+ import type { BrowserRuntimeManifest } from '@ecopages/core/build/browser-runtime-manifest';
10
11
  import type { DefaultHmrContext } from '@ecopages/core';
11
12
  import type { CompileOptions } from '@mdx-js/mdx';
12
13
  import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.js';
13
14
  export interface ReactHmrStrategyOptions {
14
15
  context: DefaultHmrContext;
15
16
  pageMetadataCache: ReactHmrPageMetadataCache;
16
- runtimeAliasMap: ReadonlyMap<string, string>;
17
+ runtimeManifest: BrowserRuntimeManifest;
17
18
  mdxCompilerOptions?: CompileOptions;
18
19
  ownedTemplateExtensions?: string[];
19
20
  allTemplateExtensions?: string[];
@@ -56,7 +57,7 @@ export interface ReactHmrStrategyOptions {
56
57
  * const strategy = new ReactHmrStrategy({
57
58
  * context,
58
59
  * pageMetadataCache,
59
- * runtimeAliasMap
60
+ * runtimeManifest
60
61
  * });
61
62
  * ```
62
63
  */
@@ -74,13 +75,17 @@ export declare class ReactHmrStrategy extends HmrStrategy {
74
75
  private context;
75
76
  private pageMetadataCache;
76
77
  private explicitGraphEnabled;
77
- private readonly runtimeAliasMap;
78
+ private readonly runtimeManifest;
78
79
  constructor(options: ReactHmrStrategyOptions);
79
80
  /**
80
81
  * Returns build plugins for React HMR bundling.
81
82
  *
82
83
  * Includes the client graph boundary plugin to prevent undeclared imports
83
84
  * (including `node:*`) from breaking the browser bundle.
85
+ *
86
+ * @remarks
87
+ * HMR builds receive the React runtime manifest and rewrite manifest-owned
88
+ * runtime imports to concrete asset URLs before module resolution.
84
89
  */
85
90
  private getBuildPlugins;
86
91
  private isReactEntrypoint;
@@ -98,8 +103,15 @@ export declare class ReactHmrStrategy extends HmrStrategy {
98
103
  /**
99
104
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
100
105
  *
106
+ * Uses a three-way decision strategy for selective invalidation:
107
+ * 1. If the file is a watched entrypoint, check if React owns it
108
+ * 2. If the file is a dependency of watched entrypoints (via dependency graph),
109
+ * check if any affected entrypoints are React-owned. Returns false if hits
110
+ * exist but none are owned (prevents unnecessary rebuilds).
111
+ * 3. Otherwise, check if the file itself is a React entrypoint template
112
+ *
101
113
  * @param filePath - Absolute path to the changed file
102
- * @returns True if this is a registered React or MDX entrypoint
114
+ * @returns True if this file should trigger React HMR rebuilds
103
115
  */
104
116
  matches(filePath: string): boolean;
105
117
  /**
@@ -118,7 +130,6 @@ export declare class ReactHmrStrategy extends HmrStrategy {
118
130
  private getEntrypointOutput;
119
131
  private getGroupedTempOutputPattern;
120
132
  private collectReactPageBuildTargets;
121
- private getRequestedTargets;
122
133
  /**
123
134
  * Expands one HMR request into the full React page build cohort when needed.
124
135
  *
@@ -130,12 +141,17 @@ export declare class ReactHmrStrategy extends HmrStrategy {
130
141
  private resolveBuildTargets;
131
142
  private partitionBuildTargets;
132
143
  /**
133
- * Processes a React file change by rebuilding all React entrypoints.
144
+ * Processes a React file change by rebuilding affected React entrypoints.
145
+ *
146
+ * Uses a three-way decision strategy for selective invalidation:
147
+ * 1. Changed file is a watched entrypoint: rebuild only that entrypoint
148
+ * 2. Dependency graph has hits: rebuild only affected React-owned entrypoints.
149
+ * If hits exist but none map to React-owned entrypoints, return 'none' to
150
+ * prevent unnecessary rebuilds.
151
+ * 3. Dependency graph miss: fall back to rebuilding all watched entrypoints
134
152
  *
135
153
  * For layout files, broadcasts a 'layout-update' event to trigger full page reload.
136
154
  * For regular components/pages, broadcasts 'update' events for module-level HMR.
137
- * When a page entrypoint is first registered, only that entrypoint is built.
138
- * Subsequent file updates rebuild all watched React entrypoints as usual.
139
155
  *
140
156
  * @param _filePath - Absolute path to the changed file
141
157
  * @returns Action to broadcast update events (layout-update for layouts, update for components)
@@ -144,11 +160,26 @@ export declare class ReactHmrStrategy extends HmrStrategy {
144
160
  /**
145
161
  * Bundles a single React/MDX entrypoint with HMR support.
146
162
  *
163
+ * After successful bundling, populates the entrypoint dependency graph with
164
+ * the build's dependency metadata. This enables selective invalidation on
165
+ * subsequent file changes, so only entrypoints affected by a changed
166
+ * dependency are rebuilt.
167
+ *
147
168
  * @param entrypointPath - Absolute path to the source file
148
169
  * @param outputUrl - URL path for the bundled file
149
170
  * @returns True if bundling was successful
150
171
  */
151
172
  private bundleReactEntrypoint;
173
+ /**
174
+ * Bundles multiple React/MDX entrypoints in a single build pass.
175
+ *
176
+ * Uses code splitting to share common dependencies across entrypoints.
177
+ * After successful bundling, populates the entrypoint dependency graph with
178
+ * the build's dependency metadata for selective invalidation.
179
+ *
180
+ * @param entrypoints - Array of entrypoint paths and their output URLs
181
+ * @returns Array of output URLs that were successfully built
182
+ */
152
183
  private bundleReactEntrypoints;
153
184
  private resolveTempOutputPath;
154
185
  /**
@@ -1,8 +1,7 @@
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 { rewriteRuntimeSpecifierAliases } from "@ecopages/core/build/runtime-specifier-aliases";
5
- import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
4
+ import { createBrowserRuntimeImportRewritePlugin } from "@ecopages/core/build/browser-runtime-import-rewrite-plugin";
6
5
  import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
7
6
  import { Logger } from "@ecopages/logger";
8
7
  import { injectHmrHandler } from "./utils/hmr-scripts.js";
@@ -10,7 +9,6 @@ import { createClientGraphBoundaryPlugin } from "./utils/client-graph-boundary-p
10
9
  import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from "./utils/declared-modules.js";
11
10
  import { createReactMdxLoaderPlugin } from "./utils/react-mdx-loader-plugin.js";
12
11
  import { getReactClientGraphAllowSpecifiers } from "./utils/react-runtime-alias-map.js";
13
- import { createUseSyncExternalStoreShimPlugin } from "./utils/use-sync-external-store-shim-plugin.js";
14
12
  const appLogger = new Logger("[ReactHmrStrategy]");
15
13
  class ReactHmrStrategy extends HmrStrategy {
16
14
  type = HmrStrategyType.INTEGRATION;
@@ -28,12 +26,12 @@ class ReactHmrStrategy extends HmrStrategy {
28
26
  context;
29
27
  pageMetadataCache;
30
28
  explicitGraphEnabled;
31
- runtimeAliasMap;
29
+ runtimeManifest;
32
30
  constructor(options) {
33
31
  super();
34
32
  this.context = options.context;
35
33
  this.pageMetadataCache = options.pageMetadataCache;
36
- this.runtimeAliasMap = options.runtimeAliasMap;
34
+ this.runtimeManifest = options.runtimeManifest;
37
35
  this.explicitGraphEnabled = options.explicitGraphEnabled ?? false;
38
36
  this.mdxCompilerOptions = options.mdxCompilerOptions;
39
37
  this.ownedTemplateExtensions = new Set(options.ownedTemplateExtensions ?? [".tsx"]);
@@ -46,11 +44,18 @@ class ReactHmrStrategy extends HmrStrategy {
46
44
  *
47
45
  * Includes the client graph boundary plugin to prevent undeclared imports
48
46
  * (including `node:*`) from breaking the browser bundle.
47
+ *
48
+ * @remarks
49
+ * HMR builds receive the React runtime manifest and rewrite manifest-owned
50
+ * runtime imports to concrete asset URLs before module resolution.
49
51
  */
50
52
  getBuildPlugins(declaredModules) {
51
- const allowSpecifiers = getReactClientGraphAllowSpecifiers(this.runtimeAliasMap.keys());
52
- const runtimeAliasPlugin = createRuntimeSpecifierAliasPlugin(this.runtimeAliasMap, {
53
- name: "react-hmr-runtime-specifier-alias"
53
+ const allowSpecifiers = getReactClientGraphAllowSpecifiers(
54
+ this.runtimeManifest.assets.map((asset) => asset.specifier)
55
+ );
56
+ const runtimeRewritePlugin = createBrowserRuntimeImportRewritePlugin({
57
+ name: "react-hmr-runtime-import-rewrite",
58
+ manifest: this.runtimeManifest
54
59
  });
55
60
  return [
56
61
  createClientGraphBoundaryPlugin({
@@ -58,12 +63,8 @@ class ReactHmrStrategy extends HmrStrategy {
58
63
  alwaysAllowSpecifiers: allowSpecifiers,
59
64
  declaredModules
60
65
  }),
61
- ...runtimeAliasPlugin ? [runtimeAliasPlugin] : [],
62
- ...this.context.getPlugins(),
63
- createUseSyncExternalStoreShimPlugin({
64
- name: "react-hmr-use-sync-external-store-shim",
65
- namespace: "ecopages-react-hmr-shim"
66
- })
66
+ ...runtimeRewritePlugin ? [runtimeRewritePlugin] : [],
67
+ ...this.context.getPlugins()
67
68
  ];
68
69
  }
69
70
  isReactEntrypoint(filePath) {
@@ -105,8 +106,15 @@ class ReactHmrStrategy extends HmrStrategy {
105
106
  /**
106
107
  * Determines if the file is a React/MDX entrypoint that's registered for HMR.
107
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
+ *
108
116
  * @param filePath - Absolute path to the changed file
109
- * @returns True if this is a registered React or MDX entrypoint
117
+ * @returns True if this file should trigger React HMR rebuilds
110
118
  */
111
119
  matches(filePath) {
112
120
  const watchedFiles = this.context.getWatchedFiles();
@@ -117,6 +125,15 @@ class ReactHmrStrategy extends HmrStrategy {
117
125
  if (watchedFiles.has(filePath)) {
118
126
  return this.ownsWatchedEntrypoint(filePath);
119
127
  }
128
+ const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(filePath);
129
+ if (dependencyHits.size > 0) {
130
+ for (const entrypoint of dependencyHits) {
131
+ if (this.ownsWatchedEntrypoint(entrypoint)) {
132
+ return true;
133
+ }
134
+ }
135
+ return false;
136
+ }
120
137
  return this.isReactEntrypoint(filePath);
121
138
  }
122
139
  /**
@@ -179,13 +196,6 @@ class ReactHmrStrategy extends HmrStrategy {
179
196
  (left, right) => left.entrypointPath.localeCompare(right.entrypointPath)
180
197
  );
181
198
  }
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
199
  /**
190
200
  * Expands one HMR request into the full React page build cohort when needed.
191
201
  *
@@ -224,12 +234,17 @@ class ReactHmrStrategy extends HmrStrategy {
224
234
  };
225
235
  }
226
236
  /**
227
- * Processes a React file change by rebuilding all React entrypoints.
237
+ * Processes a React file change by rebuilding affected React entrypoints.
238
+ *
239
+ * Uses a three-way decision strategy for selective invalidation:
240
+ * 1. Changed file is a watched entrypoint: rebuild only that entrypoint
241
+ * 2. Dependency graph has hits: rebuild only affected React-owned entrypoints.
242
+ * If hits exist but none map to React-owned entrypoints, return 'none' to
243
+ * prevent unnecessary rebuilds.
244
+ * 3. Dependency graph miss: fall back to rebuilding all watched entrypoints
228
245
  *
229
246
  * For layout files, broadcasts a 'layout-update' event to trigger full page reload.
230
247
  * For regular components/pages, broadcasts 'update' events for module-level HMR.
231
- * When a page entrypoint is first registered, only that entrypoint is built.
232
- * Subsequent file updates rebuild all watched React entrypoints as usual.
233
248
  *
234
249
  * @param _filePath - Absolute path to the changed file
235
250
  * @returns Action to broadcast update events (layout-update for layouts, update for components)
@@ -250,7 +265,22 @@ class ReactHmrStrategy extends HmrStrategy {
250
265
  appLogger.debug(`Skipping non-React watched entrypoint: ${_filePath}`);
251
266
  return { type: "none" };
252
267
  }
253
- const requestedTargets = this.getRequestedTargets(_filePath, changedEntrypointOutput, watchedFiles);
268
+ const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(_filePath);
269
+ const hasDependencyHits = dependencyHits.size > 0;
270
+ const affectedEntrypoints = /* @__PURE__ */ new Map();
271
+ if (hasDependencyHits && !changedEntrypointOutput) {
272
+ for (const entrypoint of dependencyHits) {
273
+ const outputUrl = watchedFiles.get(entrypoint);
274
+ if (outputUrl && this.ownsWatchedEntrypoint(entrypoint)) {
275
+ affectedEntrypoints.set(entrypoint, outputUrl);
276
+ }
277
+ }
278
+ if (affectedEntrypoints.size === 0) {
279
+ appLogger.debug(`Dependency hits found but none map to React-owned watched entrypoints`);
280
+ return { type: "none" };
281
+ }
282
+ }
283
+ const requestedTargets = changedEntrypointOutput ? [{ entrypointPath: _filePath, outputUrl: changedEntrypointOutput }] : hasDependencyHits ? Array.from(affectedEntrypoints, ([entrypointPath, outputUrl]) => ({ entrypointPath, outputUrl })) : Array.from(watchedFiles, ([entrypointPath, outputUrl]) => ({ entrypointPath, outputUrl }));
254
284
  const groupedPageTargets = await this.resolveBuildTargets(requestedTargets, _filePath);
255
285
  const { pageTargets, nonPageTargets } = this.partitionBuildTargets(requestedTargets, groupedPageTargets);
256
286
  const updates = [];
@@ -310,6 +340,11 @@ class ReactHmrStrategy extends HmrStrategy {
310
340
  /**
311
341
  * Bundles a single React/MDX entrypoint with HMR support.
312
342
  *
343
+ * After successful bundling, populates the entrypoint dependency graph with
344
+ * the build's dependency metadata. This enables selective invalidation on
345
+ * subsequent file changes, so only entrypoints affected by a changed
346
+ * dependency are rebuilt.
347
+ *
313
348
  * @param entrypointPath - Absolute path to the source file
314
349
  * @param outputUrl - URL path for the bundled file
315
350
  * @returns True if bundling was successful
@@ -337,6 +372,12 @@ class ReactHmrStrategy extends HmrStrategy {
337
372
  appLogger.error(`Failed to build ${entrypointPath}:`, result.logs);
338
373
  return false;
339
374
  }
375
+ if (result.dependencyGraph?.entrypoints) {
376
+ const dependencyGraph = this.context.getEntrypointDependencyGraph();
377
+ for (const [entrypoint, deps] of Object.entries(result.dependencyGraph.entrypoints)) {
378
+ dependencyGraph.setEntrypointDependencies(entrypoint, deps);
379
+ }
380
+ }
340
381
  const tempFile = result.outputs[0]?.path;
341
382
  if (!tempFile) {
342
383
  appLogger.error(`No output file generated for ${entrypointPath}`);
@@ -354,6 +395,16 @@ class ReactHmrStrategy extends HmrStrategy {
354
395
  return false;
355
396
  }
356
397
  }
398
+ /**
399
+ * Bundles multiple React/MDX entrypoints in a single build pass.
400
+ *
401
+ * Uses code splitting to share common dependencies across entrypoints.
402
+ * After successful bundling, populates the entrypoint dependency graph with
403
+ * the build's dependency metadata for selective invalidation.
404
+ *
405
+ * @param entrypoints - Array of entrypoint paths and their output URLs
406
+ * @returns Array of output URLs that were successfully built
407
+ */
357
408
  async bundleReactEntrypoints(entrypoints) {
358
409
  try {
359
410
  const declaredModules = /* @__PURE__ */ new Set();
@@ -386,6 +437,12 @@ class ReactHmrStrategy extends HmrStrategy {
386
437
  appLogger.error(`Failed to build grouped React entrypoints:`, result.logs);
387
438
  return [];
388
439
  }
440
+ if (result.dependencyGraph?.entrypoints) {
441
+ const dependencyGraph = this.context.getEntrypointDependencyGraph();
442
+ for (const [entrypoint, deps] of Object.entries(result.dependencyGraph.entrypoints)) {
443
+ dependencyGraph.setEntrypointDependencies(entrypoint, deps);
444
+ }
445
+ }
389
446
  const updatedOutputs = [];
390
447
  for (const { entrypointPath, outputUrl } of entrypoints) {
391
448
  const { outputPath } = this.getEntrypointOutput(entrypointPath);
@@ -469,7 +526,6 @@ class ReactHmrStrategy extends HmrStrategy {
469
526
  }
470
527
  try {
471
528
  let code = await fileSystem.readFile(tempPath);
472
- code = rewriteRuntimeSpecifierAliases(code, this.runtimeAliasMap);
473
529
  code = this.rewriteChunkImportUrls(code);
474
530
  code = injectHmrHandler(code);
475
531
  await fileSystem.writeAsync(finalPath, code);
@@ -1,4 +1,5 @@
1
1
  import { IntegrationPlugin, type EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
2
+ import type { BrowserRuntimeManifest } from '@ecopages/core/build/browser-runtime-manifest';
2
3
  import type { HmrStrategy } from '@ecopages/core/hmr/hmr-strategy';
3
4
  import type React from 'react';
4
5
  import { ReactRenderer } from './react-renderer.js';
@@ -40,6 +41,7 @@ export declare class ReactPlugin extends IntegrationPlugin<React.ReactNode> {
40
41
  }): ReactRenderer;
41
42
  private ensureRuntimeDependencies;
42
43
  get plugins(): EcoBuildPlugin[];
44
+ get browserRuntimeManifest(): BrowserRuntimeManifest;
43
45
  /**
44
46
  * Ensures the optional React MDX loader exists before either config-time
45
47
  * manifest sealing or runtime setup needs it.
@@ -135,6 +135,10 @@ class ReactPlugin extends IntegrationPlugin {
135
135
  }
136
136
  return [];
137
137
  }
138
+ get browserRuntimeManifest() {
139
+ this.ensureRuntimeDependencies();
140
+ return this.runtimeBundleService.getRuntimeManifest();
141
+ }
138
142
  /**
139
143
  * Ensures the optional React MDX loader exists before either config-time
140
144
  * manifest sealing or runtime setup needs it.
@@ -179,7 +183,7 @@ class ReactPlugin extends IntegrationPlugin {
179
183
  return new ReactHmrStrategy({
180
184
  context,
181
185
  pageMetadataCache: this.hmrPageMetadataCache,
182
- runtimeAliasMap: new Map(Object.entries(this.runtimeBundleService.getRuntimeAliasMap("development"))),
186
+ runtimeManifest: this.runtimeBundleService.getRuntimeManifest("development"),
183
187
  mdxCompilerOptions: this.mdxCompilerOptions,
184
188
  ownedTemplateExtensions: this.extensions,
185
189
  allTemplateExtensions: this.appConfig.templatesExt,
@@ -0,0 +1,3 @@
1
+ type Subscribe = (onStoreChange: () => void) => () => void;
2
+ export declare function useSyncExternalStoreWithSelector<Snapshot, Selection>(subscribe: Subscribe, getSnapshot: () => Snapshot, getServerSnapshot: (() => Snapshot) | undefined, selector: (snapshot: Snapshot) => Selection, isEqual?: (left: Selection, right: Selection) => boolean): Selection;
3
+ export {};
@@ -0,0 +1,56 @@
1
+ import { useDebugValue, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
+ const objectIs = Object.is;
3
+ function useSyncExternalStoreWithSelector(subscribe, getSnapshot, getServerSnapshot, selector, isEqual) {
4
+ const instRef = useRef({ hasValue: false, value: void 0 });
5
+ const memoizedSelectionRef = useMemo(() => {
6
+ let hasMemo = false;
7
+ let memoizedSnapshot;
8
+ let memoizedSelection;
9
+ const maybeGetServerSnapshot = getServerSnapshot === void 0 ? null : getServerSnapshot;
10
+ const memoizedSelector = (nextSnapshot) => {
11
+ if (!hasMemo) {
12
+ hasMemo = true;
13
+ memoizedSnapshot = nextSnapshot;
14
+ const nextSelection2 = selector(nextSnapshot);
15
+ const inst = instRef.current;
16
+ if (isEqual !== void 0 && inst.hasValue) {
17
+ const currentSelection2 = inst.value;
18
+ if (isEqual(currentSelection2, nextSelection2)) {
19
+ memoizedSelection = currentSelection2;
20
+ return currentSelection2;
21
+ }
22
+ }
23
+ memoizedSelection = nextSelection2;
24
+ return nextSelection2;
25
+ }
26
+ const currentSelection = memoizedSelection;
27
+ if (objectIs(memoizedSnapshot, nextSnapshot)) {
28
+ return currentSelection;
29
+ }
30
+ const nextSelection = selector(nextSnapshot);
31
+ if (isEqual !== void 0 && isEqual(currentSelection, nextSelection)) {
32
+ memoizedSnapshot = nextSnapshot;
33
+ return currentSelection;
34
+ }
35
+ memoizedSnapshot = nextSnapshot;
36
+ memoizedSelection = nextSelection;
37
+ return nextSelection;
38
+ };
39
+ return [
40
+ () => memoizedSelector(getSnapshot()),
41
+ maybeGetServerSnapshot === null ? void 0 : () => memoizedSelector(maybeGetServerSnapshot())
42
+ ];
43
+ }, [getSnapshot, getServerSnapshot, selector, isEqual]);
44
+ const value = useSyncExternalStore(subscribe, memoizedSelectionRef[0], memoizedSelectionRef[1]);
45
+ useEffect(() => {
46
+ instRef.current = {
47
+ hasValue: true,
48
+ value
49
+ };
50
+ }, [value]);
51
+ useDebugValue(value);
52
+ return value;
53
+ }
54
+ export {
55
+ useSyncExternalStoreWithSelector
56
+ };
@@ -47,15 +47,14 @@ export declare class ReactBundleService {
47
47
  /**
48
48
  * Creates esbuild bundle options for a page or component entry.
49
49
  *
50
+ * @remarks
51
+ * React derives runtime specifier mappings from the core browser runtime manifest
52
+ * so ESM imports resolve to concrete runtime asset URLs during module loading.
53
+ *
50
54
  * @param componentName - Generated unique component name for output naming
51
55
  * @param isMdx - Whether the source file is an MDX file
52
56
  * @param declaredModules - Explicitly declared browser module specifiers
53
57
  * @returns Bundle options object for the build adapter
54
58
  */
55
59
  createBundleOptions(componentName: string, isMdx: boolean, declaredModules: string[], bundleOptions?: ReactClientBundleOptions): Promise<Record<string, unknown>>;
56
- /**
57
- * Creates the esbuild plugin that rewrites bare React specifiers
58
- * to their runtime asset URLs.
59
- */
60
- createRuntimeAliasPlugin(runtimeAliasMap: Record<string, string>): import("@ecopages/core/build/build-types").EcoBuildPlugin | null;
61
60
  }
@@ -1,11 +1,9 @@
1
1
  import { createClientGraphBoundaryPlugin } from "../utils/client-graph-boundary-plugin.js";
2
2
  import {
3
- buildReactRuntimeAliasMap,
4
3
  getReactClientGraphAllowSpecifiers,
5
4
  getReactRuntimeExternalSpecifiers
6
5
  } from "../utils/react-runtime-alias-map.js";
7
- import { createUseSyncExternalStoreShimPlugin } from "../utils/use-sync-external-store-shim-plugin.js";
8
- import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
6
+ import { createBrowserRuntimeImportRewritePlugin } from "@ecopages/core/build/browser-runtime-import-rewrite-plugin";
9
7
  import { createForeignJsxOverridePlugin } from "@ecopages/core/plugins/foreign-jsx-override-plugin";
10
8
  import { ReactRuntimeBundleService } from "./react-runtime-bundle.service.js";
11
9
  import { createReactMdxLoaderPlugin } from "../utils/react-mdx-loader-plugin.js";
@@ -28,6 +26,10 @@ class ReactBundleService {
28
26
  /**
29
27
  * Creates esbuild bundle options for a page or component entry.
30
28
  *
29
+ * @remarks
30
+ * React derives runtime specifier mappings from the core browser runtime manifest
31
+ * so ESM imports resolve to concrete runtime asset URLs during module loading.
32
+ *
31
33
  * @param componentName - Generated unique component name for output naming
32
34
  * @param isMdx - Whether the source file is an MDX file
33
35
  * @param declaredModules - Explicitly declared browser module specifiers
@@ -64,37 +66,20 @@ class ReactBundleService {
64
66
  hostJsxImportSource: this.config.jsxImportSource ?? "react",
65
67
  foreignExtensions: this.config.nonReactExtensions ?? []
66
68
  });
67
- const useSyncExternalStoreShimPlugin = createUseSyncExternalStoreShimPlugin({
68
- name: "react-renderer-use-sync-external-store-shim",
69
- namespace: "ecopages-react-renderer-shim"
69
+ const runtimeManifest = this.runtimeBundleService.getRuntimeManifest();
70
+ const runtimeRewritePlugin = createBrowserRuntimeImportRewritePlugin({
71
+ name: "react-renderer-runtime-import-rewrite",
72
+ manifest: runtimeManifest
70
73
  });
71
- const runtimePlugins = bundleOptions.includeRuntime ? [] : [this.createRuntimeAliasPlugin(buildReactRuntimeAliasMap(runtimeImports))];
74
+ const runtimePlugins = bundleOptions.includeRuntime ? [] : [runtimeRewritePlugin].filter((plugin) => plugin !== null);
72
75
  if (isMdx && this.config.mdxCompilerOptions) {
73
76
  const mdxPlugin = createReactMdxLoaderPlugin(this.config.mdxCompilerOptions);
74
- options.plugins = [
75
- foreignJsxOverridePlugin,
76
- graphBoundaryPlugin,
77
- ...runtimePlugins,
78
- mdxPlugin,
79
- useSyncExternalStoreShimPlugin
80
- ];
77
+ options.plugins = [foreignJsxOverridePlugin, graphBoundaryPlugin, ...runtimePlugins, mdxPlugin];
81
78
  } else {
82
- options.plugins = [
83
- foreignJsxOverridePlugin,
84
- graphBoundaryPlugin,
85
- ...runtimePlugins,
86
- useSyncExternalStoreShimPlugin
87
- ];
79
+ options.plugins = [foreignJsxOverridePlugin, graphBoundaryPlugin, ...runtimePlugins];
88
80
  }
89
81
  return options;
90
82
  }
91
- /**
92
- * Creates the esbuild plugin that rewrites bare React specifiers
93
- * to their runtime asset URLs.
94
- */
95
- createRuntimeAliasPlugin(runtimeAliasMap) {
96
- return createRuntimeSpecifierAliasPlugin(runtimeAliasMap, { name: "react-runtime-import-alias" });
97
- }
98
83
  }
99
84
  export {
100
85
  ReactBundleService
@@ -9,12 +9,14 @@
9
9
  import type { EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
10
10
  import { type AssetDefinition } from '@ecopages/core/services/asset-processing-service';
11
11
  import type { ReactRouterAdapter } from '../router-adapter.js';
12
+ import { type BrowserRuntimeManifest } from '@ecopages/core/build/browser-runtime-manifest';
12
13
  export type ReactRuntimeImports = {
13
14
  react: string;
14
15
  reactDomClient: string;
15
16
  reactJsxRuntime: string;
16
17
  reactJsxDevRuntime: string;
17
18
  reactDom: string;
19
+ useSyncExternalStoreWithSelector: string;
18
20
  router?: string;
19
21
  };
20
22
  export interface ReactRuntimeBundleServiceConfig {
@@ -32,8 +34,11 @@ export declare class ReactRuntimeBundleService {
32
34
  private getReactVendorFileName;
33
35
  private getReactDomVendorFileName;
34
36
  private getRouterVendorFileName;
37
+ private getUseSyncExternalStoreWithSelectorVendorFileName;
38
+ private createReactVendorImportRewritePlugin;
35
39
  getRuntimeImports(mode?: RuntimeMode): ReactRuntimeImports;
36
40
  getRuntimeAliasMap(mode?: RuntimeMode): Record<string, string>;
41
+ getRuntimeManifest(mode?: RuntimeMode): BrowserRuntimeManifest;
37
42
  getDependencies(): AssetDefinition[];
38
43
  createRuntimeAliasPlugin(mode?: RuntimeMode): EcoBuildPlugin;
39
44
  }
@@ -1,3 +1,7 @@
1
+ import {
2
+ createBrowserRuntimeImportRewritePlugin,
3
+ DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME
4
+ } from "@ecopages/core/build/browser-runtime-import-rewrite-plugin";
1
5
  import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
2
6
  import {
3
7
  buildBrowserRuntimeAssetUrl,
@@ -5,7 +9,10 @@ import {
5
9
  createBrowserRuntimeScriptAsset
6
10
  } from "@ecopages/core/services/asset-processing-service";
7
11
  import { createReactDomRuntimeInteropPlugin } from "../utils/react-dom-runtime-interop-plugin.js";
8
- import { buildReactRuntimeAliasMap } from "../utils/react-runtime-alias-map.js";
12
+ import { buildReactRuntimeAliasMap, buildReactRuntimeManifest } from "../utils/react-runtime-alias-map.js";
13
+ import {
14
+ createBrowserRuntimeManifest
15
+ } from "@ecopages/core/build/browser-runtime-manifest";
9
16
  class ReactRuntimeBundleService {
10
17
  config;
11
18
  constructor(config) {
@@ -39,6 +46,22 @@ class ReactRuntimeBundleService {
39
46
  }
40
47
  return mode === "development" ? `${this.config.routerAdapter.bundle.outputName}.development.js` : `${this.config.routerAdapter.bundle.outputName}.js`;
41
48
  }
49
+ getUseSyncExternalStoreWithSelectorVendorFileName(mode) {
50
+ return mode === "development" ? "use-sync-external-store-with-selector.development.js" : "use-sync-external-store-with-selector.js";
51
+ }
52
+ createReactVendorImportRewritePlugin(mode) {
53
+ return createBrowserRuntimeImportRewritePlugin({
54
+ name: `react-plugin-vendor-runtime-import-rewrite-${mode}`,
55
+ manifest: createBrowserRuntimeManifest([
56
+ {
57
+ specifier: "react",
58
+ owner: "@ecopages/react",
59
+ importPath: "react",
60
+ publicPath: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode))
61
+ }
62
+ ])
63
+ });
64
+ }
42
65
  getRuntimeImports(mode = this.getCurrentRuntimeMode()) {
43
66
  const reactVendorFileName = this.getReactVendorFileName(mode);
44
67
  const reactDomVendorFileName = this.getReactDomVendorFileName(mode);
@@ -47,7 +70,10 @@ class ReactRuntimeBundleService {
47
70
  reactDomClient: buildBrowserRuntimeAssetUrl(reactDomVendorFileName),
48
71
  reactJsxRuntime: buildBrowserRuntimeAssetUrl(reactVendorFileName),
49
72
  reactJsxDevRuntime: buildBrowserRuntimeAssetUrl(reactVendorFileName),
50
- reactDom: buildBrowserRuntimeAssetUrl(reactDomVendorFileName)
73
+ reactDom: buildBrowserRuntimeAssetUrl(reactDomVendorFileName),
74
+ useSyncExternalStoreWithSelector: buildBrowserRuntimeAssetUrl(
75
+ this.getUseSyncExternalStoreWithSelectorVendorFileName(mode)
76
+ )
51
77
  };
52
78
  if (this.config.routerAdapter) {
53
79
  runtimeImports.router = buildBrowserRuntimeAssetUrl(this.getRouterVendorFileName(mode));
@@ -57,10 +83,16 @@ class ReactRuntimeBundleService {
57
83
  getRuntimeAliasMap(mode = this.getCurrentRuntimeMode()) {
58
84
  return buildReactRuntimeAliasMap(this.getRuntimeImports(mode));
59
85
  }
86
+ getRuntimeManifest(mode = this.getCurrentRuntimeMode()) {
87
+ return buildReactRuntimeManifest(this.getRuntimeImports(mode));
88
+ }
60
89
  getDependencies() {
61
- const reactDomRuntimeInteropPlugin = createReactDomRuntimeInteropPlugin();
62
90
  const dependencies = [];
63
91
  for (const mode of ["production", "development"]) {
92
+ const reactVendorImportRewritePlugin = this.createReactVendorImportRewritePlugin(mode);
93
+ const reactDomRuntimeInteropPlugin = createReactDomRuntimeInteropPlugin({
94
+ reactSpecifier: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode))
95
+ });
64
96
  const reactRuntimeAliasPlugin = createRuntimeSpecifierAliasPlugin(
65
97
  {
66
98
  react: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode))
@@ -84,7 +116,8 @@ class ReactRuntimeBundleService {
84
116
  cacheDirName: `ecopages-react-runtime-${mode}`,
85
117
  rootDir: this.config.rootDir,
86
118
  bundleOptions: {
87
- define: this.createRuntimeDefines(mode)
119
+ define: this.createRuntimeDefines(mode),
120
+ excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME]
88
121
  }
89
122
  }),
90
123
  createBrowserRuntimeModuleAsset({
@@ -95,8 +128,19 @@ class ReactRuntimeBundleService {
95
128
  rootDir: this.config.rootDir,
96
129
  bundleOptions: {
97
130
  define: this.createRuntimeDefines(mode),
131
+ excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME],
98
132
  plugins: reactDomBundlePlugins
99
133
  }
134
+ }),
135
+ createBrowserRuntimeScriptAsset({
136
+ importPath: "@ecopages/react/runtime/use-sync-external-store-with-selector",
137
+ name: "use-sync-external-store-with-selector",
138
+ fileName: this.getUseSyncExternalStoreWithSelectorVendorFileName(mode),
139
+ bundleOptions: {
140
+ define: this.createRuntimeDefines(mode),
141
+ excludeAppBuildPlugins: [DEFAULT_BROWSER_RUNTIME_IMPORT_REWRITE_PLUGIN_NAME],
142
+ plugins: [reactVendorImportRewritePlugin]
143
+ }
100
144
  })
101
145
  );
102
146
  if (this.config.routerAdapter) {
@@ -1,5 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ function escapeRegExp(value) {
4
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
3
6
  function createReactDomRuntimeInteropPlugin(options) {
4
7
  const reactDomFileFilter = /[\\/]react-dom[\\/].*\.js$/;
5
8
  const reactRequirePattern = /\brequire\((['"])react\1\)/g;
@@ -7,6 +10,12 @@ function createReactDomRuntimeInteropPlugin(options) {
7
10
  return {
8
11
  name: options?.name ?? "react-dom-runtime-interop",
9
12
  setup(build) {
13
+ if (reactSpecifier.startsWith("/")) {
14
+ build.onResolve({ filter: new RegExp(`^${escapeRegExp(reactSpecifier)}$`) }, (args) => ({
15
+ path: args.path,
16
+ external: true
17
+ }));
18
+ }
10
19
  build.onLoad({ filter: reactDomFileFilter }, (args) => {
11
20
  const content = fs.readFileSync(args.path, "utf-8");
12
21
  if (!reactRequirePattern.test(content)) {
@@ -1,6 +1,8 @@
1
1
  import type { ReactRouterAdapter } from '../router-adapter.js';
2
2
  import type { ReactRuntimeImports } from '../services/react-runtime-bundle.service.js';
3
+ import { type BrowserRuntimeManifest } from '@ecopages/core/build/browser-runtime-manifest';
3
4
  export declare const REACT_RUNTIME_SPECIFIERS: readonly ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom/client"];
4
5
  export declare function buildReactRuntimeAliasMap(runtimeImports: ReactRuntimeImports): Record<string, string>;
6
+ export declare function buildReactRuntimeManifest(runtimeImports: ReactRuntimeImports): BrowserRuntimeManifest;
5
7
  export declare function getReactRuntimeExternalSpecifiers(): string[];
6
8
  export declare function getReactClientGraphAllowSpecifiers(runtimeSpecifiers: Iterable<string>, routerAdapter?: ReactRouterAdapter): string[];
@@ -1,3 +1,7 @@
1
+ import {
2
+ createBrowserRuntimeManifest,
3
+ getBrowserRuntimeSpecifierMap
4
+ } from "@ecopages/core/build/browser-runtime-manifest";
1
5
  const REACT_RUNTIME_SPECIFIERS = [
2
6
  "react",
3
7
  "react-dom",
@@ -6,13 +10,65 @@ const REACT_RUNTIME_SPECIFIERS = [
6
10
  "react-dom/client"
7
11
  ];
8
12
  function buildReactRuntimeAliasMap(runtimeImports) {
9
- return {
10
- react: runtimeImports.react,
11
- "react/jsx-runtime": runtimeImports.reactJsxRuntime,
12
- "react/jsx-dev-runtime": runtimeImports.reactJsxDevRuntime,
13
- "react-dom": runtimeImports.reactDom,
14
- "react-dom/client": runtimeImports.reactDomClient
15
- };
13
+ return Object.fromEntries(getBrowserRuntimeSpecifierMap(buildReactRuntimeManifest(runtimeImports)));
14
+ }
15
+ function buildReactRuntimeManifest(runtimeImports) {
16
+ return createBrowserRuntimeManifest([
17
+ {
18
+ specifier: "react",
19
+ owner: "@ecopages/react",
20
+ importPath: "react",
21
+ publicPath: runtimeImports.react
22
+ },
23
+ {
24
+ specifier: "react/jsx-runtime",
25
+ owner: "@ecopages/react",
26
+ importPath: "react/jsx-runtime",
27
+ publicPath: runtimeImports.reactJsxRuntime
28
+ },
29
+ {
30
+ specifier: "react/jsx-dev-runtime",
31
+ owner: "@ecopages/react",
32
+ importPath: "react/jsx-dev-runtime",
33
+ publicPath: runtimeImports.reactJsxDevRuntime
34
+ },
35
+ {
36
+ specifier: "react-dom",
37
+ owner: "@ecopages/react",
38
+ importPath: "react-dom",
39
+ publicPath: runtimeImports.reactDom
40
+ },
41
+ {
42
+ specifier: "react-dom/client",
43
+ owner: "@ecopages/react",
44
+ importPath: "react-dom/client",
45
+ publicPath: runtimeImports.reactDomClient
46
+ },
47
+ {
48
+ specifier: "use-sync-external-store/shim",
49
+ owner: "@ecopages/react",
50
+ importPath: "use-sync-external-store/shim",
51
+ publicPath: runtimeImports.react
52
+ },
53
+ {
54
+ specifier: "use-sync-external-store/shim/index.js",
55
+ owner: "@ecopages/react",
56
+ importPath: "use-sync-external-store/shim/index.js",
57
+ publicPath: runtimeImports.react
58
+ },
59
+ {
60
+ specifier: "use-sync-external-store/shim/with-selector",
61
+ owner: "@ecopages/react",
62
+ importPath: "use-sync-external-store/shim/with-selector",
63
+ publicPath: runtimeImports.useSyncExternalStoreWithSelector
64
+ },
65
+ {
66
+ specifier: "use-sync-external-store/shim/with-selector.js",
67
+ owner: "@ecopages/react",
68
+ importPath: "use-sync-external-store/shim/with-selector.js",
69
+ publicPath: runtimeImports.useSyncExternalStoreWithSelector
70
+ }
71
+ ]);
16
72
  }
17
73
  function getReactRuntimeExternalSpecifiers() {
18
74
  return [...REACT_RUNTIME_SPECIFIERS];
@@ -28,6 +84,7 @@ function getReactClientGraphAllowSpecifiers(runtimeSpecifiers, routerAdapter) {
28
84
  export {
29
85
  REACT_RUNTIME_SPECIFIERS,
30
86
  buildReactRuntimeAliasMap,
87
+ buildReactRuntimeManifest,
31
88
  getReactClientGraphAllowSpecifiers,
32
89
  getReactRuntimeExternalSpecifiers
33
90
  };
@@ -1,5 +0,0 @@
1
- import type { EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
2
- export declare function createUseSyncExternalStoreShimPlugin(options?: {
3
- name?: string;
4
- namespace?: string;
5
- }): EcoBuildPlugin;
@@ -1,41 +0,0 @@
1
- function createUseSyncExternalStoreShimPlugin(options) {
2
- const namespace = options?.namespace ?? "ecopages-react-use-sync-external-store-shim";
3
- return {
4
- name: options?.name ?? "react-use-sync-external-store-shim",
5
- setup(build) {
6
- build.onResolve({ filter: /^use-sync-external-store\/shim(?:\/index\.js)?$/ }, () => ({
7
- path: "use-sync-external-store/shim",
8
- namespace
9
- }));
10
- build.onLoad({ filter: /^use-sync-external-store\/shim$/, namespace }, () => ({
11
- contents: "export { useSyncExternalStore } from 'react';",
12
- loader: "js"
13
- }));
14
- build.onLoad({ filter: /[\\/]use-sync-external-store[\\/]shim[\\/]index\.js$/ }, () => ({
15
- contents: "export { useSyncExternalStore } from 'react';",
16
- loader: "js"
17
- }));
18
- build.onLoad(
19
- {
20
- filter: /[\\/]use-sync-external-store[\\/]cjs[\\/]use-sync-external-store-shim\.development\.js$/
21
- },
22
- () => ({
23
- contents: "export { useSyncExternalStore } from 'react';",
24
- loader: "js"
25
- })
26
- );
27
- build.onLoad(
28
- {
29
- filter: /[\\/]use-sync-external-store[\\/]cjs[\\/]use-sync-external-store-shim\.production\.js$/
30
- },
31
- () => ({
32
- contents: "export { useSyncExternalStore } from 'react';",
33
- loader: "js"
34
- })
35
- );
36
- }
37
- };
38
- }
39
- export {
40
- createUseSyncExternalStoreShimPlugin
41
- };