@ecopages/react 0.2.0-alpha.8 → 0.2.0-beta.0

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 (77) hide show
  1. package/README.md +30 -13
  2. package/package.json +23 -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 +102 -18
  6. package/src/react-hmr-strategy.js +427 -50
  7. package/src/react-renderer.d.ts +100 -92
  8. package/src/react-renderer.js +356 -340
  9. package/src/react.constants.d.ts +1 -0
  10. package/src/react.constants.js +4 -0
  11. package/src/react.plugin.d.ts +25 -107
  12. package/src/react.plugin.js +109 -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/runtime/use-sync-external-store-with-selector.d.ts +3 -0
  17. package/src/runtime/use-sync-external-store-with-selector.js +56 -0
  18. package/src/services/pages-index.d.ts +64 -0
  19. package/src/services/pages-index.js +73 -0
  20. package/src/services/react-bundle.service.d.ts +24 -9
  21. package/src/services/react-bundle.service.js +35 -24
  22. package/src/services/react-hmr-page-metadata-cache.d.ts +10 -1
  23. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  24. package/src/services/react-hydration-asset.service.d.ts +28 -19
  25. package/src/services/react-hydration-asset.service.js +83 -64
  26. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  27. package/src/services/react-mdx-config-dependency.service.js +122 -0
  28. package/src/services/react-page-module.service.d.ts +8 -3
  29. package/src/services/react-page-module.service.js +33 -26
  30. package/src/services/react-page-payload.service.d.ts +46 -0
  31. package/src/services/react-page-payload.service.js +67 -0
  32. package/src/services/react-runtime-bundle.service.d.ts +9 -2
  33. package/src/services/react-runtime-bundle.service.js +77 -16
  34. package/src/utils/client-graph-boundary-cache.d.ts +108 -0
  35. package/src/utils/client-graph-boundary-cache.js +116 -0
  36. package/src/utils/client-graph-boundary-plugin.d.ts +13 -5
  37. package/src/utils/client-graph-boundary-plugin.js +63 -5
  38. package/src/utils/component-config-traversal.d.ts +36 -0
  39. package/src/utils/component-config-traversal.js +54 -0
  40. package/src/utils/declared-modules.d.ts +1 -1
  41. package/src/utils/declared-modules.js +7 -16
  42. package/src/utils/dynamic.test.browser.d.ts +1 -0
  43. package/src/utils/dynamic.test.browser.js +33 -0
  44. package/src/utils/hydration-scripts.d.ts +9 -5
  45. package/src/utils/hydration-scripts.js +119 -34
  46. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  47. package/src/utils/hydration-scripts.test.browser.js +198 -0
  48. package/src/utils/react-dom-runtime-interop-plugin.d.ts +1 -1
  49. package/src/utils/react-dom-runtime-interop-plugin.js +9 -0
  50. package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
  51. package/src/utils/{react-runtime-specifier-map.d.ts → react-runtime-alias-map.d.ts} +3 -1
  52. package/src/utils/react-runtime-alias-map.js +90 -0
  53. package/CHANGELOG.md +0 -27
  54. package/src/react-hmr-strategy.ts +0 -386
  55. package/src/react-renderer.ts +0 -803
  56. package/src/react.plugin.ts +0 -276
  57. package/src/router-adapter.ts +0 -95
  58. package/src/services/react-bundle.service.ts +0 -108
  59. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  60. package/src/services/react-hydration-asset.service.ts +0 -263
  61. package/src/services/react-page-module.service.ts +0 -224
  62. package/src/services/react-runtime-bundle.service.ts +0 -172
  63. package/src/utils/client-graph-boundary-plugin.ts +0 -831
  64. package/src/utils/client-only.ts +0 -27
  65. package/src/utils/declared-modules.ts +0 -99
  66. package/src/utils/dynamic.ts +0 -27
  67. package/src/utils/hmr-scripts.ts +0 -47
  68. package/src/utils/html-boundary.ts +0 -66
  69. package/src/utils/hydration-scripts.ts +0 -459
  70. package/src/utils/reachability-analyzer.ts +0 -593
  71. package/src/utils/react-dom-runtime-interop-plugin.ts +0 -33
  72. package/src/utils/react-mdx-loader-plugin.ts +0 -63
  73. package/src/utils/react-runtime-specifier-map.js +0 -37
  74. package/src/utils/react-runtime-specifier-map.ts +0 -45
  75. package/src/utils/use-sync-external-store-shim-plugin.d.ts +0 -5
  76. package/src/utils/use-sync-external-store-shim-plugin.js +0 -41
  77. package/src/utils/use-sync-external-store-shim-plugin.ts +0 -45
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Per-app persistent cache for `client-graph-boundary` plugin transforms.
3
+ *
4
+ * @remarks
5
+ * The plugin's `transformModuleImports` function is the second most
6
+ * expensive operation in the React build path (after `parseSync`).
7
+ * It walks the AST to find reachable exports and prunes forbidden
8
+ * imports. On a no-op rebuild (file unchanged, no plugin signature
9
+ * change) the entire walk can be skipped if the inputs are identical.
10
+ *
11
+ * This cache is owned by the {@link ReactPlugin} for the app's lifetime.
12
+ * It persists across HMR rebuilds but is invalidated per-file when the
13
+ * watcher detects a source change. The `requestedExports` registry
14
+ * remains per-build (it's mutated during cross-file propagation and
15
+ * must not accumulate stale state across builds).
16
+ *
17
+ * The cache is content-hashed (via `rapidhash`) so a `touch`/`utimes`
18
+ * does not invalidate a still-valid entry.
19
+ *
20
+ * **`rulesAdded` semantics:** the map holds the **after-state** of
21
+ * every registry key this transform touched — both newly added keys
22
+ * and keys that were grown (Set union) or promoted to `'*'`. On
23
+ * replay the cache applies each entry via the same merge rules the
24
+ * live transform uses (`mergeRequestedExportRules`), so a key that
25
+ * was already in the live registry when the transform ran and grew
26
+ * during the transform is correctly grown on replay against a fresh
27
+ * registry as well.
28
+ */
29
+ export type RequestedExportRules = Set<string> | '*';
30
+ export type CachedTransform = {
31
+ /** Hash of the source string at the time the transform was cached. */
32
+ sourceHash: number | bigint;
33
+ /** Hash of the globally-allowed modules map at the time the transform was cached. */
34
+ allowListHash: number | bigint;
35
+ /** The transformed source (or original if `modified` is false). */
36
+ transformed: string;
37
+ /** Whether the transform changed the source. */
38
+ modified: boolean;
39
+ /**
40
+ * Map of `requestedExports` keys this transform touched, mapped to
41
+ * their **after-state**. Includes:
42
+ * - newly-added keys
43
+ * - keys whose Set grew (union of pre-existing and newly reachable exports)
44
+ * - keys promoted to `'*'`
45
+ *
46
+ * On cache hit, each entry is replayed via `mergeRequestedExportRules`,
47
+ * which handles all three cases correctly. Defensively copied at
48
+ * `set()` time to prevent later mutation from corrupting the cache.
49
+ */
50
+ rulesAdded: Map<string, RequestedExportRules>;
51
+ };
52
+ /**
53
+ * LRU-bounded cache for client-graph-boundary transform results.
54
+ *
55
+ * One instance is owned by the React plugin for the app's lifetime and
56
+ * shared across all builds. The cache key is `(filePath, source, allowList)`
57
+ * so a file whose source or whose global allow list changes gets a fresh
58
+ * entry, while everything else hits.
59
+ */
60
+ export declare class ClientGraphBoundaryCache {
61
+ private readonly entries;
62
+ private readonly maxEntries;
63
+ private hits;
64
+ private misses;
65
+ constructor(maxEntries?: number);
66
+ /**
67
+ * Look up a cached transform for `filePath`.
68
+ *
69
+ * Returns `undefined` if the source or allow list has changed since
70
+ * the entry was stored. The caller is responsible for re-running the
71
+ * transform in that case and calling `set` to update the entry.
72
+ */
73
+ get(filePath: string, source: string, globallyAllowedSpecifiers: Iterable<string>): CachedTransform | undefined;
74
+ /**
75
+ * Store a transform result.
76
+ *
77
+ * Evicts the least recently used entry if the cache exceeds its
78
+ * capacity. Defensively copies any `Set`-typed rules in `rulesAdded`
79
+ * so later mutations to the caller's sets cannot corrupt the cache.
80
+ */
81
+ set(filePath: string, source: string, globallyAllowedSpecifiers: Iterable<string>, entry: Omit<CachedTransform, 'sourceHash' | 'allowListHash'>): void;
82
+ /**
83
+ * Invalidate a single file's entry. Call this from the file watcher
84
+ * when `filePath`'s content has changed.
85
+ */
86
+ invalidate(filePath: string): void;
87
+ /**
88
+ * Invalidate every file whose key matches a prefix. Useful for
89
+ * "anything under `src/pages/` changed" signals.
90
+ */
91
+ invalidateMatching(predicate: (filePath: string) => boolean): number;
92
+ /** Clear all entries. */
93
+ clear(): void;
94
+ /** Current cache size. */
95
+ get size(): number;
96
+ /** Hit/miss counters for observability. */
97
+ stats(): {
98
+ hits: number;
99
+ misses: number;
100
+ size: number;
101
+ hitRate: number;
102
+ };
103
+ }
104
+ /**
105
+ * Default shared cache. The React plugin owns one of these; tests can
106
+ * create their own.
107
+ */
108
+ export declare const clientGraphBoundaryCache: ClientGraphBoundaryCache;
@@ -0,0 +1,116 @@
1
+ import { rapidhash } from "@ecopages/core/utils/hash";
2
+ const DEFAULT_MAX_ENTRIES = 5e3;
3
+ function cloneRules(rules) {
4
+ return rules instanceof Set ? new Set(rules) : rules;
5
+ }
6
+ class ClientGraphBoundaryCache {
7
+ entries = /* @__PURE__ */ new Map();
8
+ maxEntries;
9
+ hits = 0;
10
+ misses = 0;
11
+ constructor(maxEntries = DEFAULT_MAX_ENTRIES) {
12
+ if (maxEntries <= 0) {
13
+ throw new Error(`ClientGraphBoundaryCache: maxEntries must be > 0, got ${maxEntries}`);
14
+ }
15
+ this.maxEntries = maxEntries;
16
+ }
17
+ /**
18
+ * Look up a cached transform for `filePath`.
19
+ *
20
+ * Returns `undefined` if the source or allow list has changed since
21
+ * the entry was stored. The caller is responsible for re-running the
22
+ * transform in that case and calling `set` to update the entry.
23
+ */
24
+ get(filePath, source, globallyAllowedSpecifiers) {
25
+ const sourceHash = rapidhash(source);
26
+ const allowListHash = hashAllowList(globallyAllowedSpecifiers);
27
+ const existing = this.entries.get(filePath);
28
+ if (existing && existing.sourceHash === sourceHash && existing.allowListHash === allowListHash) {
29
+ this.hits += 1;
30
+ this.entries.delete(filePath);
31
+ this.entries.set(filePath, existing);
32
+ return existing;
33
+ }
34
+ this.misses += 1;
35
+ return void 0;
36
+ }
37
+ /**
38
+ * Store a transform result.
39
+ *
40
+ * Evicts the least recently used entry if the cache exceeds its
41
+ * capacity. Defensively copies any `Set`-typed rules in `rulesAdded`
42
+ * so later mutations to the caller's sets cannot corrupt the cache.
43
+ */
44
+ set(filePath, source, globallyAllowedSpecifiers, entry) {
45
+ const sourceHash = rapidhash(source);
46
+ const allowListHash = hashAllowList(globallyAllowedSpecifiers);
47
+ const rulesAddedCopy = /* @__PURE__ */ new Map();
48
+ for (const [key, rules] of entry.rulesAdded) {
49
+ rulesAddedCopy.set(key, cloneRules(rules));
50
+ }
51
+ const full = {
52
+ sourceHash,
53
+ allowListHash,
54
+ transformed: entry.transformed,
55
+ modified: entry.modified,
56
+ rulesAdded: rulesAddedCopy
57
+ };
58
+ this.entries.set(filePath, full);
59
+ if (this.entries.size > this.maxEntries) {
60
+ const oldestKey = this.entries.keys().next().value;
61
+ if (oldestKey !== void 0) {
62
+ this.entries.delete(oldestKey);
63
+ }
64
+ }
65
+ }
66
+ /**
67
+ * Invalidate a single file's entry. Call this from the file watcher
68
+ * when `filePath`'s content has changed.
69
+ */
70
+ invalidate(filePath) {
71
+ this.entries.delete(filePath);
72
+ }
73
+ /**
74
+ * Invalidate every file whose key matches a prefix. Useful for
75
+ * "anything under `src/pages/` changed" signals.
76
+ */
77
+ invalidateMatching(predicate) {
78
+ let removed = 0;
79
+ for (const key of this.entries.keys()) {
80
+ if (predicate(key)) {
81
+ this.entries.delete(key);
82
+ removed += 1;
83
+ }
84
+ }
85
+ return removed;
86
+ }
87
+ /** Clear all entries. */
88
+ clear() {
89
+ this.entries.clear();
90
+ this.hits = 0;
91
+ this.misses = 0;
92
+ }
93
+ /** Current cache size. */
94
+ get size() {
95
+ return this.entries.size;
96
+ }
97
+ /** Hit/miss counters for observability. */
98
+ stats() {
99
+ const total = this.hits + this.misses;
100
+ return {
101
+ hits: this.hits,
102
+ misses: this.misses,
103
+ size: this.entries.size,
104
+ hitRate: total === 0 ? 0 : this.hits / total
105
+ };
106
+ }
107
+ }
108
+ const clientGraphBoundaryCache = new ClientGraphBoundaryCache();
109
+ function hashAllowList(specifiers) {
110
+ const sorted = Array.from(specifiers).sort();
111
+ return rapidhash(sorted.join("\n"));
112
+ }
113
+ export {
114
+ ClientGraphBoundaryCache,
115
+ clientGraphBoundaryCache
116
+ };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @module ClientGraphBoundaryPlugin
3
3
  *
