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

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 (41) hide show
  1. package/CHANGELOG.md +11 -51
  2. package/README.md +135 -29
  3. package/package.json +3 -3
  4. package/src/react-hmr-strategy.d.ts +22 -30
  5. package/src/react-hmr-strategy.js +57 -120
  6. package/src/react-hmr-strategy.ts +76 -145
  7. package/src/react-renderer.d.ts +130 -11
  8. package/src/react-renderer.js +368 -64
  9. package/src/react-renderer.ts +490 -90
  10. package/src/react.plugin.d.ts +17 -5
  11. package/src/react.plugin.js +44 -13
  12. package/src/react.plugin.ts +49 -14
  13. package/src/router-adapter.d.ts +2 -2
  14. package/src/router-adapter.ts +2 -2
  15. package/src/services/react-bundle.service.d.ts +2 -30
  16. package/src/services/react-bundle.service.js +19 -94
  17. package/src/services/react-bundle.service.ts +20 -129
  18. package/src/services/react-hydration-asset.service.js +3 -3
  19. package/src/services/react-hydration-asset.service.ts +7 -4
  20. package/src/services/react-page-module.service.d.ts +3 -0
  21. package/src/services/react-page-module.service.js +20 -16
  22. package/src/services/react-page-module.service.ts +27 -17
  23. package/src/services/react-runtime-bundle.service.d.ts +12 -12
  24. package/src/services/react-runtime-bundle.service.js +98 -180
  25. package/src/services/react-runtime-bundle.service.ts +112 -211
  26. package/src/utils/client-graph-boundary-plugin.js +78 -1
  27. package/src/utils/client-graph-boundary-plugin.ts +122 -1
  28. package/src/utils/hydration-scripts.d.ts +18 -1
  29. package/src/utils/hydration-scripts.js +83 -32
  30. package/src/utils/hydration-scripts.ts +159 -38
  31. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  32. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  33. package/src/utils/react-dom-runtime-interop-plugin.ts +33 -0
  34. package/src/utils/react-mdx-loader-plugin.js +13 -5
  35. package/src/utils/react-mdx-loader-plugin.ts +28 -5
  36. package/src/utils/react-runtime-specifier-map.d.ts +6 -0
  37. package/src/utils/react-runtime-specifier-map.js +37 -0
  38. package/src/utils/react-runtime-specifier-map.ts +45 -0
  39. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  40. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  41. package/src/utils/use-sync-external-store-shim-plugin.ts +45 -0
@@ -2,23 +2,22 @@
2
2
  * Runtime bundle service for React integration.
3
3
  *
4
4
  * Owns creation of the browser runtime assets for React and React DOM,
5
- * including temporary entry generation, specifier mapping, and React DOM
6
- * interop rewriting.
5
+ * including shared runtime entry generation and specifier mapping.
7
6
  *
8
7
  * @module
9
8
  */
10
9
 
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import { createRequire } from 'node:module';
14
10
  import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
15
- import { type AssetDefinition, AssetFactory } from '@ecopages/core/services/asset-processing-service';
11
+ import { createRuntimeSpecifierAliasPlugin } from '@ecopages/core/build/runtime-specifier-alias-plugin';
12
+ import {
13
+ buildBrowserRuntimeAssetUrl,
14
+ createBrowserRuntimeModuleAsset,
15
+ createBrowserRuntimeScriptAsset,
16
+ type AssetDefinition,
17
+ } from '@ecopages/core/services/asset-processing-service';
16
18
  import type { ReactRouterAdapter } from '../router-adapter.ts';
17
-
18
- type RuntimeModuleConfig = {
19
- specifier: string;
20
- defaultExport?: boolean;
21
- };
19
+ import { createReactDomRuntimeInteropPlugin } from '../utils/react-dom-runtime-interop-plugin.ts';
20
+ import { buildReactRuntimeSpecifierMap } from '../utils/react-runtime-specifier-map.ts';
22
21
 
