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

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.
Files changed (68) hide show
  1. package/README.md +152 -29
  2. package/package.json +16 -12
  3. package/src/eco-embed.d.ts +11 -0
  4. package/src/eco-embed.js +11 -0
  5. package/src/react-hmr-strategy.d.ts +60 -43
  6. package/src/react-hmr-strategy.js +297 -144
  7. package/src/react-renderer.d.ts +169 -42
  8. package/src/react-renderer.js +484 -164
  9. package/src/react.constants.d.ts +1 -0
  10. package/src/react.constants.js +4 -0
  11. package/src/react.plugin.d.ts +38 -111
  12. package/src/react.plugin.js +132 -61
  13. package/src/react.types.d.ts +88 -0
  14. package/src/react.types.js +0 -0
  15. package/src/router-adapter.d.ts +7 -14
  16. package/src/services/react-bundle.service.d.ts +19 -31
  17. package/src/services/react-bundle.service.js +51 -100
  18. package/src/services/react-hmr-page-metadata-cache.d.ts +9 -0
  19. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  20. package/src/services/react-hydration-asset.service.d.ts +28 -19
  21. package/src/services/react-hydration-asset.service.js +85 -66
  22. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  23. package/src/services/react-mdx-config-dependency.service.js +122 -0
  24. package/src/services/react-page-module.service.d.ts +10 -2
  25. package/src/services/react-page-module.service.js +47 -39
  26. package/src/services/react-page-payload.service.d.ts +46 -0
  27. package/src/services/react-page-payload.service.js +67 -0
  28. package/src/services/react-runtime-bundle.service.d.ts +15 -13
  29. package/src/services/react-runtime-bundle.service.js +103 -180
  30. package/src/utils/client-graph-boundary-plugin.d.ts +1 -1
  31. package/src/utils/client-graph-boundary-plugin.js +80 -3
  32. package/src/utils/component-config-traversal.d.ts +36 -0
  33. package/src/utils/component-config-traversal.js +54 -0
  34. package/src/utils/declared-modules.d.ts +1 -1
  35. package/src/utils/declared-modules.js +7 -16
  36. package/src/utils/dynamic.test.browser.d.ts +1 -0
  37. package/src/utils/dynamic.test.browser.js +33 -0
  38. package/src/utils/hydration-scripts.d.ts +27 -6
  39. package/src/utils/hydration-scripts.js +177 -44
  40. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  41. package/src/utils/hydration-scripts.test.browser.js +198 -0
  42. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  43. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  44. package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
  45. package/src/utils/react-mdx-loader-plugin.js +13 -5
  46. package/src/utils/react-runtime-alias-map.d.ts +6 -0
  47. package/src/utils/react-runtime-alias-map.js +33 -0
  48. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  49. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  50. package/CHANGELOG.md +0 -67
  51. package/src/react-hmr-strategy.ts +0 -455
  52. package/src/react-renderer.ts +0 -403
  53. package/src/react.plugin.ts +0 -241
  54. package/src/router-adapter.ts +0 -95
  55. package/src/services/react-bundle.service.ts +0 -217
  56. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  57. package/src/services/react-hydration-asset.service.ts +0 -260
  58. package/src/services/react-page-module.service.ts +0 -214
  59. package/src/services/react-runtime-bundle.service.ts +0 -271
  60. package/src/utils/client-graph-boundary-plugin.ts +0 -710
  61. package/src/utils/client-only.ts +0 -27
  62. package/src/utils/declared-modules.ts +0 -99
  63. package/src/utils/dynamic.ts +0 -27
  64. package/src/utils/hmr-scripts.ts +0 -47
  65. package/src/utils/html-boundary.ts +0 -66
  66. package/src/utils/hydration-scripts.ts +0 -338
  67. package/src/utils/reachability-analyzer.ts +0 -593
  68. package/src/utils/react-mdx-loader-plugin.ts +0 -40