4
- * This module defines the primary esbuild plugin responsible for securing the Ecopages
4
+ * This module defines the primary build plugin responsible for securing the Ecopages
5
5
  * isomorphic compilation pipeline. It ensures that backend-only code, sensitive Node.js APIs,
6
6
  * and massive server utilities do not accidentally leak into the browser bundle.
7
7
  *
@@ -14,9 +14,10 @@
14
14
  * Additionally, this plugin provides a build-time transform that statically resolves and
15
15
  * inlines `fs.readFileSync(path.resolve(...))` calls to prevent server/client data mismatches.
16
16
  */
17
- import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
17
+ import type { EcoBuildPlugin } from '@ecopages/core/plugins/integration-plugin';
18
+ import { ClientGraphBoundaryCache } from './client-graph-boundary-cache.js';
18
19
  /**
19
- * Configuration options for the Client Graph Boundary esbuild plugin.
20
+ * Configuration options for the Client Graph Boundary build plugin.
20
21
  *
21
22
  * This plugin serves as the primary security layer between server-only logic and the client-side JavaScript bundle.
22
23
  * It prevents Node.js built-ins (`node:fs`, `node:path`) and backend-exclusive dependencies (e.g. `pg`, `redis`)
@@ -29,12 +30,19 @@ type ClientGraphBoundaryOptions = {
29
30
  * Array of module specifiers that are explicitly whitelisted to be bundled in the client code.
30
31
  * This is typically populated by parsing `modules: ["..."]` declarations in React/Lit components.
31
32
  */
