@ecopages/react 0.2.0-alpha.4 → 0.2.0-alpha.7
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/CHANGELOG.md +23 -37
- package/README.md +143 -17
- package/package.json +3 -3
- package/src/react-hmr-strategy.d.ts +22 -19
- package/src/react-hmr-strategy.js +57 -109
- package/src/react-hmr-strategy.ts +76 -134
- package/src/react-renderer.d.ts +130 -11
- package/src/react-renderer.js +368 -64
- package/src/react-renderer.ts +490 -90
- package/src/react.plugin.d.ts +17 -5
- package/src/react.plugin.js +44 -13
- package/src/react.plugin.ts +49 -14
- package/src/router-adapter.d.ts +2 -2
- package/src/router-adapter.ts +2 -2
- package/src/services/react-bundle.service.d.ts +2 -25
- package/src/services/react-bundle.service.js +21 -91
- package/src/services/react-bundle.service.ts +22 -126
- package/src/services/react-hydration-asset.service.js +3 -3
- package/src/services/react-hydration-asset.service.ts +7 -4
- package/src/services/react-page-module.service.d.ts +3 -0
- package/src/services/react-page-module.service.js +20 -16
- package/src/services/react-page-module.service.ts +27 -17
- package/src/services/react-runtime-bundle.service.d.ts +12 -12
- package/src/services/react-runtime-bundle.service.js +98 -180
- package/src/services/react-runtime-bundle.service.ts +112 -211
- package/src/utils/client-graph-boundary-plugin.js +147 -9
- package/src/utils/client-graph-boundary-plugin.ts +252 -11
- package/src/utils/hydration-scripts.d.ts +18 -1
- package/src/utils/hydration-scripts.js +83 -32
- package/src/utils/hydration-scripts.ts +159 -38
- package/src/utils/reachability-analyzer.d.ts +12 -1
- package/src/utils/reachability-analyzer.js +101 -5
- package/src/utils/reachability-analyzer.ts +161 -8
- package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
- package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
- package/src/utils/react-dom-runtime-interop-plugin.ts +33 -0
- package/src/utils/react-mdx-loader-plugin.js +13 -5
- package/src/utils/react-mdx-loader-plugin.ts +28 -5
- package/src/utils/react-runtime-specifier-map.d.ts +6 -0
- package/src/utils/react-runtime-specifier-map.js +37 -0
- package/src/utils/react-runtime-specifier-map.ts +45 -0
- package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
- package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
- package/src/utils/use-sync-external-store-shim-plugin.ts +45 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,55 +8,41 @@ All notable changes to `@ecopages/react` are documented here.
|
|
|
8
8
|
|
|
9
9
|
### Features
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- Added Phase 1 client render graph analysis to track client-reachable exports and enforce explicit React hydration boundaries.
|
|
12
|
+
- Split React browser and runtime work into focused services for runtime bundles, hydration assets, page modules, and browser bundling.
|
|
13
|
+
- Inlined the React MDX loader so React apps can enable MDX without installing `@ecopages/mdx` separately.
|
|
12
14
|
|
|
13
|
-
|
|
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
|
|
15
|
+
### Bug Fixes
|
|
33
16
|
|
|
34
|
-
-
|
|
35
|
-
-
|
|
17
|
+
- Fixed React island bootstrapping to replace SSR nodes with a block-level `eco-island` container and per-element `data-eco-props` payloads, preventing duplicate DOM and prop collisions.
|
|
18
|
+
- Fixed router-backed React pages to emit the canonical `__ECO_PAGE_DATA__` payload and explicit document owner markers so mixed React and non-React navigation and hydration stay aligned.
|
|
19
|
+
- Fixed React page hydration and handoff cleanup to use `document.body`, shared navigation coordination, preserved request locals, and stable root reuse across route handoffs.
|
|
20
|
+
- Fixed React MDX extension handling so `.md` stays opt-in and shared builds no longer let standalone MDX configuration hijack React `.mdx` routes.
|
|
21
|
+
- Fixed client graph boundary wiring so client-reachable server-only re-exports fail fast and page-entry bundles strip unreachable server-only `eco.page()` options.
|
|
22
|
+
- Moved React MDX page-module transpilation into the internal work directory so static exports no longer leak `.server-modules-react-mdx` into `distDir`.
|
|
23
|
+
- Fixed development React runtime vendor asset naming so concurrent preview/export builds no longer overwrite dev-only JSX runtime helpers such as `jsxDEV`.
|
|
24
|
+
- Fixed React MDX declared component dependencies to eagerly emit SSR-marked lazy custom-element scripts so mixed React and Lit pages keep declared custom elements interactive.
|
|
25
|
+
- Fixed mixed-integration HMR matching so React strategy now uses configured route-template extension ownership (including explicit overrides such as `.react.tsx`) instead of generic `.tsx` matching for all page/layout templates.
|
|
26
|
+
- Fixed React MDX layout metadata fallback so inferred `__eco.file` anchors to the owning layout/component directory instead of a dependency file path, preventing double-nested relative script resolution.
|
|
36
27
|
|
|
37
28
|
### Refactoring
|
|
38
29
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
- Client graph boundaries and runtime dependency wiring corrected (`4b6cd32e`).
|
|
42
|
-
- Updated test suite for esbuild adapter and Node.js runtime compatibility (`31a44458`).
|
|
30
|
+
- Moved runtime specifier registration onto the shared integration lifecycle and reused core browser runtime asset and entry helpers instead of React-owned temp entry assembly.
|
|
31
|
+
- Centralized React runtime specifier policy and consolidated browser runtime state under `window.__ECO_PAGES__`.
|
|
43
32
|
|
|
44
|
-
###
|
|
33
|
+
### Documentation
|
|
45
34
|
|
|
46
|
-
-
|
|
47
|
-
- Fixed stale temp module race during Fast Refresh cycles (`b2cf8466`).
|
|
48
|
-
- Fixed client graph boundary wiring for runtime dependencies (`4b6cd32e`).
|
|
35
|
+
- Expanded the README for client graph boundaries, shared-module rules, AST rewrite order, and hydration `locals`.
|
|
49
36
|
|
|
50
37
|
### Tests
|
|
51
38
|
|
|
52
|
-
- Added
|
|
53
|
-
- Added
|
|
54
|
-
- Added `reachability-analyzer.test.ts` (187 lines) covering export declaration reachability.
|
|
55
|
-
- Updated integration tests for esbuild adapter compatibility (`31a44458`).
|
|
39
|
+
- Added coverage for client graph reachability, hydration boundary utilities, and the router-backed component-render regression that prevents implicit island hydration.
|
|
40
|
+
- Added regression coverage for development React runtime vendor asset naming so dev and preview React bundles stay isolated.
|
|
56
41
|
|
|
57
42
|
---
|
|
58
43
|
|
|
59
44
|
## Migration Notes
|
|
60
45
|
|
|
61
|
-
- The React integration now requires explicit client boundary declarations
|
|
62
|
-
-
|
|
46
|
+
- The React integration now requires explicit client boundary declarations for client-rendered components.
|
|
47
|
+
- React MDX support is built in and no longer requires installing `@ecopages/mdx` just to enable React MDX routes.
|
|
48
|
+
- The internal service layer is not part of the public API and may change between releases.
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ecopages/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
First-class integration for [React 19](https://react.dev/) in Ecopages. This plugin enables React SSR and client hydration, allowing you to build component-level React islands or full React Single Page Applications (SPAs).
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
bunx jsr add @ecopages/react
|
|
@@ -10,10 +10,10 @@ bunx jsr add @ecopages/react
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Configure the plugin in your `eco.config.ts`:
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
|
-
import { ConfigBuilder } from '@ecopages/core';
|
|
16
|
+
import { ConfigBuilder } from '@ecopages/core/config-builder';
|
|
17
17
|
import { reactPlugin } from '@ecopages/react';
|
|
18
18
|
|
|
19
19
|
const config = await new ConfigBuilder()
|
|
@@ -24,16 +24,27 @@ const config = await new ConfigBuilder()
|
|
|
24
24
|
export default config;
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
## Component-Level Islands
|
|
28
|
+
|
|
29
|
+
By default, Ecopages React acts in island mode:
|
|
30
|
+
|
|
31
|
+
- SSR output preserves the authored DOM structure (no unnecessary wrapper elements).
|
|
32
|
+
- A stable `data-eco-component-id` attribute is attached to the component SSR root.
|
|
33
|
+
- The client bootstrap mounts the component via `createRoot()` strictly within that root boundary.
|
|
34
|
+
|
|
35
|
+
> [!TIP]
|
|
36
|
+
> **Full React SPA Routing:**
|
|
37
|
+
> If you are building full React pages and want client-side navigation (SPA), use [@ecopages/react-router](../react-router/README.md) and pass it to the react plugin: `reactPlugin({ router: ecoRouter() })`.
|
|
38
|
+
|
|
27
39
|
## MDX Support
|
|
28
40
|
|
|
29
|
-
The React plugin includes
|
|
41
|
+
The React plugin includes built-in MDX support. When enabled, you can write `.mdx` pages alongside `.tsx` pages with unified client-side routing, hydration, and HMR.
|
|
30
42
|
|
|
31
43
|
```ts
|
|
32
|
-
import { ConfigBuilder } from '@ecopages/core';
|
|
44
|
+
import { ConfigBuilder } from '@ecopages/core/config-builder';
|
|
33
45
|
import { reactPlugin } from '@ecopages/react';
|
|
34
46
|
|
|
35
47
|
const config = await new ConfigBuilder()
|
|
36
|
-
.setBaseUrl(import.meta.env.ECOPAGES_BASE_URL)
|
|
37
48
|
.setIntegrations([
|
|
38
49
|
reactPlugin({
|
|
39
50
|
mdx: {
|
|
@@ -49,17 +60,132 @@ const config = await new ConfigBuilder()
|
|
|
49
60
|
export default config;
|
|
50
61
|
```
|
|
51
62
|
|
|
52
|
-
|
|
63
|
+
## Server and Client Graph Contract
|
|
53
64
|
|
|
54
|
-
|
|
65
|
+
The React integration supports Node.js modules and server-only code **only on the server execution graph**.
|
|
66
|
+
|
|
67
|
+
- Server rendering can safely import `node:*` modules, database clients, filesystem utilities, etc.
|
|
68
|
+
- Client-hydrated React code must resolve to browser-safe modules only.
|
|
69
|
+
- If a server-only import crosses the boundary and becomes reachable by client code, **the client build will intentionally fail**.
|
|
70
|
+
|
|
71
|
+
Keep server helpers close, but separate them physically or logically so they do not leak into the client bundle.
|
|
72
|
+
|
|
73
|
+
## Client Graph Boundary Architecture
|
|
74
|
+
|
|
75
|
+
This section explains the internal contract used to keep the browser bundle minimal while preventing server-only code and request-only configuration from leaking into client output.
|
|
76
|
+
|
|
77
|
+
### Goal
|
|
78
|
+
|
|
79
|
+
The React integration has two jobs that must hold at the same time:
|
|
80
|
+
|
|
81
|
+
- Produce a browser-safe bundle for hydrated pages and islands.
|
|
82
|
+
- Preserve enough page code for hydration to reconstruct the same React tree the server rendered.
|
|
83
|
+
|
|
84
|
+
That means the client bundle must keep client-safe render logic, but it must drop server-only imports and server-only `eco.page(...)` options such as middleware and build-time metadata.
|
|
85
|
+
|
|
86
|
+
### Mental Model
|
|
87
|
+
|
|
88
|
+
Think about each React page as two related graphs:
|
|
89
|
+
|
|
90
|
+
1. **Server graph**: everything needed to render the page on the server. This graph may include middleware, request locals, database access, filesystem access, and other server-only modules.
|
|
91
|
+
2. **Client graph**: the smallest browser-safe subset needed to hydrate the rendered output in the browser.
|
|
92
|
+
|
|
93
|
+
The React integration builds the client graph conservatively. If a server-only module becomes reachable from the hydrated render path, the build should fail rather than silently shipping unsafe code.
|
|
94
|
+
|
|
95
|
+
### What Stays and What Goes
|
|
96
|
+
|
|
97
|
+
The client bundle keeps:
|
|
98
|
+
|
|
99
|
+
- The page component render path.
|
|
100
|
+
- Client-safe component dependencies reachable from render.
|
|
101
|
+
- Layout wiring needed for hydration.
|
|
102
|
+
- Router runtime state needed by [@ecopages/react-router](../react-router/README.md) when SPA mode is enabled.
|
|
103
|
+
|
|
104
|
+
The client bundle removes or excludes:
|
|
105
|
+
|
|
106
|
+
- Server-only imports that are not reachable from the hydrated render path.
|
|
107
|
+
- Server-only `eco.page(...)` options such as `cache`, `middleware`, `metadata`, `staticProps`, and `staticPaths`.
|
|
108
|
+
- Request-time configuration that has no meaning in the browser.
|
|
109
|
+
|
|
110
|
+
Important:
|
|
111
|
+
|
|
112
|
+
- `render` must stay in the client bundle, because hydration needs it to reconstruct the page tree.
|
|
113
|
+
- `requires` does **not** stay in the browser page config. It is used on the server to decide which `locals` keys may be serialized into the hydration payload.
|
|
114
|
+
|
|
115
|
+
### AST Pipeline Order
|
|
116
|
+
|
|
117
|
+
The browser-bound transform in [packages/integrations/react/src/utils/client-graph-boundary-plugin.ts](packages/integrations/react/src/utils/client-graph-boundary-plugin.ts) follows this order:
|
|
118
|
+
|
|
119
|
+
1. Parse the module and build a reachability view of the client render graph.
|
|
120
|
+
2. Remove imports that are not allowed or not reachable from the client graph.
|
|
121
|
+
3. Reparse the transformed source.
|
|
122
|
+
4. Strip server-only `eco.page(...)` object properties from the reparsed AST.
|
|
123
|
+
5. Return the rewritten source to the bundle step.
|
|
124
|
+
|
|
125
|
+
The reparse step is important. Once import edits change source offsets, the original AST locations are stale. Reusing them for later edits can corrupt the output or remove the wrong code.
|
|
126
|
+
|
|
127
|
+
### Why `eco.page(...)` Options Are Stripped
|
|
128
|
+
|
|
129
|
+
Import pruning alone is not enough.
|
|
130
|
+
|
|
131
|
+
Consider a page like this:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { authMiddleware } from './auth.server';
|
|
135
|
+
|
|
136
|
+
export default eco.page({
|
|
137
|
+
cache: 'dynamic',
|
|
138
|
+
middleware: [authMiddleware],
|
|
139
|
+
requires: ['session'] as const,
|
|
140
|
+
render: () => <div>Dashboard</div>,
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
If the client transform removes the `auth.server` import but leaves `middleware: [authMiddleware]` in place, the browser bundle still contains a dangling identifier. That breaks production hydration even though the import was removed correctly.
|
|
145
|
+
|
|
146
|
+
The fix is to strip server-only `eco.page(...)` options after import pruning, while keeping `render` intact.
|
|
147
|
+
|
|
148
|
+
### Hydration Contract for `locals`
|
|
149
|
+
|
|
150
|
+
The browser must not receive arbitrary request-scoped data.
|
|
151
|
+
|
|
152
|
+
The React renderer in [packages/integrations/react/src/react-renderer.ts](packages/integrations/react/src/react-renderer.ts) serializes only the top-level `locals` keys explicitly declared by `Page.requires`. If a page does not declare `requires`, no `locals` are serialized for hydration.
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
export default eco.page({
|
|
158
|
+
requires: ['session'] as const,
|
|
159
|
+
render: ({ locals }) => <Dashboard user={locals?.session?.user} />,
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
In this case, the hydration payload may include `locals.session`, but it will exclude unrelated request-only keys.
|
|
164
|
+
|
|
165
|
+
Important:
|
|
166
|
+
|
|
167
|
+
- This filtering is currently top-level only.
|
|
168
|
+
- If `locals.session` itself contains sensitive nested fields, those fields will still be serialized.
|
|
169
|
+
- Middleware should therefore expose a client-safe shape for any key declared in `requires`.
|
|
170
|
+
|
|
171
|
+
### Layout Hydration Invariant
|
|
172
|
+
|
|
173
|
+
Hydration must rebuild the same tree the server rendered.
|
|
174
|
+
|
|
175
|
+
That applies to both:
|
|
176
|
+
|
|
177
|
+
- non-router hydration scripts in [packages/integrations/react/src/utils/hydration-scripts.ts](packages/integrations/react/src/utils/hydration-scripts.ts)
|
|
178
|
+
- router-backed hydration in [packages/react-router/src/router.ts](packages/react-router/src/router.ts)
|
|
179
|
+
|
|
180
|
+
If the page render receives `locals` on the server and the layout also depends on those values, the client must pass the same serialized `locals` into the layout during hydration. Otherwise React will detect a mismatch.
|
|
55
181
|
|
|
56
|
-
|
|
182
|
+
### Tests That Guard This Contract
|
|
57
183
|
|
|
58
|
-
|
|
59
|
-
- A stable `data-eco-component-id` attribute is attached to the component SSR root when a single root element is available.
|
|
60
|
-
- Client bootstrap resolves the component export and mounts with `createRoot()` into that root boundary.
|
|
61
|
-
- Component assets are emitted through the shared dependency pipeline and deduplicated with other integrations.
|
|
184
|
+
The main regression coverage lives in:
|
|
62
185
|
|
|
63
|
-
|
|
186
|
+
- [packages/integrations/react/src/utils/client-graph-boundary-plugin.test.ts](packages/integrations/react/src/utils/client-graph-boundary-plugin.test.ts): verifies server-only `eco.page(...)` options are stripped from browser bundles.
|
|
187
|
+
- [packages/integrations/react/src/react-renderer.locals.test.ts](packages/integrations/react/src/react-renderer.locals.test.ts): verifies only declared `requires` keys are serialized into hydration payloads.
|
|
188
|
+
- [packages/integrations/react/src/utils/hydration-scripts.test.ts](packages/integrations/react/src/utils/hydration-scripts.test.ts): verifies non-router hydration passes serialized `locals` into layouts.
|
|
189
|
+
- [packages/react-router/test/hmr-reload.test.browser.ts](packages/react-router/test/hmr-reload.test.browser.ts): verifies router-backed layout hydration receives `locals` with `persistLayouts` both enabled and disabled.
|
|
64
190
|
|
|
65
|
-
|
|
191
|
+
If you change the AST transform or hydration flow, update the corresponding tests in the same change.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/react",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.7",
|
|
4
4
|
"description": "React integration for Ecopages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -53,14 +53,14 @@
|
|
|
53
53
|
"directory": "packages/integrations/react"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@ecopages/core": "0.2.0-alpha.
|
|
56
|
+
"@ecopages/core": "0.2.0-alpha.7",
|
|
57
57
|
"@types/react": "^19",
|
|
58
58
|
"@types/react-dom": "^19",
|
|
59
59
|
"react": "^19",
|
|
60
60
|
"react-dom": "^19"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@ecopages/file-system": "0.2.0-alpha.
|
|
63
|
+
"@ecopages/file-system": "0.2.0-alpha.7",
|
|
64
64
|
"@ecopages/logger": "latest",
|
|
65
65
|
"@mdx-js/esbuild": "^3.0.1",
|
|
66
66
|
"@mdx-js/mdx": "^3.1.0",
|
|
@@ -19,9 +19,11 @@ import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metada
|
|
|
19
19
|
* The processing steps are:
|
|
20
20
|
* 1. Check if any React entrypoints are registered
|
|
21
21
|
* 2. Rebuild all React entrypoints (the changed file could be a dependency)
|
|
22
|
-
* 3.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* 3. Rebuild browser output through the shared browser bundle service while
|
|
23
|
+
* preserving React-specific runtime aliases and graph policy
|
|
24
|
+
* 4. Read page config metadata through the shared server-module loading path
|
|
25
|
+
* 5. Inject HMR acceptance handler
|
|
26
|
+
* 6. Broadcast update events for each rebuilt entrypoint
|
|
25
27
|
*
|
|
26
28
|
* @remarks
|
|
27
29
|
* This strategy has higher priority than generic JsHmrStrategy, allowing it
|
|
@@ -47,14 +49,11 @@ import type { ReactHmrPageMetadataCache } from './services/react-hmr-page-metada
|
|
|
47
49
|
* ```
|
|
48
50
|
*/
|
|
49
51
|
export declare class ReactHmrStrategy extends HmrStrategy {
|
|
50
|
-
private context;
|
|
51
|
-
private pageMetadataCache;
|
|
52
|
-
private explicitGraphEnabled;
|
|
53
52
|
readonly type = HmrStrategyType.INTEGRATION;
|
|
54
53
|
private mdxCompilerOptions?;
|
|
55
|
-
private readonly
|
|
54
|
+
private readonly ownedTemplateExtensions;
|
|
55
|
+
private readonly allTemplateExtensions;
|
|
56
56
|
private importNodePageModule;
|
|
57
|
-
private createUseSyncExternalStoreShimPlugin;
|
|
58
57
|
/**
|
|
59
58
|
* Creates a new React HMR strategy instance.
|
|
60
59
|
*
|
|
@@ -68,7 +67,10 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
68
67
|
* @param explicitGraphEnabled - Enables explicit graph mode for React HMR bundling.
|
|
69
68
|
* In explicit mode, HMR builds omit AST server-only stripping plugins in React paths.
|
|
70
69
|
*/
|
|
71
|
-
|
|
70
|
+
private context;
|
|
71
|
+
private pageMetadataCache;
|
|
72
|
+
private explicitGraphEnabled;
|
|
73
|
+
constructor(context: DefaultHmrContext, pageMetadataCache: ReactHmrPageMetadataCache, mdxCompilerOptions?: CompileOptions, ownedTemplateExtensions?: string[], allTemplateExtensions?: string[], explicitGraphEnabled?: boolean);
|
|
72
74
|
/**
|
|
73
75
|
* Returns build plugins for React HMR bundling.
|
|
74
76
|
*
|
|
@@ -77,6 +79,16 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
77
79
|
*/
|
|
78
80
|
private getBuildPlugins;
|
|
79
81
|
private isReactEntrypoint;
|
|
82
|
+
/**
|
|
83
|
+
* Returns true when a route file uses a compound extension like `page.foo.tsx`.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* React integration owns plain `.tsx` route templates. Compound extensions in
|
|
87
|
+
* pages/layouts are integration-specific route templates and should not be
|
|
88
|
+
* claimed by React HMR strategy.
|
|
89
|
+
*/
|
|
90
|
+
private isRouteTemplate;
|
|
91
|
+
private resolveTemplateExtension;
|
|
80
92
|
/**
|
|
81
93
|
* Determines if the file is a React/MDX entrypoint that's registered for HMR.
|
|
82
94
|
*
|
|
@@ -122,7 +134,7 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
122
134
|
*/
|
|
123
135
|
private encodeDynamicSegments;
|
|
124
136
|
/**
|
|
125
|
-
* Processes bundled output
|
|
137
|
+
* Processes bundled output and injects the React HMR handler.
|
|
126
138
|
* Writes to temp file first, then renames atomically to avoid conflicts.
|
|
127
139
|
*
|
|
128
140
|
* @param tempPath - Path to the temporary bundled file
|
|
@@ -131,13 +143,4 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
131
143
|
* @returns True if processing was successful
|
|
132
144
|
*/
|
|
133
145
|
private processOutput;
|
|
134
|
-
/**
|
|
135
|
-
* Replaces bare specifiers with runtime URLs.
|
|
136
|
-
*
|
|
137
|
-
* Handles both static imports and dynamic imports.
|
|
138
|
-
*
|
|
139
|
-
* @param code - The bundled code to transform
|
|
140
|
-
* @returns The transformed code with runtime URLs
|
|
141
|
-
*/
|
|
142
|
-
private replaceBareSpecifiers;
|
|
143
146
|
}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
2
|
import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
|
|
4
|
-
import {
|
|
3
|
+
import { createRuntimeSpecifierAliasPlugin } from "@ecopages/core/build/runtime-specifier-alias-plugin";
|
|
5
4
|
import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
|
|
6
5
|
import { Logger } from "@ecopages/logger";
|
|
7
6
|
import { injectHmrHandler } from "./utils/hmr-scripts.js";
|
|
8
7
|
import { createClientGraphBoundaryPlugin } from "./utils/client-graph-boundary-plugin.js";
|
|
9
8
|
import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from "./utils/declared-modules.js";
|
|
9
|
+
import { getReactClientGraphAllowSpecifiers } from "./utils/react-runtime-specifier-map.js";
|
|
10
|
+
import { createUseSyncExternalStoreShimPlugin } from "./utils/use-sync-external-store-shim-plugin.js";
|
|
10
11
|
const appLogger = new Logger("[ReactHmrStrategy]");
|
|
11
12
|
class ReactHmrStrategy extends HmrStrategy {
|
|
13
|
+
type = HmrStrategyType.INTEGRATION;
|
|
14
|
+
mdxCompilerOptions;
|
|
15
|
+
ownedTemplateExtensions;
|
|
16
|
+
allTemplateExtensions;
|
|
17
|
+
async importNodePageModule(entrypointPath) {
|
|
18
|
+
return await this.context.importServerModule(entrypointPath);
|
|
19
|
+
}
|
|
12
20
|
/**
|
|
13
21
|
* Creates a new React HMR strategy instance.
|
|
14
22
|
*
|
|
@@ -22,75 +30,17 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
22
30
|
* @param explicitGraphEnabled - Enables explicit graph mode for React HMR bundling.
|
|
23
31
|
* In explicit mode, HMR builds omit AST server-only stripping plugins in React paths.
|
|
24
32
|
*/
|
|
25
|
-
|
|
33
|
+
context;
|
|
34
|
+
pageMetadataCache;
|
|
35
|
+
explicitGraphEnabled;
|
|
36
|
+
constructor(context, pageMetadataCache, mdxCompilerOptions, ownedTemplateExtensions = [".tsx"], allTemplateExtensions = [".tsx"], explicitGraphEnabled = false) {
|
|
26
37
|
super();
|
|
27
38
|
this.context = context;
|
|
28
39
|
this.pageMetadataCache = pageMetadataCache;
|
|
29
40
|
this.explicitGraphEnabled = explicitGraphEnabled;
|
|
30
41
|
this.mdxCompilerOptions = mdxCompilerOptions;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
mdxCompilerOptions;
|
|
34
|
-
knownEntrypoints = /* @__PURE__ */ new Set();
|
|
35
|
-
async importNodePageModule(entrypointPath) {
|
|
36
|
-
const srcDir = this.context.getSrcDir();
|
|
37
|
-
const rootDir = path.dirname(srcDir);
|
|
38
|
-
const outdir = path.join(path.resolve(this.context.getDistDir(), "..", ".."), ".server-modules");
|
|
39
|
-
const fileBaseName = path.basename(entrypointPath, path.extname(entrypointPath));
|
|
40
|
-
const fileHash = fileSystem.hash(entrypointPath);
|
|
41
|
-
const outputFileName = `${fileBaseName}-${fileHash}.js`;
|
|
42
|
-
const buildResult = await defaultBuildAdapter.build({
|
|
43
|
-
entrypoints: [entrypointPath],
|
|
44
|
-
root: rootDir,
|
|
45
|
-
outdir,
|
|
46
|
-
target: "node",
|
|
47
|
-
format: "esm",
|
|
48
|
-
sourcemap: "none",
|
|
49
|
-
splitting: false,
|
|
50
|
-
minify: false,
|
|
51
|
-
naming: outputFileName
|
|
52
|
-
});
|
|
53
|
-
if (!buildResult.success) {
|
|
54
|
-
const details = buildResult.logs.map((log) => log.message).join(" | ");
|
|
55
|
-
throw new Error(`Error transpiling React HMR page module: ${details}`);
|
|
56
|
-
}
|
|
57
|
-
const preferredOutputPath = path.join(outdir, outputFileName);
|
|
58
|
-
const compiledOutput = buildResult.outputs.find((output) => output.path === preferredOutputPath)?.path ?? buildResult.outputs.find((output) => output.path.endsWith(".js"))?.path;
|
|
59
|
-
if (!compiledOutput) {
|
|
60
|
-
throw new Error(`No transpiled output generated for React HMR page module: ${entrypointPath}`);
|
|
61
|
-
}
|
|
62
|
-
return await import(pathToFileURL(compiledOutput).href);
|
|
63
|
-
}
|
|
64
|
-
createUseSyncExternalStoreShimPlugin() {
|
|
65
|
-
return {
|
|
66
|
-
name: "react-hmr-use-sync-external-store-shim",
|
|
67
|
-
setup(build) {
|
|
68
|
-
build.onResolve({ filter: /^use-sync-external-store\/shim(?:\/index\.js)?$/ }, () => ({
|
|
69
|
-
path: "use-sync-external-store/shim",
|
|
70
|
-
namespace: "ecopages-react-hmr-shim"
|
|
71
|
-
}));
|
|
72
|
-
build.onLoad(
|
|
73
|
-
{ filter: /^use-sync-external-store\/shim$/, namespace: "ecopages-react-hmr-shim" },
|
|
74
|
-
() => ({
|
|
75
|
-
contents: "export { useSyncExternalStore } from 'react';",
|
|
76
|
-
loader: "js"
|
|
77
|
-
})
|
|
78
|
-
);
|
|
79
|
-
build.onLoad({ filter: /[\\/]use-sync-external-store[\\/]shim[\\/]index\.js$/ }, () => ({
|
|
80
|
-
contents: "export { useSyncExternalStore } from 'react';",
|
|
81
|
-
loader: "js"
|
|
82
|
-
}));
|
|
83
|
-
build.onLoad(
|
|
84
|
-
{
|
|
85
|
-
filter: /[\\/]use-sync-external-store[\\/]cjs[\\/]use-sync-external-store-shim\.development\.js$/
|
|
86
|
-
},
|
|
87
|
-
() => ({
|
|
88
|
-
contents: "export { useSyncExternalStore } from 'react';",
|
|
89
|
-
loader: "js"
|
|
90
|
-
})
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
};
|
|
42
|
+
this.ownedTemplateExtensions = new Set(ownedTemplateExtensions);
|
|
43
|
+
this.allTemplateExtensions = [...allTemplateExtensions].sort((a, b) => b.length - a.length);
|
|
94
44
|
}
|
|
95
45
|
/**
|
|
96
46
|
* Returns build plugins for React HMR bundling.
|
|
@@ -99,30 +49,53 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
99
49
|
* (including `node:*`) from breaking the browser bundle.
|
|
100
50
|
*/
|
|
101
51
|
getBuildPlugins(declaredModules) {
|
|
102
|
-
const allowSpecifiers =
|
|
103
|
-
|
|
104
|
-
"react"
|
|
105
|
-
|
|
106
|
-
"react/jsx-runtime",
|
|
107
|
-
"react/jsx-dev-runtime",
|
|
108
|
-
"react-dom/client",
|
|
109
|
-
...Array.from(this.context.getSpecifierMap().keys())
|
|
110
|
-
];
|
|
52
|
+
const allowSpecifiers = getReactClientGraphAllowSpecifiers(this.context.getSpecifierMap().keys());
|
|
53
|
+
const runtimeAliasPlugin = createRuntimeSpecifierAliasPlugin(this.context.getSpecifierMap(), {
|
|
54
|
+
name: "react-hmr-runtime-specifier-alias"
|
|
55
|
+
});
|
|
111
56
|
return [
|
|
112
57
|
createClientGraphBoundaryPlugin({
|
|
113
58
|
absWorkingDir: path.dirname(this.context.getSrcDir()),
|
|
114
59
|
alwaysAllowSpecifiers: allowSpecifiers,
|
|
115
60
|
declaredModules
|
|
116
61
|
}),
|
|
62
|
+
...runtimeAliasPlugin ? [runtimeAliasPlugin] : [],
|
|
117
63
|
...this.context.getPlugins(),
|
|
118
|
-
|
|
64
|
+
createUseSyncExternalStoreShimPlugin({
|
|
65
|
+
name: "react-hmr-use-sync-external-store-shim",
|
|
66
|
+
namespace: "ecopages-react-hmr-shim"
|
|
67
|
+
})
|
|
119
68
|
];
|
|
120
69
|
}
|
|
121
70
|
isReactEntrypoint(filePath) {
|
|
122
|
-
if (filePath.endsWith(".
|
|
71
|
+
if (filePath.endsWith(".mdx")) {
|
|
72
|
+
return this.mdxCompilerOptions !== void 0;
|
|
73
|
+
}
|
|
74
|
+
if (!filePath.endsWith(".tsx")) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (!this.isRouteTemplate(filePath)) {
|
|
123
78
|
return true;
|
|
124
79
|
}
|
|
125
|
-
|
|
80
|
+
const templateExtension = this.resolveTemplateExtension(filePath);
|
|
81
|
+
if (!templateExtension) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return this.ownedTemplateExtensions.has(templateExtension);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Returns true when a route file uses a compound extension like `page.foo.tsx`.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* React integration owns plain `.tsx` route templates. Compound extensions in
|
|
91
|
+
* pages/layouts are integration-specific route templates and should not be
|
|
92
|
+
* claimed by React HMR strategy.
|
|
93
|
+
*/
|
|
94
|
+
isRouteTemplate(filePath) {
|
|
95
|
+
return filePath.startsWith(this.context.getPagesDir()) || filePath.startsWith(this.context.getLayoutsDir());
|
|
96
|
+
}
|
|
97
|
+
resolveTemplateExtension(filePath) {
|
|
98
|
+
return this.allTemplateExtensions.find((extension) => filePath.endsWith(extension));
|
|
126
99
|
}
|
|
127
100
|
/**
|
|
128
101
|
* Determines if the file is a React/MDX entrypoint that's registered for HMR.
|
|
@@ -174,8 +147,8 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
174
147
|
if (isLayout) {
|
|
175
148
|
appLogger.debug(`Detected layout file change: ${_filePath}`);
|
|
176
149
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
150
|
+
const changedEntrypointOutput = watchedFiles.get(_filePath);
|
|
151
|
+
const entrypointsToBuild = changedEntrypointOutput ? [[_filePath, changedEntrypointOutput]] : watchedFiles.entries();
|
|
179
152
|
const updates = [];
|
|
180
153
|
for (const [entrypoint, outputUrl] of entrypointsToBuild) {
|
|
181
154
|
if (!this.isReactEntrypoint(entrypoint)) {
|
|
@@ -235,16 +208,13 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
235
208
|
const mdxPlugin = createReactMdxLoaderPlugin(this.mdxCompilerOptions);
|
|
236
209
|
plugins.unshift(mdxPlugin);
|
|
237
210
|
}
|
|
238
|
-
const result = await
|
|
211
|
+
const result = await this.context.getBrowserBundleService().bundle({
|
|
212
|
+
profile: "hmr-entrypoint",
|
|
239
213
|
entrypoints: [entrypointPath],
|
|
240
214
|
outdir: tempDir,
|
|
241
215
|
naming: `[name].[hash].tmp`,
|
|
242
|
-
target: "browser",
|
|
243
|
-
format: "esm",
|
|
244
|
-
sourcemap: "none",
|
|
245
216
|
plugins,
|
|
246
|
-
minify: false
|
|
247
|
-
external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom/client"]
|
|
217
|
+
minify: false
|
|
248
218
|
});
|
|
249
219
|
if (!result.success) {
|
|
250
220
|
appLogger.error(`Failed to build ${entrypointPath}:`, result.logs);
|
|
@@ -270,7 +240,7 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
270
240
|
return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
|
|
271
241
|
}
|
|
272
242
|
/**
|
|
273
|
-
* Processes bundled output
|
|
243
|
+
* Processes bundled output and injects the React HMR handler.
|
|
274
244
|
* Writes to temp file first, then renames atomically to avoid conflicts.
|
|
275
245
|
*
|
|
276
246
|
* @param tempPath - Path to the temporary bundled file
|
|
@@ -285,7 +255,6 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
285
255
|
}
|
|
286
256
|
try {
|
|
287
257
|
let code = await fileSystem.readFile(tempPath);
|
|
288
|
-
code = this.replaceBareSpecifiers(code);
|
|
289
258
|
code = injectHmrHandler(code);
|
|
290
259
|
await fileSystem.writeAsync(finalPath, code);
|
|
291
260
|
await fileSystem.removeAsync(tempPath).catch(() => {
|
|
@@ -305,27 +274,6 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
305
274
|
return false;
|
|
306
275
|
}
|
|
307
276
|
}
|
|
308
|
-
/**
|
|
309
|
-
* Replaces bare specifiers with runtime URLs.
|
|
310
|
-
*
|
|
311
|
-
* Handles both static imports and dynamic imports.
|
|
312
|
-
*
|
|
313
|
-
* @param code - The bundled code to transform
|
|
314
|
-
* @returns The transformed code with runtime URLs
|
|
315
|
-
*/
|
|
316
|
-
replaceBareSpecifiers(code) {
|
|
317
|
-
const specifierMap = this.context.getSpecifierMap();
|
|
318
|
-
if (specifierMap.size === 0) {
|
|
319
|
-
return code;
|
|
320
|
-
}
|
|
321
|
-
let result = code;
|
|
322
|
-
for (const [bareSpec, runtimeUrl] of specifierMap.entries()) {
|
|
323
|
-
const escaped = bareSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
324
|
-
result = result.replace(new RegExp(`from\\s*["']${escaped}["']`, "g"), `from "${runtimeUrl}"`);
|
|
325
|
-
result = result.replace(new RegExp(`import\\(["']${escaped}["']\\)`, "g"), `import("${runtimeUrl}")`);
|
|
326
|
-
}
|
|
327
|
-
return result;
|
|
328
|
-
}
|
|
329
277
|
}
|
|
330
278
|
export {
|
|
331
279
|
ReactHmrStrategy
|