@@ -1,3 +1,3 @@
1
- import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
1
+ import type { EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
2
2
  import { type CompileOptions } from '@mdx-js/mdx';
3
3
  export declare function createReactMdxLoaderPlugin(compilerOptions?: CompileOptions): EcoBuildPlugin;
@@ -1,11 +1,18 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { compile } from "@mdx-js/mdx";
4
- import { SourceMapGenerator } from "source-map";
4
+ import sourceMap from "source-map";
5
5
  import { VFile } from "vfile";
6
+ function resolveCompileFormat(filePath, compilerOptions) {
7
+ const configuredFormat = compilerOptions?.format;
8
+ if (configuredFormat && configuredFormat !== "detect") {
9
+ return configuredFormat;
10
+ }
11
+ return path.extname(filePath).toLowerCase() === ".md" ? "mdx" : configuredFormat;
12
+ }
6
13
  function createReactMdxLoaderPlugin(compilerOptions) {
7
14
  const mdxExtensions = compilerOptions?.mdxExtensions ?? [".mdx"];
8
- const mdExtensions = compilerOptions?.mdExtensions ?? [".md"];
15
+ const mdExtensions = compilerOptions?.mdExtensions ?? [];
9
16
  const allExtensions = [...mdxExtensions, ...mdExtensions];
10
17
  const escapedExts = allExtensions.map((ext) => ext.replace(".", "\\."));
11
18
  const filter = new RegExp(`(${escapedExts.join("|")})(\\?.*)?$`);
@@ -18,13 +25,14 @@ function createReactMdxLoaderPlugin(compilerOptions) {
18
25
  const file = new VFile({ path: filePath, value: source });
19
26
  const compiled = await compile(file, {
20
27
  ...compilerOptions,
21
- SourceMapGenerator
28
+ format: resolveCompileFormat(filePath, compilerOptions),
29
+ SourceMapGenerator: sourceMap.SourceMapGenerator
22
30
  });
23
- const sourceMap = compiled.map ? `
31
+ const inlineSourceMap = compiled.map ? `
24
32
  //# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(compiled.map)).toString("base64")}
25
33
  ` : "";
26
34
  return {
27
- contents: `${String(compiled.value)}${sourceMap}`,
35
+ contents: `${String(compiled.value)}${inlineSourceMap}`,
28
36
  loader: compilerOptions?.jsx ? "jsx" : "js",
29
37
  resolveDir: path.dirname(args.path)
30
38
  };
@@ -0,0 +1,6 @@
1
+ import type { ReactRouterAdapter } from '../router-adapter.js';
2
+ import type { ReactRuntimeImports } from '../services/react-runtime-bundle.service.js';
3
+ export declare const REACT_RUNTIME_SPECIFIERS: readonly ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom/client"];
4
+ export declare function buildReactRuntimeAliasMap(runtimeImports: ReactRuntimeImports): Record<string, string>;
5
+ export declare function getReactRuntimeExternalSpecifiers(): string[];
6
+ export declare function getReactClientGraphAllowSpecifiers(runtimeSpecifiers: Iterable<string>, routerAdapter?: ReactRouterAdapter): string[];
@@ -0,0 +1,33 @@
1
+ const REACT_RUNTIME_SPECIFIERS = [
2
+ "react",
3
+ "react-dom",
4
+ "react/jsx-runtime",
5
+ "react/jsx-dev-runtime",
6
+ "react-dom/client"
7
+ ];
8
+ 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
+ };
16
+ }
17
+ function getReactRuntimeExternalSpecifiers() {
18
+ return [...REACT_RUNTIME_SPECIFIERS];
19
+ }
20
+ function getReactClientGraphAllowSpecifiers(runtimeSpecifiers, routerAdapter) {
21
+ return [
22
+ "@ecopages/core",
23
+ ...REACT_RUNTIME_SPECIFIERS,
24
+ ...routerAdapter ? [routerAdapter.bundle.importPath] : [],
25
+ ...Array.from(runtimeSpecifiers)
26
+ ];
27
+ }
28
+ export {
29
+ REACT_RUNTIME_SPECIFIERS,
30
+ buildReactRuntimeAliasMap,
31
+ getReactClientGraphAllowSpecifiers,
32
+ getReactRuntimeExternalSpecifiers
33
+ };
@@ -0,0 +1,5 @@
1
+ import type { EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
2
+ export declare function createUseSyncExternalStoreShimPlugin(options?: {
3
+ name?: string;
4
+ namespace?: string;
5
+ }): EcoBuildPlugin;
@@ -0,0 +1,41 @@
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
+ };
package/CHANGELOG.md DELETED
@@ -1,67 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `@ecopages/react` are documented here.
4
-
5
- > **Note:** Changelog tracking begins at version `0.2.0`. Changes prior to this release are not recorded here but are available in the git history.
6
-
7
- ## [UNRELEASED] — TBD
8
-
9
- ### Features
10
-
11
- #### Render Reachability Analysis
12
-
13
- - **Client render graph (Phase 1)** — Introduces a static reachability analysis step that builds an explicit graph of which components are rendered client-side (`cdfbd69e`).
14
- - **OXC-powered reachability analyzer** — `reachability-analyzer.ts` uses OXC to parse and walk component ASTs, building a `ClientRenderGraph` that maps exported components to their client-side reach (`5412df6b`).
15
- - **Explicit client graph boundaries** — Components must now declare explicit boundaries; the analyser enforces these to prevent over-hydration (`2912d6bd`).
16
- - **Declared modules utility** — `declared-modules.ts` tracks which modules are declared as client boundaries.
17
-
18
- #### Service Architecture Refactor
19
-
20
- - **`ReactRuntimeBundleService`** — Manages runtime assets and specifier mapping for the React integration (`cfd3cb05`).
21
- - **`ReactHydrationAssetService`** — Creates and manages hydration assets for client-side rendering (`cfd3cb05`).
22
- - **`ReactBundleService`** — Handles esbuild bundle configuration for React components (`cfd3cb05`).
23
- - **`ReactPageModuleService`** — Loads and compiles MDX/TSX page modules, including config resolution (`cfd3cb05`).
24
- - The integration no longer builds a monolithic renderer — each concern is handled by a focused service.
25
-
26
- #### HMR Improvements
27
-
28
- - **HMR page metadata caching** — Page metadata is now cached between HMR refreshes, preventing unnecessary re-fetches during Fast Refresh (`a663788c`).
29
- - **Stale temp module race fix** — HMR no longer incorrectly reads a stale temporary module during rapid refresh cycles (`b2cf8466`).
30
- - **Client graph HMR stability** — HMR reloads now correctly respect client graph boundaries to avoid partial hydration mismatches (`2912d6bd`).
31
-
32
- #### HTML Boundary Utilities
33
-
34
- - **`html-boundary.ts`** — New utility that wraps rendered output in explicit boundary markers for the cross-integration boundary rendering policy (`ec1e4d66`).
35
- - **`hydration-scripts.ts`** — Expanded with new helpers for generating and injecting hydration entry scripts.
36
-
37
- ### Refactoring
38
-
39
- - Aligned React renderer to full orchestration mode — removed legacy rendering path (`fc07bdb0`).
40
- - Ambient module declarations cleaned up (`5f46ecc5`).
41
- - Client graph boundaries and runtime dependency wiring corrected (`4b6cd32e`).
42
- - Updated test suite for esbuild adapter and Node.js runtime compatibility (`31a44458`).
43
-
44
- ### Bug Fixes
45
-
46
- - Inlined the React MDX loader so React apps no longer need to install `@ecopages/mdx` when enabling React MDX support (`unreleased`).
47
- - Fixed stale temp module race during Fast Refresh cycles (`b2cf8466`).
48
- - Fixed client graph boundary wiring for runtime dependencies (`4b6cd32e`).
49
- - Fixed shared React barrel handling so client-reachable server-only re-exports now fail the build instead of being silently pruned (`unreleased`).
50
-
51
- ### Documentation
52
-
53
- - Documented the React integration server/client graph contract for shared modules and barrel exports (`unreleased`).
54
-
55
- ### Tests
56
-
57
- - Added `html-boundary.test.ts` covering boundary wrapping utilities.
58
- - Added `hydration-scripts.test.ts` with hydration script generation coverage.
59
- - Added `reachability-analyzer.test.ts` (187 lines) covering export declaration reachability.
60
- - Updated integration tests for esbuild adapter compatibility (`31a44458`).
61
-
62
- ---
63
-
64
- ## Migration Notes
65
-
66
- - The React integration now requires explicit client boundary declarations. Components that should be hydrated client-side must be marked at a boundary entry point.
67
- - The internal service layer (`ReactRuntimeBundleService`, `ReactBundleService`, etc.) is not part of the public API and may change between releases.
@@ -1,455 +0,0 @@
1
- /**
2
- * React HMR Strategy
3
- *
4
- * Handles hot module replacement for React components.
5
- * Triggers module invalidation on changes to ensure fresh component re-renders.
6
- *
7
- * @module
8
- */
9
-
10
- import path from 'node:path';
11
- import { pathToFileURL } from 'node:url';
12
-
13
- import { HmrStrategy, HmrStrategyType, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
14
- import { defaultBuildAdapter } from '@ecopages/core/build/build-adapter';
15
- import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
16
- import { FileNotFoundError, fileSystem } from '@ecopages/file-system';
17
- import { Logger } from '@ecopages/logger';
18
- import type { DefaultHmrContext, EcoComponentConfig } from '@ecopages/core';
19
- import type { CompileOptions } from '@mdx-js/mdx';
20
- import { injectHmrHandler } from './utils/hmr-scripts.ts';
21
- import { createClientGraphBoundaryPlugin } from './utils/client-graph-boundary-plugin.ts';
22
- import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from './utils/declared-modules.ts';
23
- import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metadata-cache.ts';
24
-
25
- const appLogger = new Logger('[ReactHmrStrategy]');
26
-
27
- /**
28
- * Strategy for handling React component HMR updates.
29
- *
30
- * This strategy provides React-specific HMR handling by rebuilding entrypoints
31
- * and injecting HMR acceptance handlers that trigger module invalidation.
32
- *
33
- * The processing steps are:
34
- * 1. Check if any React entrypoints are registered
35
- * 2. Rebuild all React entrypoints (the changed file could be a dependency)
36
- * 3. Replace bare specifiers with runtime URLs
37
- * 4. Inject HMR acceptance handler
38
- * 5. Broadcast update events for each rebuilt entrypoint
39
- *
40
- * @remarks
41
- * This strategy has higher priority than generic JsHmrStrategy, allowing it
42
- * to handle React files specially while falling back to generic handling for
43
- * non-React files.
44
- *
45
- * Future enhancement: Track dependencies using Bun's transpiler API to only
46
- * rebuild affected entrypoints instead of all of them.
47
- *
48
- * @see https://bun.sh/docs/runtime/transpiler
49
- *
50
- * @example
51
- * ```typescript
52
- * const context = {
53
- * getWatchedFiles: () => watchedFilesMap,
54
- * getSpecifierMap: () => specifierMap,
55
- * getDistDir: () => '/path/to/dist/_hmr',
56
- * getPlugins: () => [],
57
- * getSrcDir: () => '/path/to/src',
58
- * getLayoutsDir: () => '/path/to/src/layouts'
59
- * };
60
- * const strategy = new ReactHmrStrategy(context);
61
- * ```
62
- */
63
- export class ReactHmrStrategy extends HmrStrategy {
64
- readonly type = HmrStrategyType.INTEGRATION;
65
- private mdxCompilerOptions?: CompileOptions;
66
- private readonly knownEntrypoints = new Set<string>();
67
-
68
- private async importNodePageModule(entrypointPath: string): Promise<{
69
- default?: { config?: EcoComponentConfig };
70
- config?: EcoComponentConfig;
71
- }> {
72
- const srcDir = this.context.getSrcDir();
73
- const rootDir = path.dirname(srcDir);
74
- const outdir = path.join(path.resolve(this.context.getDistDir(), '..', '..'), '.server-modules');
75
- const fileBaseName = path.basename(entrypointPath, path.extname(entrypointPath));
76
- const fileHash = fileSystem.hash(entrypointPath);
77
- const outputFileName = `${fileBaseName}-${fileHash}.js`;
78
-
79
- const buildResult = await defaultBuildAdapter.build({
80
- entrypoints: [entrypointPath],
81
- root: rootDir,
82
- outdir,
83
- target: 'node',
84
- format: 'esm',
85
- sourcemap: 'none',
86
- splitting: false,
87
- minify: false,
88
- naming: outputFileName,
89
- });
90
-
91
- if (!buildResult.success) {
92
- const details = buildResult.logs.map((log) => log.message).join(' | ');
93
- throw new Error(`Error transpiling React HMR page module: ${details}`);
94
- }
95
-
96
- const preferredOutputPath = path.join(outdir, outputFileName);
97
- const compiledOutput =
98
- buildResult.outputs.find((output) => output.path === preferredOutputPath)?.path ??
99
- buildResult.outputs.find((output) => output.path.endsWith('.js'))?.path;
100
-
101
- if (!compiledOutput) {
102
- throw new Error(`No transpiled output generated for React HMR page module: ${entrypointPath}`);
103
- }
104
-
105
- return (await import(pathToFileURL(compiledOutput).href)) as {
106
- default?: { config?: EcoComponentConfig };
107
- config?: EcoComponentConfig;
108
- };
109
- }
110
-
111
- /**
112
- * Redirects `use-sync-external-store/shim` imports to React's built-in
113
- * `useSyncExternalStore`.
114
- *
115
- * Libraries like React Aria still list `use-sync-external-store` as a
116
- * dependency to support React 16/17. On React 18+ the `/shim` export is
117
- * already a pass-through, but without this plugin esbuild would bundle
118
- * the full CJS shim (including `process.env` branching) into the browser
119
- * bundle. The plugin short-circuits the resolution so only a single clean
120
- * ESM re-export is emitted.
121
- */
122
- private createUseSyncExternalStoreShimPlugin(): EcoBuildPlugin {
123
- return {
124
- name: 'react-hmr-use-sync-external-store-shim',
125
- setup(build) {
126
- build.onResolve({ filter: /^use-sync-external-store\/shim(?:\/index\.js)?$/ }, () => ({
127
- path: 'use-sync-external-store/shim',
128
- namespace: 'ecopages-react-hmr-shim',
129
- }));
130
-
131
- build.onLoad(
132
- { filter: /^use-sync-external-store\/shim$/, namespace: 'ecopages-react-hmr-shim' },
133
- () => ({
134
- contents: "export { useSyncExternalStore } from 'react';",
135
- loader: 'js',
136
- }),
137
- );
138
-
139
- build.onLoad({ filter: /[\\/]use-sync-external-store[\\/]shim[\\/]index\.js$/ }, () => ({
140
- contents: "export { useSyncExternalStore } from 'react';",
141
- loader: 'js',
142
- }));
143
-
144
- build.onLoad(
145
- {
146
- filter: /[\\/]use-sync-external-store[\\/]cjs[\\/]use-sync-external-store-shim\.development\.js$/,
147
- },
148
- () => ({
149
- contents: "export { useSyncExternalStore } from 'react';",
150
- loader: 'js',
151
- }),
152
- );
153
- },
154
- };
155
- }
156
-
157
- /**
158
- * Creates a new React HMR strategy instance.
159
- *
160
- * @param context - The HMR context providing access to watched files, plugins, build directories,
161
- * and the layouts directory for detecting layout file changes that require full
162
- * page reloads instead of module-level HMR updates.
163
- * @param pageMetadataCache - React-only cache of declared browser modules discovered during
164
- * server rendering. This avoids re-importing unchanged page modules
165
- * during save-time Fast Refresh rebuilds.
166
- * @param mdxCompilerOptions - Optional MDX compiler options for processing .mdx files
167
- * @param explicitGraphEnabled - Enables explicit graph mode for React HMR bundling.
168
- * In explicit mode, HMR builds omit AST server-only stripping plugins in React paths.
169
- */
170
- constructor(
171
- private context: DefaultHmrContext,
172
- private pageMetadataCache: ReactHmrPageMetadataCache,
173
- mdxCompilerOptions?: CompileOptions,
174
- private explicitGraphEnabled = false,
175
- ) {
176
- super();
177
- this.mdxCompilerOptions = mdxCompilerOptions;
178
- }
179
-
180
- /**
181
- * Returns build plugins for React HMR bundling.
182
- *
183
- * Includes the client graph boundary plugin to prevent undeclared imports
184
- * (including `node:*`) from breaking the browser bundle.
185
- */
186
- private getBuildPlugins(declaredModules?: string[]): EcoBuildPlugin[] {
187
- const allowSpecifiers = [
188
- '@ecopages/core',
189
- 'react',
190
- 'react-dom',
191
- 'react/jsx-runtime',
192
- 'react/jsx-dev-runtime',
193
- 'react-dom/client',
194
- ...Array.from(this.context.getSpecifierMap().keys()),
195
- ];
196
-
197
- return [
198
- createClientGraphBoundaryPlugin({
199
- absWorkingDir: path.dirname(this.context.getSrcDir()),
200
- alwaysAllowSpecifiers: allowSpecifiers,
201
- declaredModules,
202
- }),
203
- ...this.context.getPlugins(),
204
- this.createUseSyncExternalStoreShimPlugin(),
205
- ];
206
- }
207
-
208
- private isReactEntrypoint(filePath: string): boolean {
209
- if (filePath.endsWith('.tsx')) {
210
- return true;
211
- }
212
-
213
- return filePath.endsWith('.mdx') && this.mdxCompilerOptions !== undefined;
214
- }
215
-
216
- /**
217
- * Determines if the file is a React/MDX entrypoint that's registered for HMR.
218
- *
219
- * @param filePath - Absolute path to the changed file
220
- * @returns True if this is a registered React or MDX entrypoint
221
- */
222
- matches(filePath: string): boolean {
223
- const watchedFiles = this.context.getWatchedFiles();
224
- appLogger.debug(`Checking ${filePath}. Watched: ${watchedFiles.size}`);
225
- if (watchedFiles.size === 0) {
226
- return false;
227
- }
228
-
229
- return this.isReactEntrypoint(filePath);
230
- }
231
-
232
- /**
233
- * Checks if a file is a layout file.
234
- *
235
- * Layout files require special HMR handling because they wrap multiple pages and affect
236
- * the entire page structure. When a layout changes, we trigger a 'layout-update' event
237
- * instead of a regular 'update' event, which instructs the browser to perform a full
238
- * page reload (or clear cache and re-render) rather than attempting module-level HMR.
239
- *
240
- * @param filePath - Absolute path to the file
241
- * @returns True if the file is in the layouts directory
242
- */
243
- private isLayoutFile(filePath: string): boolean {
244
- return filePath.startsWith(this.context.getLayoutsDir());
245
- }
246
-
247
- /**
248
- * Processes a React file change by rebuilding all React entrypoints.
249
- *
250
- * For layout files, broadcasts a 'layout-update' event to trigger full page reload.
251
- * For regular components/pages, broadcasts 'update' events for module-level HMR.
252
- * When a page entrypoint is first registered, only that entrypoint is built.
253
- * Subsequent file updates rebuild all watched React entrypoints as usual.
254
- *
255
- * @param _filePath - Absolute path to the changed file
256
- * @returns Action to broadcast update events (layout-update for layouts, update for components)
257
- */
258
- async process(_filePath: string): Promise<HmrAction> {
259
- appLogger.debug(`Processing ${_filePath}`);
260
- const watchedFiles = this.context.getWatchedFiles();
261
-
262
- if (watchedFiles.size === 0) {
263
- appLogger.debug(`No watched files`);
264
- return { type: 'none' };
265
- }
266
-
267
- const isLayout = this.isLayoutFile(_filePath);
268
- if (isLayout) {
269
- appLogger.debug(`Detected layout file change: ${_filePath}`);
270
- }
271
-
272
- const entrypointsToBuild =
273
- !this.knownEntrypoints.has(_filePath) && watchedFiles.has(_filePath)
274
- ? [[_filePath, watchedFiles.get(_filePath)!]]
275
- : watchedFiles.entries();
276
- this.knownEntrypoints.add(_filePath);
277
-
278
- const updates: string[] = [];
279
- for (const [entrypoint, outputUrl] of entrypointsToBuild) {
280
- if (!this.isReactEntrypoint(entrypoint)) {
281
- continue;
282
- }
283
-
284
- appLogger.debug(`Bundling ${entrypoint}`);
285
- const success = await this.bundleReactEntrypoint(entrypoint, outputUrl);
286
- if (success) {
287
- updates.push(outputUrl);
288
- }
289
- }
290
-
291
- if (updates.length > 0) {
292
- if (isLayout) {
293
- appLogger.debug(`Layout update detected, sending layout-update event`);
294
- return {
295
- type: 'broadcast',
296
- events: [
297
- {
298
- type: 'layout-update',
299
- },
300
- ],
301
- };
302
- }
303
-
304
- appLogger.debug(`Broadcasting ${updates.length} updates`);
305
- return {
306
- type: 'broadcast',
307
- events: updates.map((path) => ({
308
- type: 'update',
309
- path,
310
- timestamp: Date.now(),
311
- })),
312
- };
313
- }
314
-
315
- appLogger.debug(`No updates generated`);
316
- return { type: 'none' };
317
- }
318
-
319
- /**
320
- * Bundles a single React/MDX entrypoint with HMR support.
321
- *
322
- * @param entrypointPath - Absolute path to the source file
323
- * @param outputUrl - URL path for the bundled file
324
- * @returns True if bundling was successful
325
- */
326
- private async bundleReactEntrypoint(entrypointPath: string, outputUrl: string): Promise<boolean> {
327
- try {
328
- const isMdx = entrypointPath.endsWith('.mdx');
329
- const srcDir = this.context.getSrcDir();
330
- const relativePath = path.relative(srcDir, entrypointPath);
331
- const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, '.js');
332
- const encodedPathJs = this.encodeDynamicSegments(relativePathJs);
333
- const outputPath = path.join(this.context.getDistDir(), encodedPathJs);
334
- const tempDir = path.dirname(outputPath);
335
-
336
- const declaredModules = this.pageMetadataCache.getDeclaredModules(entrypointPath)
337
- ? this.pageMetadataCache.getDeclaredModules(entrypointPath)!
338
- : isMdx
339
- ? await collectPageDeclaredModules(entrypointPath)
340
- : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
341
- const plugins = this.getBuildPlugins(declaredModules);
342
-
343
- if (isMdx && this.mdxCompilerOptions) {
344
- const { createReactMdxLoaderPlugin } = await import('./utils/react-mdx-loader-plugin.ts');
345
- const mdxPlugin = createReactMdxLoaderPlugin(this.mdxCompilerOptions);
346
- plugins.unshift(mdxPlugin);
347
- }
348
-
349
- const result = await defaultBuildAdapter.build({
350
- entrypoints: [entrypointPath],
351
- outdir: tempDir,
352
- naming: `[name].[hash].tmp`,
353
- target: 'browser',
354
- format: 'esm',
355
- sourcemap: 'none',
356
- plugins,
357
- minify: false,
358
- external: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom/client'],
359
- });
360
-
361
- if (!result.success) {
362
- appLogger.error(`Failed to build ${entrypointPath}:`, result.logs);
363
- return false;
364
- }
365
-
366
- const tempFile = result.outputs[0]?.path;
367
- if (!tempFile) {
368
- appLogger.error(`No output file generated for ${entrypointPath}`);
369
- return false;
370
- }
371
-
372
- const processed = await this.processOutput(tempFile, outputPath, outputUrl);
373
- return processed;
374
- } catch (error) {
375
- appLogger.error(`Error bundling ${entrypointPath}:`, error as Error);
376
- return false;
377
- }
378
- }
379
-
380
- /**
381
- * Encodes dynamic route segments (brackets) in file paths.
382
- * Converts `[slug]` to `_slug_` to avoid filesystem issues.
383
- */
384
- private encodeDynamicSegments(filepath: string): string {
385
- return filepath.replace(/\[([^\]]+)\]/g, '_$1_');
386
- }
387
-
388
- /**
389
- * Processes bundled output by replacing specifiers and injecting HMR handler.
390
- * Writes to temp file first, then renames atomically to avoid conflicts.
391
- *
392
- * @param tempPath - Path to the temporary bundled file
393
- * @param finalPath - Final destination path
394
- * @param url - URL path for logging
395
- * @returns True if processing was successful
396
- */
397
- private async processOutput(tempPath: string, finalPath: string, url: string): Promise<boolean> {
398
- if (!fileSystem.exists(tempPath)) {
399
- appLogger.debug(`Skipping stale temp output for ${url}: ${tempPath}`);
400
- return false;
401
- }
402
-
403
- try {
404
- let code = await fileSystem.readFile(tempPath);
405
-
406
- code = this.replaceBareSpecifiers(code);
407
- code = injectHmrHandler(code);
408
-
409
- await fileSystem.writeAsync(finalPath, code);
410
- await fileSystem.removeAsync(tempPath).catch(() => {});
411
-
412
- appLogger.debug(`Processed ${url} with HMR handler`);
413
- return true;
414
- } catch (error) {
415
- if (
416
- error instanceof FileNotFoundError ||
417
- (error instanceof Error && error.message.includes('not found')) ||
418
- (error instanceof Error && 'code' in error && error.code === 'ENOENT')
419
- ) {
420
- appLogger.debug(`Skipping stale temp output for ${url}: ${tempPath}`);
421
- await fileSystem.removeAsync(tempPath).catch(() => {});
422
- return false;
423
- }
424
-
425
- appLogger.error(`Error processing output for ${url}:`, error as Error);
426
- await fileSystem.removeAsync(tempPath).catch(() => {});
427
- return false;
428
- }
429
- }
430
-
431
- /**
432
- * Replaces bare specifiers with runtime URLs.
433
- *
434
- * Handles both static imports and dynamic imports.
435
- *
436
- * @param code - The bundled code to transform
437
- * @returns The transformed code with runtime URLs
438
- */
439
- private replaceBareSpecifiers(code: string): string {
440
- const specifierMap = this.context.getSpecifierMap();
441
-
442
- if (specifierMap.size === 0) {
443
- return code;
444
- }
445
-
446
- let result = code;
447
- for (const [bareSpec, runtimeUrl] of specifierMap.entries()) {
448
- const escaped = bareSpec.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
449
- result = result.replace(new RegExp(`from\\s*["']${escaped}["']`, 'g'), `from "${runtimeUrl}"`);
450
- result = result.replace(new RegExp(`import\\(["']${escaped}["']\\)`, 'g'), `import("${runtimeUrl}")`);
451
- }
452
-
453
- return result;
454
- }
455
- }