32
- declaredModules?: string[];
33
+ declaredModules?: readonly string[];
33
34
  /** Array of emergency escape-hatch specifiers that always bypass the boundary checks regardless of component declarations. */
34
35
  alwaysAllowSpecifiers?: string[];
36
+ /**
37
+ * Persistent per-app cache for transform results. Owned by the React
38
+ * plugin for the app's lifetime; survives across HMR rebuilds. When
39
+ * omitted, transforms are still memoized inside the plugin for the
40
+ * duration of a single build but not across builds.
41
+ */
42
+ cache?: ClientGraphBoundaryCache;
35
43
  };
36
44
  /**
37
- * Instantiates the client graph boundary esbuild plugin.
45
+ * Instantiates the client graph boundary build plugin.
38
46
  *
39
47
  * @param options - Configuration options for the graph boundary.
40
48
  * @returns The resulting `EcoBuildPlugin`.
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, extname, resolve } from "node:path";
3
- import { parseSync } from "oxc-parser";
4
- import { analyzeReachability } from "./reachability-analyzer";
3
+ import { cachedParseSync } from "@ecopages/core/cache";
4
+ import { ClientGraphBoundaryCache } from "./client-graph-boundary-cache.js";
5
+ import { analyzeReachability } from "./reachability-analyzer.js";
5
6
  const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
6
7
  const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = /* @__PURE__ */ new Set([
7
8
  "cache",
@@ -180,10 +181,38 @@ function mergeRequestedExportRules(registry, moduleKey, rules) {
180
181
  existing.add(rule);
181
182
  }
182
183
  }
184
+ function cloneRequestedExportRules(rules) {
185
+ return rules === "*" ? rules : new Set(rules);
186
+ }
187
+ function snapshotRegistry(registry) {
188
+ const out = /* @__PURE__ */ new Map();
189
+ for (const [key, rules] of registry) {
190
+ out.set(key, cloneRequestedExportRules(rules));
191
+ }
192
+ return out;
193
+ }
194
+ function diffRequestedExportRules(before, after) {
195
+ if (!before) {
196
+ return cloneRequestedExportRules(after);
197
+ }
198
+ if (before === "*") {
199
+ return void 0;
200
+ }
201
+ if (after === "*") {
202
+ return "*";
203
+ }
204
+ const addedRules = /* @__PURE__ */ new Set();
205
+ for (const rule of after) {
206
+ if (!before.has(rule)) {
207
+ addedRules.add(rule);
208
+ }
209
+ }
210
+ return addedRules.size > 0 ? addedRules : void 0;
211
+ }
183
212
  function transformModuleImports(source, filename, globallyAllowed, requestedExports) {
184
213
  let result;
185
214
  try {
186
- result = parseSync(filename, source, {
215
+ result = cachedParseSync(filename, source, {
187
216
  sourceType: "module",
188
217
  lang: parserLanguageForFile(filename)
189
218
  });
@@ -414,7 +443,7 @@ function transformModuleImports(source, filename, globallyAllowed, requestedExpo
414
443
  }
415
444
  let reparsedResult;
416
445
  try {
417
- reparsedResult = parseSync(filename, transformed, {
446
+ reparsedResult = cachedParseSync(filename, transformed, {
418
447
  sourceType: "module",
419
448
  lang: parserLanguageForFile(filename)
420
449
  });
@@ -432,11 +461,13 @@ function createClientGraphBoundaryPlugin(options) {
432
461
  name: "ecopages-client-graph-boundary",
433
462
  setup(build) {
434
463
  const absWorkingDir = options?.absWorkingDir ?? process.cwd();
464
+ const cache = options?.cache;
435
465
  const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
436
466
  const requestedExports = /* @__PURE__ */ new Map();
437
467
  for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
438
468
  globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), "*");
439
469
  }
470
+ const allowListForCache = Array.from(globallyDeclaredSources.keys()).sort();
440
471
  build.onLoad({ filter: SOURCE_FILE_FILTER }, (args) => {
441
472
  let source;
442
473
  try {
@@ -444,6 +475,17 @@ function createClientGraphBoundaryPlugin(options) {
444
475
  } catch {
445
476
  return void 0;
446
477
  }
478
+ if (cache) {
479
+ const cached = cache.get(args.path, source, allowListForCache);
480
+ if (cached) {
481
+ for (const [moduleKey, rules] of cached.rulesAdded) {
482
+ mergeRequestedExportRules(requestedExports, moduleKey, rules);
483
+ }
484
+ if (!cached.modified) return void 0;
485
+ const ext2 = extname(args.path).slice(1);
486
+ return { contents: cached.transformed, loader: ext2, resolveDir: dirname(args.path) };
487
+ }
488
+ }
447
489
  let transformed = source;
448
490
  let modified = false;
449
491
  if (source.includes("readFileSync")) {
@@ -472,6 +514,7 @@ function createClientGraphBoundaryPlugin(options) {
472
514
  );
473
515
  transformed = readFileTransformed;
474
516
  }
517
+ const registryBefore = snapshotRegistry(requestedExports);
475
518
  const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
476
519
  transformed,
477
520
  args.path,
@@ -482,9 +525,24 @@ function createClientGraphBoundaryPlugin(options) {
482
525
  modified = true;
483
526
  transformed = oxcTransformed;
484
527
  }
528
+ if (cache) {
529
+ const rulesAdded = /* @__PURE__ */ new Map();
530
+ for (const [key, afterRules] of requestedExports) {
531
+ const beforeRules = registryBefore.get(key);
532
+ const diff = diffRequestedExportRules(beforeRules, afterRules);
533
+ if (!diff) continue;
534
+ rulesAdded.set(key, diff);
535
+ }
536
+ const entry = {
537
+ transformed,
538
+ modified,
539
+ rulesAdded
540
+ };
541
+ cache.set(args.path, source, allowListForCache, entry);
542
+ }
485
543
  if (!modified) return void 0;
486
544
  const ext = extname(args.path).slice(1);
487
- return { contents: transformed, loader: ext };
545
+ return { contents: transformed, loader: ext, resolveDir: dirname(args.path) };
488
546
  });
489
547
  }
490
548
  };
@@ -0,0 +1,36 @@
1
+ import type { EcoComponent, EcoComponentConfig } from '@ecopages/core';
2
+ /**
3
+ * Walks a component config tree once, including nested layout configs and
4
+ * dependency component configs.
5
+ *
6
+ * The shared React integration code performs several different analyses over the
7
+ * same config graph. Centralizing the traversal keeps cycle handling and graph
8
+ * shape assumptions in one place instead of repeating them in the renderer and
9
+ * services.
10
+ */
11
+ export declare function walkConfigTree(config: EcoComponentConfig | undefined, visitor: (config: EcoComponentConfig) => void, visited?: Set<EcoComponentConfig>): void;
12
+ /**
13
+ * Walks a forest of root component configs using one shared visited set.
14
+ *
15
+ * This is useful when a page contributes multiple config roots, such as a page
16
+ * config plus a resolved layout config, and duplicate nested nodes should still
17
+ * be processed only once.
18
+ */
19
+ export declare function walkConfigForest(configs: Iterable<EcoComponentConfig | undefined>, visitor: (config: EcoComponentConfig) => void): void;
20
+ /**
21
+ * Collects values from a config tree while preserving the shared traversal and
22
+ * cycle protection behavior used across the React integration.
23
+ */
24
+ export declare function collectFromConfigTree<T>(config: EcoComponentConfig | undefined, collector: (config: EcoComponentConfig) => T[]): T[];
25
+ /**
26
+ * Collects values from multiple config roots with one shared visited set.
27
+ */
28
+ export declare function collectFromConfigForest<T>(configs: Iterable<EcoComponentConfig | undefined>, collector: (config: EcoComponentConfig) => T[]): T[];
29
+ /**
30
+ * Returns true when any node in the config tree matches the predicate.
31
+ */
32
+ export declare function someInConfigTree(config: EcoComponentConfig | undefined, predicate: (config: EcoComponentConfig) => boolean): boolean;
33
+ /**
34
+ * Reads config roots from partial components while tolerating undefined config.
35
+ */
36
+ export declare function getComponentConfigs(components: Partial<EcoComponent>[]): Array<EcoComponentConfig | undefined>;
@@ -0,0 +1,54 @@
1
+ function walkConfigTree(config, visitor, visited = /* @__PURE__ */ new Set()) {
2
+ if (!config || visited.has(config)) {
3
+ return;
4
+ }
5
+ visited.add(config);
6
+ visitor(config);
7
+ if (config.layout?.config) {
8
+ walkConfigTree(config.layout.config, visitor, visited);
9
+ }
10
+ for (const component of config.dependencies?.components ?? []) {
11
+ walkConfigTree(component.config, visitor, visited);
12
+ }
13
+ }
14
+ function walkConfigForest(configs, visitor) {
15
+ const visited = /* @__PURE__ */ new Set();
16
+ for (const config of configs) {
17
+ walkConfigTree(config, visitor, visited);
18
+ }
19
+ }
20
+ function collectFromConfigTree(config, collector) {
21
+ const values = [];
22
+ walkConfigTree(config, (node) => {
23
+ values.push(...collector(node));
24
+ });
25
+ return values;
26
+ }
27
+ function collectFromConfigForest(configs, collector) {
28
+ const values = [];
29
+ walkConfigForest(configs, (node) => {
30
+ values.push(...collector(node));
31
+ });
32
+ return values;
33
+ }
34
+ function someInConfigTree(config, predicate) {
35
+ let matched = false;
36
+ walkConfigTree(config, (node) => {
37
+ if (matched) {
38
+ return;
39
+ }
40
+ matched = predicate(node);
41
+ });
42
+ return matched;
43
+ }
44
+ function getComponentConfigs(components) {
45
+ return components.map((component) => component.config);
46
+ }
47
+ export {
48
+ collectFromConfigForest,
49
+ collectFromConfigTree,
50
+ getComponentConfigs,
51
+ someInConfigTree,
52
+ walkConfigForest,
53
+ walkConfigTree
54
+ };
@@ -29,7 +29,7 @@ export declare function normalizeDeclaredModuleSources(modules?: string[]): stri
29
29
  * Recursively walks a component config tree (including layouts and nested
30
30
  * `dependencies.components`) to collect all declared module sources.
31
31
  */
32
- export declare function collectDeclaredModulesInConfig(config: EcoComponentConfig | undefined, visited?: Set<EcoComponentConfig>): string[];
32
+ export declare function collectDeclaredModulesInConfig(config: EcoComponentConfig | undefined): string[];
33
33
  /**
34
34
  * Collects declared module sources from an already imported page module.
35
35
  */
@@ -1,3 +1,4 @@
1
+ import { collectFromConfigTree } from "./component-config-traversal.js";
1
2
  function parseDeclaredModuleSource(value) {
2
3
  const source = value.trim();
3
4
  if (source.length === 0) return void 0;
@@ -16,21 +17,8 @@ function normalizeDeclaredModuleSources(modules) {
16
17
  }
17
18
  return Array.from(seen);
18
19
  }
19
- function collectDeclaredModulesInConfig(config, visited = /* @__PURE__ */ new Set()) {
20
- if (!config || visited.has(config)) {
21
- return [];
22
- }
23
- visited.add(config);
24
- const declarations = normalizeDeclaredModuleSources(config.dependencies?.modules);
25
- if (config.layout?.config) {
26
- declarations.push(...collectDeclaredModulesInConfig(config.layout.config, visited));
27
- }
28
- for (const component of config.dependencies?.components ?? []) {
29
- if (component.config) {
30
- declarations.push(...collectDeclaredModulesInConfig(component.config, visited));
31
- }
32
- }
33
- return declarations;
20
+ function collectDeclaredModulesInConfig(config) {
21
+ return collectFromConfigTree(config, (node) => normalizeDeclaredModuleSources(node.dependencies?.modules));
34
22
  }
35
23
  function collectPageDeclaredModulesFromModule(pageModule) {
36
24
  const declarations = [
@@ -41,7 +29,10 @@ function collectPageDeclaredModulesFromModule(pageModule) {
41
29
  }
42
30
  async function collectPageDeclaredModules(pagePath) {
43
31
  try {
44
- const pageModule = await import(pagePath);
32
+ const pageModule = await import(
33
+ /* @vite-ignore */
34
+ pagePath
35
+ );
45
36
  return collectPageDeclaredModulesFromModule(pageModule);
46
37
  } catch {
47
38
  return [];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { Suspense } from "react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { cleanup, render, screen } from "@testing-library/react";
5
+ import { dynamic } from "./dynamic.js";
6
+ function createDeferredImport() {
7
+ let resolve;
8
+ const promise = new Promise((innerResolve) => {
9
+ resolve = innerResolve;
10
+ });
11
+ return {
12
+ promise,
13
+ resolve
14
+ };
15
+ }
16
+ describe("dynamic", () => {
17
+ afterEach(() => {
18
+ cleanup();
19
+ });
20
+ it("returns a browser lazy component that resolves through Suspense", async () => {
21
+ const deferredImport = createDeferredImport();
22
+ const DynamicComponent = dynamic(() => deferredImport.promise);
23
+ render(
24
+ /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx("span", { children: "Loading dynamic component" }), children: /* @__PURE__ */ jsx(DynamicComponent, {}) })
25
+ );
26
+ expect(screen.getByText("Loading dynamic component")).toBeTruthy();
27
+ deferredImport.resolve({
28
+ default: () => /* @__PURE__ */ jsx("span", { children: "Dynamic content" })
29
+ });
30
+ expect(await screen.findByText("Dynamic content")).toBeTruthy();
31
+ expect(screen.queryByText("Loading dynamic component")).toBeNull();
32
+ });
33
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Hydration script generators for React pages.
3
- * These functions create the client-side scripts that hydrate React components.
3
+ * These functions create the page entry modules that hydrate React routes.
4
4
  * @module
5
5
  */
6
6
  import type { ReactRouterAdapter } from '../router-adapter.js';
@@ -8,8 +8,12 @@ import type { ReactRouterAdapter } from '../router-adapter.js';
8
8
  * Options for generating a hydration script.
9
9
  */
10
10
  export type HydrationScriptOptions = {
11
- /** The import path for the bundled page component */
11
+ /** The module path imported by the page entry module. */
12
12
  importPath: string;
13
+ /** Browser expression that resolves to the page module URL the router should import. */
14
+ pageModuleUrlExpression?: string;
15
+ /** Stable id of the page entry script tag in the document. */
16
+ scriptId: string;
13
17
  /** Direct import path for React runtime module */
14
18
  reactImportPath: string;
15
19
  /** Direct import path for react-dom/client runtime module */
@@ -26,14 +30,14 @@ export type HydrationScriptOptions = {
26
30
  export type IslandHydrationScriptOptions = {
27
31
  /** Bundled browser module path for the island component. */
28
32
  importPath: string;
33
+ /** Stable id of the island bootstrap script tag in the document. */
34
+ scriptId: string;
29
35
  /** Browser import path for React runtime. */
30
36
  reactImportPath: string;
31
37
  /** Browser import path for react-dom/client runtime. */
32
38
  reactDomClientImportPath: string;
33
- /** Selector that resolves to the SSR root element for this island instance. */
39
+ /** Selector that resolves to all SSR root elements for this island component. */
34
40
  targetSelector: string;
35
- /** Serialized component props emitted at render time. */
36
- props: Record<string, unknown>;
37
41
  /** Optional stable component id used to resolve named exports reliably. */
38
42
  componentRef?: string;
39
43
  /** Optional source file hint used as fallback for component resolution. */