23
22
  export type ReactRuntimeImports = {
24
23
  react: string;
@@ -33,239 +32,141 @@ export interface ReactRuntimeBundleServiceConfig {
33
32
  routerAdapter?: ReactRouterAdapter;
34
33
  }
35
34
 
36
- export class ReactRuntimeBundleService {
37
- constructor(private readonly config: ReactRuntimeBundleServiceConfig) {}
38
-
39
- getRuntimeImports(): ReactRuntimeImports {
40
- const runtimeImports: ReactRuntimeImports = {
41
- react: this.buildImportMapSourceUrl('react.js'),
42
- reactDomClient: this.buildImportMapSourceUrl('react-dom.js'),
43
- reactJsxRuntime: this.buildImportMapSourceUrl('react.js'),
44
- reactJsxDevRuntime: this.buildImportMapSourceUrl('react.js'),
45
- reactDom: this.buildImportMapSourceUrl('react-dom.js'),
46
- };
35
+ type RuntimeMode = 'development' | 'production';
47
36
 
48
- if (this.config.routerAdapter) {
49
- runtimeImports.router = this.buildImportMapSourceUrl(`${this.config.routerAdapter.bundle.outputName}.js`);
50
- }
37
+ export class ReactRuntimeBundleService {
38
+ private readonly config: ReactRuntimeBundleServiceConfig;
51
39
 
52
- return runtimeImports;
40
+ constructor(config: ReactRuntimeBundleServiceConfig) {
41
+ this.config = config;
53
42
  }
54
43
 
55
- getSpecifierMap(): Record<string, string> {
56
- const runtimeImports = this.getRuntimeImports();
57
- const map: Record<string, string> = {
58
- react: runtimeImports.react,
59
- 'react/jsx-runtime': runtimeImports.reactJsxRuntime,
60
- 'react/jsx-dev-runtime': runtimeImports.reactJsxDevRuntime,
61
- 'react-dom': runtimeImports.reactDom,
62
- 'react-dom/client': runtimeImports.reactDomClient,
63
- };
64
-
65
- if (this.config.routerAdapter && runtimeImports.router) {
66
- map[this.config.routerAdapter.importMapKey] = runtimeImports.router;
67
- }
68
-
69
- return map;
44
+ private get isDevelopment(): boolean {
45
+ return process.env.NODE_ENV === 'development';
70
46
  }
71
47
 
72
- getDependencies(): AssetDefinition[] {
73
- const runtimeAttrs = { type: 'module', defer: '' } as const;
74
- const runtimeImports = this.getRuntimeImports();
75
- const reactRuntimeAliasPlugin = this.createRuntimeSpecifierAliasPlugin({
76
- react: runtimeImports.react,
77
- });
78
- const reactDomRuntimeInteropPlugin = this.createReactDomRuntimeInteropPlugin();
79
-
80
- const reactEntry = this.createRuntimeEntry(
81
- [
82
- { specifier: 'react', defaultExport: true },
83
- { specifier: 'react/jsx-runtime' },
84
- { specifier: 'react/jsx-dev-runtime' },
85
- ],
86
- 'react-entry.mjs',
87
- );
88
- const reactDomEntry = this.createRuntimeEntry(
89
- [{ specifier: 'react-dom', defaultExport: true }, { specifier: 'react-dom/client' }],
90
- 'react-dom-entry.mjs',
91
- );
92
-
93
- const dependencies: AssetDefinition[] = [
94
- AssetFactory.createNodeModuleScript({
95
- position: 'head',
96
- importPath: reactEntry,
97
- name: 'react',
98
- excludeFromHtml: true,
99
- bundleOptions: { naming: 'react.js' },
100
- attributes: runtimeAttrs,
101
- }),
102
- AssetFactory.createNodeModuleScript({
103
- position: 'head',
104
- importPath: reactDomEntry,
105
- name: 'react-dom',
106
- excludeFromHtml: true,
107
- bundleOptions: {
108
- naming: 'react-dom.js',
109
- plugins: [reactRuntimeAliasPlugin, reactDomRuntimeInteropPlugin],
110
- },
111
- attributes: runtimeAttrs,
112
- }),
113
- ];
114
-
115
- if (this.config.routerAdapter) {
116
- const runtimeAliasPlugin = this.createRuntimeAliasPlugin();
117
- const mappedSpecifiers = new Set(Object.keys(this.getSpecifierMap()));
118
- const unresolvedExternals = this.config.routerAdapter.bundle.externals.filter(
119
- (external) => !mappedSpecifiers.has(external),
120
- );
121
-
122
- dependencies.push(
123
- AssetFactory.createNodeModuleScript({
124
- position: 'head',
125
- importPath: this.config.routerAdapter.bundle.importPath,
126
- name: this.config.routerAdapter.bundle.outputName,
127
- excludeFromHtml: true,
128
- bundleOptions: {
129
- naming: `${this.config.routerAdapter.bundle.outputName}.js`,
130
- external: unresolvedExternals,
131
- plugins: [runtimeAliasPlugin],
132
- },
133
- attributes: runtimeAttrs,
134
- }),
135
- );
136
- }
137
-
138
- return dependencies;
48
+ private getCurrentRuntimeMode(): RuntimeMode {
49
+ return this.isDevelopment ? 'development' : 'production';
139
50
  }
140
51
 
141
- createRuntimeAliasPlugin(): EcoBuildPlugin {
142
- const specifierMap = this.getSpecifierMap();
143
- const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
144
- const filter = new RegExp(
145
- `^(${Object.keys(specifierMap)
146
- .map((key) => escapeRegExp(key))
147
- .join('|')})$`,
148
- );
52
+ private createRuntimeDefines(mode: RuntimeMode): Record<string, string> {
53
+ const nodeEnv = JSON.stringify(mode);
149
54
 
150
55
  return {
151
- name: 'react-plugin-runtime-alias',
152
- setup(build) {
153
- build.onResolve({ filter }, (args) => {
154
- const mappedPath = specifierMap[args.path];
155
- if (!mappedPath) {
156
- return undefined;
157
- }
158
-
159
- return {
160
- path: mappedPath,
161
- external: true,
162
- };
163
- });
164
- },
56
+ 'process.env.NODE_ENV': nodeEnv,
57
+ 'import.meta.env.NODE_ENV': nodeEnv,
165
58
  };
166
59
  }
167
60
 
168
- private buildImportMapSourceUrl(fileName: string): string {
169
- return `/${AssetFactory.RESOLVED_ASSETS_VENDORS_DIR}/${fileName}`;
61
+ private getReactVendorFileName(mode: RuntimeMode): string {
62
+ return mode === 'development' ? 'react.development.js' : 'react.js';
170
63
  }
171
64
 
172
- private createRuntimeSpecifierAliasPlugin(specifierMap: Record<string, string>, external = true): EcoBuildPlugin {
173
- const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
174
- const filter = new RegExp(
175
- `^(${Object.keys(specifierMap)
176
- .map((key) => escapeRegExp(key))
177
- .join('|')})$`,
178
- );
65
+ private getReactDomVendorFileName(mode: RuntimeMode): string {
66
+ return mode === 'development' ? 'react-dom.development.js' : 'react-dom.js';
67
+ }
179
68
 
180
- return {
181
- name: 'react-plugin-runtime-specifier-alias',
182
- setup(build) {
183
- build.onResolve({ filter }, (args) => {
184
- const mappedPath = specifierMap[args.path];
185
- if (!mappedPath) {
186
- return undefined;
187
- }
69
+ private getRouterVendorFileName(mode: RuntimeMode): string {
70
+ if (!this.config.routerAdapter) {
71
+ return '';
72
+ }
188
73
 
189
- return {
190
- path: mappedPath,
191
- external,
192
- };
193
- });
194
- },
195
- };
74
+ return mode === 'development'
75
+ ? `${this.config.routerAdapter.bundle.outputName}.development.js`
76
+ : `${this.config.routerAdapter.bundle.outputName}.js`;
196
77
  }
197
78
 
198
- private createReactDomRuntimeInteropPlugin(): EcoBuildPlugin {
199
- const reactDomFileFilter = /[\\/]react-dom[\\/].*\.js$/;
200
- const reactRequirePattern = /\brequire\((['"])react\1\)/g;
201
-
202
- return {
203
- name: 'react-dom-runtime-interop',
204
- setup(build) {
205
- build.onLoad({ filter: reactDomFileFilter }, (args) => {
206
- const content = fs.readFileSync(args.path, 'utf-8');
207
- if (!reactRequirePattern.test(content)) {
208
- return undefined;
209
- }
79
+ getRuntimeImports(mode = this.getCurrentRuntimeMode()): ReactRuntimeImports {
80
+ const reactVendorFileName = this.getReactVendorFileName(mode);
81
+ const reactDomVendorFileName = this.getReactDomVendorFileName(mode);
82
+ const runtimeImports: ReactRuntimeImports = {
83
+ react: buildBrowserRuntimeAssetUrl(reactVendorFileName),
84
+ reactDomClient: buildBrowserRuntimeAssetUrl(reactDomVendorFileName),
85
+ reactJsxRuntime: buildBrowserRuntimeAssetUrl(reactVendorFileName),
86
+ reactJsxDevRuntime: buildBrowserRuntimeAssetUrl(reactVendorFileName),
87
+ reactDom: buildBrowserRuntimeAssetUrl(reactDomVendorFileName),
88
+ };
210
89
 
211
- reactRequirePattern.lastIndex = 0;
212
- const rewritten = content.replace(reactRequirePattern, '__ecopages_react_runtime');
90
+ if (this.config.routerAdapter) {
91
+ runtimeImports.router = buildBrowserRuntimeAssetUrl(this.getRouterVendorFileName(mode));
92
+ }
213
93
 
214
- return {
215
- contents: `import * as __ecopages_react_runtime from 'react';\n${rewritten}`,
216
- loader: 'js',
217
- resolveDir: path.dirname(args.path),
218
- };
219
- });
220
- },
221
- };
94
+ return runtimeImports;
222
95
  }
223
96
 
224
- private getRuntimeArtifactsDir(): string {
225
- const tmpDir = path.join(process.cwd(), 'node_modules', '.cache', 'ecopages-react-runtime');
226
- fs.mkdirSync(tmpDir, { recursive: true });
227
- return tmpDir;
97
+ getSpecifierMap(mode = this.getCurrentRuntimeMode()): Record<string, string> {
98
+ return buildReactRuntimeSpecifierMap(this.getRuntimeImports(mode), this.config.routerAdapter);
228
99
  }
229
100
 
230
- private createRuntimeEntry(modules: RuntimeModuleConfig[], fileName: string): string {
231
- const tmpDir = this.getRuntimeArtifactsDir();
232
- const requireFromRoot = createRequire(path.join(process.cwd(), 'package.json'));
233
- const seenExports = new Set<string>();
234
- const statements: string[] = [];
101
+ getDependencies(): AssetDefinition[] {
102
+ const reactDomRuntimeInteropPlugin = createReactDomRuntimeInteropPlugin();
103
+ const dependencies: AssetDefinition[] = [];
235
104
 
236
- for (const module of modules) {
237
- if (module.defaultExport) {
238
- statements.push(`import __ecopages_default_export__ from '${module.specifier}';`);
239
- statements.push('export default __ecopages_default_export__;');
240
- }
105
+ for (const mode of ['production', 'development'] as const) {
106
+ const reactRuntimeAliasPlugin = createRuntimeSpecifierAliasPlugin(
107
+ {
108
+ react: buildBrowserRuntimeAssetUrl(this.getReactVendorFileName(mode)),
109
+ },
110
+ { name: `react-plugin-runtime-specifier-alias-${mode}` },
111
+ );
112
+ const reactDomBundlePlugins = [reactRuntimeAliasPlugin, reactDomRuntimeInteropPlugin].filter(
113
+ (plugin): plugin is EcoBuildPlugin => plugin !== null,
114
+ );
115
+ const runtimeAliasPlugin = this.createRuntimeAliasPlugin(mode);
116
+ const mappedSpecifiers = new Set(Object.keys(this.getSpecifierMap(mode)));
241
117
 
242
- const exportNames = this.getModuleExportNames(module.specifier, requireFromRoot).filter(
243
- (name) => !seenExports.has(name),
118
+ dependencies.push(
119
+ createBrowserRuntimeModuleAsset({
120
+ modules: [
121
+ { specifier: 'react', defaultExport: true },
122
+ { specifier: 'react/jsx-runtime' },
123
+ { specifier: 'react/jsx-dev-runtime' },
124
+ ],
125
+ name: 'react',
126
+ fileName: this.getReactVendorFileName(mode),
127
+ cacheDirName: `ecopages-react-runtime-${mode}`,
128
+ bundleOptions: {
129
+ define: this.createRuntimeDefines(mode),
130
+ },
131
+ }),
132
+ createBrowserRuntimeModuleAsset({
133
+ modules: [{ specifier: 'react-dom', defaultExport: true }, { specifier: 'react-dom/client' }],
134
+ name: 'react-dom',
135
+ fileName: this.getReactDomVendorFileName(mode),
136
+ cacheDirName: `ecopages-react-runtime-${mode}`,
137
+ bundleOptions: {
138
+ define: this.createRuntimeDefines(mode),
139
+ plugins: reactDomBundlePlugins,
140
+ },
141
+ }),
244
142
  );
245
143
 
246
- if (exportNames.length > 0) {
247
- statements.push(`export { ${exportNames.join(', ')} } from '${module.specifier}';`);
248
- for (const exportName of exportNames) {
249
- seenExports.add(exportName);
250
- }
144
+ if (this.config.routerAdapter) {
145
+ const unresolvedExternals = this.config.routerAdapter.bundle.externals.filter(
146
+ (external) => !mappedSpecifiers.has(external),
147
+ );
148
+
149
+ dependencies.push(
150
+ createBrowserRuntimeScriptAsset({
151
+ importPath: this.config.routerAdapter.bundle.importPath,
152
+ name: this.config.routerAdapter.bundle.outputName,
153
+ fileName: this.getRouterVendorFileName(mode),
154
+ bundleOptions: {
155
+ define: this.createRuntimeDefines(mode),
156
+ external: unresolvedExternals,
157
+ plugins: [runtimeAliasPlugin],
158
+ },
159
+ }),
160
+ );
251
161
  }
252
162
  }
253
163
 
254
- const filePath = path.join(tmpDir, fileName);
255
- fs.writeFileSync(filePath, statements.join('\n'), 'utf-8');
256
- return filePath;
257
- }
258
-
259
- private getModuleExportNames(specifier: string, requireFromRoot: ReturnType<typeof createRequire>): string[] {
260
- const moduleExports = requireFromRoot(specifier);
261
-
262
- return Object.keys(moduleExports)
263
- .filter((name) => name !== '__esModule' && name !== 'default')
264
- .filter((name) => this.isValidExportName(name))
265
- .sort();
164
+ return dependencies;
266
165
  }
267
166
 
268
- private isValidExportName(name: string): boolean {
269
- return /^[$A-Z_a-z][$\w]*$/.test(name);
167
+ createRuntimeAliasPlugin(mode = this.getCurrentRuntimeMode()): EcoBuildPlugin {
168
+ return createRuntimeSpecifierAliasPlugin(this.getSpecifierMap(mode), {
169
+ name: `react-plugin-runtime-alias-${mode}`,
170
+ })!;
270
171
  }
271
172
  }
@@ -3,6 +3,14 @@ import { dirname, extname, resolve } from "node:path";
3
3
  import { parseSync } from "oxc-parser";
4
4
  import { analyzeReachability } from "./reachability-analyzer";
5
5
  const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
6
+ const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = /* @__PURE__ */ new Set([
7
+ "cache",
8
+ "middleware",
9
+ "requires",
10
+ "metadata",
11
+ "staticProps",
12
+ "staticPaths"
13
+ ]);
6
14
  function isBareSpecifier(specifier) {
7
15
  if (specifier.startsWith(".")) return false;
8
16
  if (specifier.startsWith("/")) return false;
@@ -86,6 +94,62 @@ function parserLanguageForFile(filename) {
86
94
  if (extension === ".jsx") return "jsx";
87
95
  return "js";
88
96
  }
97
+ function getObjectPropertyKeyName(node) {
98
+ if (!node) return void 0;
99
+ if (node.type === "Identifier") return node.name;
100
+ if (node.type === "StringLiteral" || node.type === "Literal") {
101
+ return typeof node.value === "string" ? node.value : void 0;
102
+ }
103
+ return void 0;
104
+ }
105
+ function stripServerOnlyEcoPageOptions(source, program) {
106
+ const edits = [];
107
+ function walk(node) {
108
+ if (!node || typeof node !== "object") return;
109
+ if (Array.isArray(node)) {
110
+ for (const child of node) walk(child);
111
+ return;
112
+ }
113
+ if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "eco" && node.callee.property?.type === "Identifier" && node.callee.property.name === "page" && node.arguments?.[0]?.type === "ObjectExpression") {
114
+ const objectExpression = node.arguments[0];
115
+ const keptProperties = [];
116
+ let removedProperty = false;
117
+ for (const property of objectExpression.properties ?? []) {
118
+ if (property?.type === "Property") {
119
+ const keyName = getObjectPropertyKeyName(property.key);
120
+ if (keyName && SERVER_ONLY_ECO_PAGE_OPTION_KEYS.has(keyName)) {
121
+ removedProperty = true;
122
+ continue;
123
+ }
124
+ }
125
+ keptProperties.push(source.slice(property.start, property.end));
126
+ }
127
+ if (removedProperty) {
128
+ const replacement = keptProperties.length > 0 ? `{ ${keptProperties.join(", ")} }` : "{}";
129
+ edits.push({
130
+ start: objectExpression.start,
131
+ end: objectExpression.end,
132
+ replacement
133
+ });
134
+ }
135
+ }
136
+ for (const key in node) {
137
+ if (key !== "type" && key !== "start" && key !== "end") {
138
+ walk(node[key]);
139
+ }
140
+ }
141
+ }
142
+ walk(program);
143
+ if (edits.length === 0) {
144
+ return { transformed: source, modified: false };
145
+ }
146
+ edits.sort((a, b) => b.start - a.start);
147
+ let transformed = source;
148
+ for (const edit of edits) {
149
+ transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
150
+ }
151
+ return { transformed, modified: true };
152
+ }
89
153
  function normalizeRequestedExportsKey(pathname) {
90
154
  let normalized = pathname.replace(/\\/g, "/");
91
155
  normalized = normalized.replace(/\.(tsx?|jsx?)$/i, "");
@@ -341,13 +405,26 @@ function transformModuleImports(source, filename, globallyAllowed, requestedExpo
341
405
  }
342
406
  walkImports(program);
343
407
  if (edits.length === 0) {
344
- return { transformed: source, modified: false };
408
+ return stripServerOnlyEcoPageOptions(source, program);
345
409
  }
346
410
  edits.sort((a, b) => b.start - a.start);
347
411
  let transformed = source;
348
412
  for (const edit of edits) {
349
413
  transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
350
414
  }
415
+ let reparsedResult;
416
+ try {
417
+ reparsedResult = parseSync(filename, transformed, {
418
+ sourceType: "module",
419
+ lang: parserLanguageForFile(filename)
420
+ });
421
+ } catch {
422
+ return { transformed, modified: true };
423
+ }
424
+ const strippedPageOptions = stripServerOnlyEcoPageOptions(transformed, reparsedResult.program);
425
+ if (strippedPageOptions.modified) {
426
+ return strippedPageOptions;
427
+ }
351
428
  return { transformed, modified: true };
352
429
  }
353
430
  function createClientGraphBoundaryPlugin(options) {
@@ -22,6 +22,14 @@ import { parseSync } from 'oxc-parser';
22
22
  import { analyzeReachability } from './reachability-analyzer';
23
23
 
24
24
  const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
25
+ const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = new Set([
26
+ 'cache',
27
+ 'middleware',
28
+ 'requires',
29
+ 'metadata',
30
+ 'staticProps',
31
+ 'staticPaths',
32
+ ]);
25
33
 
26
34
  /**
27
35
  * Configuration options for the Client Graph Boundary esbuild plugin.
@@ -205,6 +213,104 @@ function parserLanguageForFile(filename: string): 'js' | 'jsx' | 'ts' | 'tsx' {
205
213
  return 'js';
206
214
  }
207
215
 
216
+ /**
217
+ * Extracts a static property key name from an object literal property node.
218
+ *
219
+ * The client graph boundary rewrite only strips known `eco.page(...)` keys when
220
+ * it can prove the property name statically. Computed or otherwise dynamic keys
221
+ * are ignored so the transform remains conservative.
222
+ *
223
+ * @param node - OXC AST node representing an object property key.
224
+ * @returns Static property key name when it can be resolved, otherwise `undefined`.
225
+ */
226
+ function getObjectPropertyKeyName(node: any): string | undefined {
227
+ if (!node) return undefined;
228
+ if (node.type === 'Identifier') return node.name;
229
+ if (node.type === 'StringLiteral' || node.type === 'Literal') {
230
+ return typeof node.value === 'string' ? node.value : undefined;
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ /**
236
+ * Removes server-only `eco.page(...)` options from browser-bound modules.
237
+ *
238
+ * Import pruning alone is not sufficient because a page module can still retain
239
+ * references to stripped server imports through config fields like `middleware`
240
+ * or `metadata`. This pass rewrites the `eco.page(...)` object literal so only
241
+ * browser-relevant properties remain.
242
+ *
243
+ * @param source - Original or already-transformed module source.
244
+ * @param program - Parsed OXC program for the same source text.
245
+ * @returns Updated source plus a flag indicating whether any rewrite occurred.
246
+ */
247
+ function stripServerOnlyEcoPageOptions(source: string, program: any): { transformed: string; modified: boolean } {
248
+ const edits: { start: number; end: number; replacement: string }[] = [];
249
+
250
+ function walk(node: any) {
251
+ if (!node || typeof node !== 'object') return;
252
+ if (Array.isArray(node)) {
253
+ for (const child of node) walk(child);
254
+ return;
255
+ }
256
+
257
+ if (
258
+ node.type === 'CallExpression' &&
259
+ node.callee?.type === 'MemberExpression' &&
260
+ node.callee.object?.type === 'Identifier' &&
261
+ node.callee.object.name === 'eco' &&
262
+ node.callee.property?.type === 'Identifier' &&
263
+ node.callee.property.name === 'page' &&
264
+ node.arguments?.[0]?.type === 'ObjectExpression'
265
+ ) {
266
+ const objectExpression = node.arguments[0];
267
+ const keptProperties: string[] = [];
268
+ let removedProperty = false;
269
+
270
+ for (const property of objectExpression.properties ?? []) {
271
+ if (property?.type === 'Property') {
272
+ const keyName = getObjectPropertyKeyName(property.key);
273
+ if (keyName && SERVER_ONLY_ECO_PAGE_OPTION_KEYS.has(keyName)) {
274
+ removedProperty = true;
275
+ continue;
276
+ }
277
+ }
278
+
279
+ keptProperties.push(source.slice(property.start, property.end));
280
+ }
281
+
282
+ if (removedProperty) {
283
+ const replacement = keptProperties.length > 0 ? `{ ${keptProperties.join(', ')} }` : '{}';
284
+ edits.push({
285
+ start: objectExpression.start,
286
+ end: objectExpression.end,
287
+ replacement,
288
+ });
289
+ }
290
+ }
291
+
292
+ for (const key in node) {
293
+ if (key !== 'type' && key !== 'start' && key !== 'end') {
294
+ walk(node[key]);
295
+ }
296
+ }
297
+ }
298
+
299
+ walk(program);
300
+
301
+ if (edits.length === 0) {
302
+ return { transformed: source, modified: false };
303
+ }
304
+
305
+ edits.sort((a, b) => b.start - a.start);
306
+ let transformed = source;
307
+ for (const edit of edits) {
308
+ transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
309
+ }
310
+
311
+ return { transformed, modified: true };
312
+ }
313
+
208
314
  /**
209
315
  * Tracks the subset of exports that a downstream local module is allowed to expose.
210
316
  *
@@ -610,7 +716,7 @@ function transformModuleImports(
610
716
  walkImports(program);
611
717
 
612
718
  if (edits.length === 0) {
613
- return { transformed: source, modified: false };
719
+ return stripServerOnlyEcoPageOptions(source, program);
614
720
  }
615
721
 
616
722
  edits.sort((a, b) => b.start - a.start);
@@ -619,6 +725,21 @@ function transformModuleImports(
619
725
  transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
620
726
  }
621
727
 
728
+ let reparsedResult;
729
+ try {
730
+ reparsedResult = parseSync(filename, transformed, {
731
+ sourceType: 'module',
732
+ lang: parserLanguageForFile(filename),
733
+ });
734
+ } catch {
735
+ return { transformed, modified: true };
736
+ }
737
+
738
+ const strippedPageOptions = stripServerOnlyEcoPageOptions(transformed, reparsedResult.program);
739
+ if (strippedPageOptions.modified) {
740
+ return strippedPageOptions;
741
+ }
742
+
622
743
  return { transformed, modified: true };
623
744
  }
624
745
 
@@ -43,7 +43,18 @@ export type IslandHydrationScriptOptions = {
43
43
  };
44
44
  /**
45
45
  * Creates a hydration script for client-side React hydration.
46
- * Generates appropriate script based on environment and router configuration.
46
+ *
47
+ * Why this dispatcher exists:
48
+ * the runtime matrix is small but behaviorally different across development vs
49
+ * production and router vs non-router pages. Keeping that branch here preserves
50
+ * a compact public API while allowing each emitted script to stay focused.
51
+ *
52
+ * Selection rules:
53
+ * - development uses readable scripts with HMR hooks
54
+ * - production uses minified equivalents
55
+ * - router presence decides whether page updates flow through the router runtime
56
+ * or rebuild directly from the page module
57
+ *
47
58
  * @param options - Configuration options for script generation
48
59
  * @returns The generated hydration script as a string
49
60
  */
@@ -63,8 +74,14 @@ export declare function createHydrationScript(options: HydrationScriptOptions):
63
74
  * - resolves the component export by metadata (`componentRef`, `componentFile`)
64
75
  * before falling back to default/first function export
65
76
  * - selects island root using `targetSelector`
77
+ * - replaces the SSR host with a dedicated client-owned container
66
78
  * - creates a fresh React root and renders with serialized `props`
67
79
  *
80
+ * Why it remounts instead of hydrating:
81
+ * island SSR intentionally avoids synthetic wrapper elements. The runtime swaps
82
+ * the authored SSR node for a dedicated client-owned container before mounting
83
+ * so the server markup stays clean while the client still gets a stable root.
84
+ *
68
85
  * @param options Island script generation options.
69
86
  * @returns Browser-executable JavaScript module source.
70
87
  */