@ecopages/react 0.2.0-alpha.9 → 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.
- package/README.md +30 -13
- package/package.json +23 -12
- package/src/eco-embed.d.ts +11 -0
- package/src/eco-embed.js +11 -0
- package/src/react-hmr-strategy.d.ts +102 -18
- package/src/react-hmr-strategy.js +427 -50
- package/src/react-renderer.d.ts +100 -92
- package/src/react-renderer.js +356 -340
- package/src/react.constants.d.ts +1 -0
- package/src/react.constants.js +4 -0
- package/src/react.plugin.d.ts +25 -107
- package/src/react.plugin.js +109 -61
- package/src/react.types.d.ts +88 -0
- package/src/react.types.js +0 -0
- package/src/router-adapter.d.ts +7 -14
- package/src/runtime/use-sync-external-store-with-selector.d.ts +3 -0
- package/src/runtime/use-sync-external-store-with-selector.js +56 -0
- package/src/services/pages-index.d.ts +64 -0
- package/src/services/pages-index.js +73 -0
- package/src/services/react-bundle.service.d.ts +24 -9
- package/src/services/react-bundle.service.js +35 -24
- package/src/services/react-hmr-page-metadata-cache.d.ts +10 -1
- package/src/services/react-hmr-page-metadata-cache.js +18 -2
- package/src/services/react-hydration-asset.service.d.ts +28 -19
- package/src/services/react-hydration-asset.service.js +83 -64
- package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
- package/src/services/react-mdx-config-dependency.service.js +122 -0
- package/src/services/react-page-module.service.d.ts +8 -3
- package/src/services/react-page-module.service.js +33 -26
- package/src/services/react-page-payload.service.d.ts +46 -0
- package/src/services/react-page-payload.service.js +67 -0
- package/src/services/react-runtime-bundle.service.d.ts +9 -2
- package/src/services/react-runtime-bundle.service.js +77 -16
- package/src/utils/client-graph-boundary-cache.d.ts +108 -0
- package/src/utils/client-graph-boundary-cache.js +116 -0
- package/src/utils/client-graph-boundary-plugin.d.ts +13 -5
- package/src/utils/client-graph-boundary-plugin.js +63 -5
- package/src/utils/component-config-traversal.d.ts +36 -0
- package/src/utils/component-config-traversal.js +54 -0
- package/src/utils/declared-modules.d.ts +1 -1
- package/src/utils/declared-modules.js +7 -16
- package/src/utils/dynamic.test.browser.d.ts +1 -0
- package/src/utils/dynamic.test.browser.js +33 -0
- package/src/utils/hydration-scripts.d.ts +9 -5
- package/src/utils/hydration-scripts.js +119 -34
- package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
- package/src/utils/hydration-scripts.test.browser.js +198 -0
- package/src/utils/react-dom-runtime-interop-plugin.d.ts +1 -1
- package/src/utils/react-dom-runtime-interop-plugin.js +9 -0
- package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
- package/src/utils/{react-runtime-specifier-map.d.ts → react-runtime-alias-map.d.ts} +3 -1
- package/src/utils/react-runtime-alias-map.js +90 -0
- package/CHANGELOG.md +0 -27
- package/src/react-hmr-strategy.ts +0 -386
- package/src/react-renderer.ts +0 -803
- package/src/react.plugin.ts +0 -276
- package/src/router-adapter.ts +0 -95
- package/src/services/react-bundle.service.ts +0 -108
- package/src/services/react-hmr-page-metadata-cache.ts +0 -24
- package/src/services/react-hydration-asset.service.ts +0 -263
- package/src/services/react-page-module.service.ts +0 -224
- package/src/services/react-runtime-bundle.service.ts +0 -172
- package/src/utils/client-graph-boundary-plugin.ts +0 -831
- package/src/utils/client-only.ts +0 -27
- package/src/utils/declared-modules.ts +0 -99
- package/src/utils/dynamic.ts +0 -27
- package/src/utils/hmr-scripts.ts +0 -47
- package/src/utils/html-boundary.ts +0 -66
- package/src/utils/hydration-scripts.ts +0 -459
- package/src/utils/reachability-analyzer.ts +0 -593
- package/src/utils/react-dom-runtime-interop-plugin.ts +0 -33
- package/src/utils/react-mdx-loader-plugin.ts +0 -63
- package/src/utils/react-runtime-specifier-map.js +0 -37
- package/src/utils/react-runtime-specifier-map.ts +0 -45
- package/src/utils/use-sync-external-store-shim-plugin.d.ts +0 -5
- package/src/utils/use-sync-external-store-shim-plugin.js +0 -41
- 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
|
|
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/
|
|
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
|
|
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
|
|
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 {
|
|
4
|
-
import {
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
20
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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. */
|