partial-react 0.0.1 → 0.0.3
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 +57 -0
- package/package.json +5 -4
- package/src/compiler.ts +10 -8
- package/src/hookSignature.ts +1 -1
- package/src/importMap.ts +441 -30
- package/src/importProbe.ts +62 -0
- package/src/remoteModule.ts +274 -0
- package/src/{runtime.tsx → runtime.ts} +69 -20
- package/src/state.ts +11 -3
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# partial-react
|
|
2
|
+
|
|
3
|
+
Renderer for generated React/TSX.
|
|
4
|
+
|
|
5
|
+
## Input
|
|
6
|
+
|
|
7
|
+
`GenUIRenderer` accepts TSX source strings:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { GenUIRenderer } from "partial-react";
|
|
11
|
+
|
|
12
|
+
const renderer = await GenUIRenderer.create(document.getElementById("preview"), {
|
|
13
|
+
preserveStateOnUpdate: true,
|
|
14
|
+
callbacks: {
|
|
15
|
+
onRendered: (_component, code) => console.log(code),
|
|
16
|
+
onError: (error, phase) => console.error(phase, error),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
renderer.render("export default function App() { return <div>Hello world</div>; }");
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Output
|
|
24
|
+
|
|
25
|
+
- The target element receives a React preview.
|
|
26
|
+
- `onReady(component, url, code)` fires after a module compiles and imports.
|
|
27
|
+
- `onRendered(component, code, serial)` fires after React commits.
|
|
28
|
+
- `onError(error, phase)` reports `"transform"`, `"compile"`, or `"render"`.
|
|
29
|
+
|
|
30
|
+
## Imports
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { GenUIRenderer } from "partial-react";
|
|
34
|
+
import { createTsxCompiler } from "partial-react/compiler";
|
|
35
|
+
import { mergeFallbackImports, prepareRendererImportMap } from "partial-react/import-map";
|
|
36
|
+
import { useGenUIRenderContext } from "partial-react/render-context";
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## State and Streaming
|
|
40
|
+
|
|
41
|
+
- Keep `preserveStateOnUpdate: true` for normal previews. It preserves useful React state and DOM continuity across regenerated code.
|
|
42
|
+
- Set `preserveStateOnUpdate: false` only when every update should remount from scratch, usually while debugging renderer state bugs.
|
|
43
|
+
- Use the preview wrapper's `streaming` prop (`GenUIPreviewHost` / `StaticGenUIRenderer`) when the input is still growing or being patched in place. It treats the code as partial TSX and avoids surfacing transient mid-stream errors.
|
|
44
|
+
- Leave `streaming` off for complete snapshots, final answers, history replay, and deterministic one-shot renders.
|
|
45
|
+
|
|
46
|
+
## Corner Cases
|
|
47
|
+
|
|
48
|
+
- `render(code)` replaces the current buffer with a complete TSX module.
|
|
49
|
+
- `clear({ preserveVisualState: true })` clears pending code but leaves the last visual tree in place.
|
|
50
|
+
- Missing default exports fail at compile/import time.
|
|
51
|
+
- Render failures call `onError` and, with state preservation on, try to restore the last good component.
|
|
52
|
+
|
|
53
|
+
## Test
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
bun test lib/partial-react
|
|
57
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partial-react",
|
|
3
3
|
"description": "A state-preserving renderer for prefix-growing streaming React/TSX.",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
"directory": "lib/partial-react"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
|
-
".": "./src/runtime.
|
|
12
|
+
".": "./src/runtime.ts",
|
|
13
13
|
"./compiler": "./src/compiler.ts",
|
|
14
14
|
"./import-map": "./src/importMap.ts",
|
|
15
|
+
"./remote-module": "./src/remoteModule.ts",
|
|
15
16
|
"./render-context": "./src/renderContext.ts"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
@@ -19,8 +20,8 @@
|
|
|
19
20
|
"!src/**/*.test.*"
|
|
20
21
|
],
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@esm.sh/tsx": "^1.5.
|
|
23
|
-
"partial-tsx": "0.0.
|
|
23
|
+
"@esm.sh/tsx": "^1.5.3",
|
|
24
|
+
"partial-tsx": "0.0.3"
|
|
24
25
|
},
|
|
25
26
|
"peerDependencies": {
|
|
26
27
|
"react": "^19.0.0",
|
package/src/compiler.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import initTsx, { transform as transformTsx } from "@esm.sh/tsx";
|
|
2
2
|
import tsxWasmUrl from "@esm.sh/tsx/pkg/tsx_bg.wasm?url";
|
|
3
3
|
import { normalizeGeneratedTsx } from "partial-tsx";
|
|
4
|
-
import type
|
|
4
|
+
import { rewriteImportMetaResolveSpecifiers, type RendererImportMap } from "./importMap";
|
|
5
5
|
|
|
6
|
-
export type CompileOptions = { importMap?: RendererImportMap; partial?: boolean; previousCode?: string; filename?: string; jsxImportSource?: string };
|
|
6
|
+
export type CompileOptions = { importMap?: RendererImportMap; partial?: boolean; previousCode?: string; filename?: string; lang?: "ts" | "tsx" | "js" | "jsx"; jsxImportSource?: string };
|
|
7
7
|
export type CompileResult = { code: string; source: string; changed: boolean };
|
|
8
8
|
export type TsxCompiler = { compile: (code: string, options?: CompileOptions) => Promise<CompileResult> };
|
|
9
9
|
|
|
10
10
|
let initPromise: Promise<unknown> | null = null;
|
|
11
11
|
const loadNodeWasm = async () => {
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
return Bun.file(
|
|
12
|
+
// `import.meta.resolve` yields a `file://` URL under both Bun and Node/Vite SSR, so `new URL` is safe — don't "fix" it with pathToFileURL.
|
|
13
|
+
const wasmUrl = new URL(import.meta.resolve("@esm.sh/tsx/pkg/tsx_bg.wasm"));
|
|
14
|
+
if (typeof Bun !== "undefined") return Bun.file(wasmUrl).arrayBuffer();
|
|
15
|
+
return (await import("node:fs/promises")).readFile(wasmUrl);
|
|
15
16
|
};
|
|
16
17
|
const initCompiler = () =>
|
|
17
18
|
(initPromise ??= (async () => {
|
|
18
19
|
try {
|
|
19
20
|
if (typeof window === "undefined") return initTsx(await loadNodeWasm());
|
|
20
21
|
} catch {
|
|
21
|
-
//
|
|
22
|
+
// Environments without a filesystem (e.g. web workers) fall back to Vite's asset URL below.
|
|
22
23
|
}
|
|
23
24
|
return initTsx(tsxWasmUrl);
|
|
24
25
|
})().catch((error) => {
|
|
@@ -30,8 +31,9 @@ export function createTsxCompiler(): TsxCompiler {
|
|
|
30
31
|
return {
|
|
31
32
|
async compile(code, options = {}) {
|
|
32
33
|
await initCompiler();
|
|
33
|
-
const source = options.partial ? normalizeGeneratedTsx(code) : code;
|
|
34
|
-
const
|
|
34
|
+
const source = options.partial ? normalizeGeneratedTsx(code, { mode: "streaming" }) : code;
|
|
35
|
+
const resolvedSource = options.importMap?.imports ? rewriteImportMetaResolveSpecifiers(source, options.importMap.imports) : source;
|
|
36
|
+
const result = transformTsx({ filename: options.filename ?? "_.tsx", code: resolvedSource, lang: options.lang, target: "es2022", importMap: options.importMap, jsxImportSource: options.jsxImportSource ?? "react" });
|
|
35
37
|
const compiled = new TextDecoder().decode(result.code);
|
|
36
38
|
return { code: compiled, source, changed: compiled !== options.previousCode };
|
|
37
39
|
},
|
package/src/hookSignature.ts
CHANGED
|
@@ -146,7 +146,7 @@ const shouldStartJsxTag = (code: string, index: number) => {
|
|
|
146
146
|
return jsxPrefixKeywords.has(word);
|
|
147
147
|
};
|
|
148
148
|
const scanHookSignatureJs = (code: string, hooks: string[], context: HookSignatureContext, start = 0, end = code.length): number => {
|
|
149
|
-
for (let index = start; index < end;
|
|
149
|
+
for (let index = start; index < end;) {
|
|
150
150
|
const char = code[index];
|
|
151
151
|
if (char === "/" && code[index + 1] === "/") index = skipLineComment(code, index);
|
|
152
152
|
else if (char === "/" && code[index + 1] === "*") index = skipBlockComment(code, index);
|
package/src/importMap.ts
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
|
-
export type RendererImportMap = { imports?: Record<string, string
|
|
1
|
+
export type RendererImportMap = { imports?: Record<string, string>; scopes?: Record<string, Record<string, string>>; styles?: string[] };
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// A provider contributes import-map entries. `base` providers always run; `fallback` providers only run when earlier
|
|
4
|
+
// providers left some required specifier unresolved, and only see (and should answer for) those `missing` specifiers.
|
|
5
|
+
export type ImportMapProviderContext = { code: string; missing: string[]; imports?: Readonly<Record<string, string>> };
|
|
6
|
+
export type ImportMapProvider = { kind?: "base" | "fallback"; prefetch?: () => Promise<unknown> | void; resolve: (context: ImportMapProviderContext) => RendererImportMap | Promise<RendererImportMap> };
|
|
7
|
+
|
|
8
|
+
// Packages whose identity must be shared with a dependent's own bundled copy — see `toEsmShImportUrl`.
|
|
9
|
+
const SHARED_INSTANCE_PACKAGES = new Set(["three"]);
|
|
10
|
+
const DEFAULT_ESM_SH_EXTERNALS = ["react", "react-dom", "scheduler"] as const;
|
|
11
|
+
export const RUNTIME_SINGLETON_SPECIFIERS = ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime", "scheduler"] as const;
|
|
12
|
+
export type RuntimeSingletonSpecifier = (typeof RUNTIME_SINGLETON_SPECIFIERS)[number];
|
|
13
|
+
export const isRuntimeSingletonSpecifier = (specifier: string): specifier is RuntimeSingletonSpecifier => RUNTIME_SINGLETON_SPECIFIERS.includes(specifier as RuntimeSingletonSpecifier);
|
|
14
|
+
// A trailing-slash import-map key also owns matching singleton subpaths.
|
|
15
|
+
export const isRuntimeSingletonImportMapKey = (specifier: string) => isRuntimeSingletonSpecifier(specifier) || (specifier.endsWith("/") && RUNTIME_SINGLETON_SPECIFIERS.some((runtimeSpecifier) => runtimeSpecifier.startsWith(specifier)));
|
|
4
16
|
const IDENTIFIER_CHAR_PATTERN = /[\w$]/;
|
|
5
17
|
const REGEX_PREFIX_WORDS = new Set(["return", "throw", "case", "delete", "typeof", "void", "new", "in", "of", "await", "yield"]);
|
|
6
18
|
const LOCAL_EXPORT_KEYWORDS = ["abstract", "async", "class", "const", "declare", "default", "enum", "function", "interface", "let", "namespace", "var"];
|
|
7
19
|
|
|
8
20
|
export const isBareModuleSpecifier = (specifier: string) => !specifier.startsWith(".") && !specifier.startsWith("/") && !/^(?:[a-z][a-z\d+.-]*:|#)/i.test(specifier);
|
|
9
21
|
export const getBarePackageName = (specifier: string) => (specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0] || specifier);
|
|
22
|
+
// A shared-instance package must stay on its canonical esm.sh URL end to end: `toEsmShImportUrl` keeps it unbundled so
|
|
23
|
+
// it resolves to the same `/three@x/es2022/three.mjs` a dependent's bundle imports, and callers must not blob it either
|
|
24
|
+
// — a blob: copy is a distinct module object, which is exactly the duplication the unbundled path exists to avoid.
|
|
25
|
+
export const isSharedInstancePackage = (specifier: string) => SHARED_INSTANCE_PACKAGES.has(getBarePackageName(specifier));
|
|
26
|
+
// Shared #1178 contract: /src/… and /node_modules/… targets are root-relative disk paths each engine rebases against
|
|
27
|
+
// its own root (dev: Vite's resolved root; build: cwd). Single-sourced so a new local prefix lands in both engines.
|
|
28
|
+
export const isLocalRootRelativeTarget = (target: string) => target.startsWith("/src/") || target.startsWith("/node_modules/");
|
|
10
29
|
const canUseEsmShFallback = (specifier: string) => isBareModuleSpecifier(specifier) && !specifier.startsWith("@/");
|
|
11
30
|
|
|
12
31
|
const isKeywordAt = (code: string, index: number, keyword: string) => code.startsWith(keyword, index) && !IDENTIFIER_CHAR_PATTERN.test(code[index - 1] ?? "") && !IDENTIFIER_CHAR_PATTERN.test(code[index + keyword.length] ?? "");
|
|
@@ -52,11 +71,13 @@ const readStringLiteral = (code: string, index: number) => {
|
|
|
52
71
|
index += 2;
|
|
53
72
|
continue;
|
|
54
73
|
}
|
|
55
|
-
if (char === quote) return { value, end: index + 1 };
|
|
74
|
+
if (char === quote) return { value, end: index + 1, terminated: true };
|
|
56
75
|
value += char;
|
|
57
76
|
index += 1;
|
|
58
77
|
}
|
|
59
|
-
|
|
78
|
+
// Unterminated literal (a streamed import mid-chunk, e.g. `import X from "https://x/Foo.ts`). Report end so scanning
|
|
79
|
+
// still advances past it, but flag it so callers don't treat a half-typed specifier as a real import to fetch.
|
|
80
|
+
return { value, end: code.length, terminated: false };
|
|
60
81
|
};
|
|
61
82
|
|
|
62
83
|
const skipTemplateLiteral = (code: string, index: number, onExpression?: (start: number, end: number) => void) => {
|
|
@@ -171,8 +192,9 @@ const readFromSpecifier = (code: string, index: number) => {
|
|
|
171
192
|
continue;
|
|
172
193
|
}
|
|
173
194
|
if (isKeywordAt(code, index, "from")) {
|
|
174
|
-
const
|
|
175
|
-
|
|
195
|
+
const start = skipWhitespaceAndComments(code, index + "from".length);
|
|
196
|
+
const literal = readStringLiteral(code, start);
|
|
197
|
+
if (literal) return { specifier: literal.value, start, end: literal.end, terminated: literal.terminated };
|
|
176
198
|
index += "from".length;
|
|
177
199
|
continue;
|
|
178
200
|
}
|
|
@@ -189,7 +211,99 @@ const readExportFromSpecifier = (code: string, index: number) => {
|
|
|
189
211
|
return readFromSpecifier(code, next);
|
|
190
212
|
};
|
|
191
213
|
|
|
192
|
-
|
|
214
|
+
// `.meta.resolve(` after the `import` keyword, returning the string-literal argument. Null means this `import.` is
|
|
215
|
+
// something else (`import.meta.url`, `import.meta.env`) and the caller should just skip past the dot.
|
|
216
|
+
const readImportMetaResolveSpecifier = (code: string, dotIndex: number) => {
|
|
217
|
+
let next = skipWhitespaceAndComments(code, dotIndex + 1);
|
|
218
|
+
if (!isKeywordAt(code, next, "meta")) return null;
|
|
219
|
+
next = skipWhitespaceAndComments(code, next + "meta".length);
|
|
220
|
+
if (code[next] !== ".") return null;
|
|
221
|
+
next = skipWhitespaceAndComments(code, next + 1);
|
|
222
|
+
if (!isKeywordAt(code, next, "resolve")) return null;
|
|
223
|
+
next = skipWhitespaceAndComments(code, next + "resolve".length);
|
|
224
|
+
if (code[next] !== "(") return null;
|
|
225
|
+
const start = skipWhitespaceAndComments(code, next + 1);
|
|
226
|
+
const literal = readStringLiteral(code, start);
|
|
227
|
+
return literal ? { ...literal, start } : { value: "", start, end: next, terminated: false };
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const readIdentifierWord = (code: string, index: number) => {
|
|
231
|
+
let end = index;
|
|
232
|
+
while (end < code.length && IDENTIFIER_CHAR_PATTERN.test(code[end])) end += 1;
|
|
233
|
+
return end > index ? { name: code.slice(index, end), end } : null;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Parses one static import clause right after the `import` keyword into the export names the module must provide
|
|
237
|
+
// ("default" for default imports; namespace and side-effect imports demand none). `notLinked` marks a type-only import,
|
|
238
|
+
// which survives specifier collection but is erased before the module is ever instantiated. Returns null on clauses it does not understand
|
|
239
|
+
// (streaming-truncated ones) so callers can fall back to the lenient readFromSpecifier.
|
|
240
|
+
const readImportBindings = (code: string, index: number): { specifier: string; names: string[]; start: number; end: number; terminated: boolean; notLinked?: boolean } | null => {
|
|
241
|
+
const names: string[] = [];
|
|
242
|
+
index = skipWhitespaceAndComments(code, index);
|
|
243
|
+
const sideEffect = readStringLiteral(code, index);
|
|
244
|
+
if (sideEffect) return sideEffect.value ? { specifier: sideEffect.value, names, start: index, end: sideEffect.end, terminated: sideEffect.terminated } : null;
|
|
245
|
+
if (isKeywordAt(code, index, "type")) {
|
|
246
|
+
const after = skipWhitespaceAndComments(code, index + "type".length);
|
|
247
|
+
// `import type …` is erased at runtime — but `import type from "x"` binds a default named `type`.
|
|
248
|
+
if (!isKeywordAt(code, after, "from")) {
|
|
249
|
+
const from = readFromSpecifier(code, after);
|
|
250
|
+
return from ? { ...from, names, notLinked: true } : null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (code[index] !== "{" && code[index] !== "*") {
|
|
254
|
+
const afterDefault = readIdentifierWord(code, index)?.end;
|
|
255
|
+
if (afterDefault === undefined) return null;
|
|
256
|
+
names.push("default");
|
|
257
|
+
index = skipWhitespaceAndComments(code, afterDefault);
|
|
258
|
+
if (code[index] === ",") index = skipWhitespaceAndComments(code, index + 1);
|
|
259
|
+
}
|
|
260
|
+
if (code[index] === "*") {
|
|
261
|
+
index = skipWhitespaceAndComments(code, index + 1);
|
|
262
|
+
if (!isKeywordAt(code, index, "as")) return null;
|
|
263
|
+
const afterNamespace = readIdentifierWord(code, skipWhitespaceAndComments(code, index + "as".length))?.end;
|
|
264
|
+
if (afterNamespace === undefined) return null;
|
|
265
|
+
index = skipWhitespaceAndComments(code, afterNamespace);
|
|
266
|
+
} else if (code[index] === "{") {
|
|
267
|
+
index = skipWhitespaceAndComments(code, index + 1);
|
|
268
|
+
while (index < code.length && code[index] !== "}") {
|
|
269
|
+
let typeOnly = false;
|
|
270
|
+
if (isKeywordAt(code, index, "type")) {
|
|
271
|
+
const after = skipWhitespaceAndComments(code, index + "type".length);
|
|
272
|
+
// `{ type Foo }` is erased; a lone `{ type }` / `{ type as t }` demands an export literally named `type`.
|
|
273
|
+
if (code[after] !== "," && code[after] !== "}" && !isKeywordAt(code, after, "as")) {
|
|
274
|
+
typeOnly = true;
|
|
275
|
+
index = after;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const literal = readStringLiteral(code, index);
|
|
279
|
+
const imported = literal ? { name: literal.value, end: literal.end } : readIdentifierWord(code, index);
|
|
280
|
+
if (!imported) return null;
|
|
281
|
+
index = skipWhitespaceAndComments(code, imported.end);
|
|
282
|
+
if (isKeywordAt(code, index, "as")) {
|
|
283
|
+
const local = readIdentifierWord(code, skipWhitespaceAndComments(code, index + "as".length));
|
|
284
|
+
if (!local) return null;
|
|
285
|
+
index = skipWhitespaceAndComments(code, local.end);
|
|
286
|
+
}
|
|
287
|
+
if (!typeOnly) names.push(imported.name);
|
|
288
|
+
if (code[index] === ",") index = skipWhitespaceAndComments(code, index + 1);
|
|
289
|
+
else if (code[index] !== "}") return null;
|
|
290
|
+
}
|
|
291
|
+
if (code[index] !== "}") return null;
|
|
292
|
+
index = skipWhitespaceAndComments(code, index + 1);
|
|
293
|
+
}
|
|
294
|
+
if (!isKeywordAt(code, index, "from")) return null;
|
|
295
|
+
const start = skipWhitespaceAndComments(code, index + "from".length);
|
|
296
|
+
const literal = readStringLiteral(code, start);
|
|
297
|
+
return literal?.value ? { specifier: literal.value, names, start, end: literal.end, terminated: literal.terminated } : null;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Walks module-level import/export statements and reports each specifier with the export names bound from it
|
|
301
|
+
// (empty for namespace, side-effect, re-export and dynamic imports). `onSpecifier` is the single output channel —
|
|
302
|
+
// callers project it into whatever shape they need. `site` separates the module graph from `import.meta.resolve()`,
|
|
303
|
+
// which is a runtime asset lookup rather than an edge.
|
|
304
|
+
type ResolutionSite = "import" | "meta-resolve";
|
|
305
|
+
type SpecifierRange = { start: number; end: number };
|
|
306
|
+
const collectModuleSpecifiers = (code: string, onSpecifier: (specifier: string, names: string[], notLinked: boolean | undefined, site: ResolutionSite, range?: SpecifierRange) => void, start = 0, end = code.length) => {
|
|
193
307
|
for (let index = start; index < end; index += 1) {
|
|
194
308
|
const char = code[index];
|
|
195
309
|
if (char === "/" && code[index + 1] === "/") {
|
|
@@ -209,39 +323,110 @@ const collectBareModuleSpecifiers = (code: string, specifiers: Set<string>, star
|
|
|
209
323
|
continue;
|
|
210
324
|
}
|
|
211
325
|
if (char === "`") {
|
|
212
|
-
index = skipTemplateLiteral(code, index, (expressionStart, expressionEnd) =>
|
|
326
|
+
index = skipTemplateLiteral(code, index, (expressionStart, expressionEnd) => collectModuleSpecifiers(code, onSpecifier, expressionStart, expressionEnd));
|
|
213
327
|
continue;
|
|
214
328
|
}
|
|
215
329
|
if (isKeywordAt(code, index, "import")) {
|
|
216
330
|
const afterImport = skipWhitespaceAndComments(code, index + "import".length);
|
|
217
331
|
if (code[afterImport] === ".") {
|
|
332
|
+
// Keep runtime asset lookups visible to callers that scan all specifiers, while separating them from actual
|
|
333
|
+
// module-graph edges. The renderer no longer projects these into a browser-native import map.
|
|
334
|
+
const literal = readImportMetaResolveSpecifier(code, afterImport);
|
|
335
|
+
if (literal) {
|
|
336
|
+
// notLinked: nothing is instantiated here, so probing it would fetch what the module never requested.
|
|
337
|
+
if (literal.terminated && literal.value) onSpecifier(literal.value, [], true, "meta-resolve", { start: literal.start, end: literal.end });
|
|
338
|
+
index = literal.end;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
218
341
|
index = afterImport;
|
|
219
342
|
continue;
|
|
220
343
|
}
|
|
221
344
|
if (code[afterImport] === "(") {
|
|
222
|
-
const
|
|
223
|
-
|
|
345
|
+
const literalStart = skipWhitespaceAndComments(code, afterImport + 1);
|
|
346
|
+
const literal = readStringLiteral(code, literalStart);
|
|
347
|
+
// A dynamic target is not linked at instantiation, so probing it would issue a real request.
|
|
348
|
+
if (literal?.terminated && literal.value) onSpecifier(literal.value, [], true, "import", { start: literalStart, end: literal.end });
|
|
224
349
|
index = literal?.end ?? afterImport;
|
|
225
350
|
continue;
|
|
226
351
|
}
|
|
227
|
-
const
|
|
228
|
-
const from =
|
|
229
|
-
if (from?.
|
|
352
|
+
const bindings = readImportBindings(code, afterImport);
|
|
353
|
+
const from = bindings ?? readFromSpecifier(code, afterImport);
|
|
354
|
+
if (from?.terminated && from.specifier) onSpecifier(from.specifier, bindings?.names ?? [], bindings?.notLinked, "import", { start: from.start, end: from.end });
|
|
230
355
|
index = from?.end ?? afterImport;
|
|
231
356
|
continue;
|
|
232
357
|
}
|
|
233
358
|
if (isKeywordAt(code, index, "export")) {
|
|
234
359
|
const from = readExportFromSpecifier(code, index + "export".length);
|
|
235
|
-
if (from?.
|
|
360
|
+
if (from?.terminated && from.specifier) onSpecifier(from.specifier, [], undefined, "import", { start: from.start, end: from.end });
|
|
236
361
|
index = from?.end ?? index;
|
|
237
362
|
}
|
|
238
363
|
}
|
|
239
364
|
};
|
|
240
365
|
|
|
241
|
-
|
|
366
|
+
const extractSpecifiers = (code: string, keep: (specifier: string, site: ResolutionSite) => boolean) => {
|
|
242
367
|
const specifiers = new Set<string>();
|
|
243
|
-
|
|
368
|
+
collectModuleSpecifiers(code, (specifier, _names, _notLinked, site) => {
|
|
369
|
+
if (keep(specifier, site)) specifiers.add(specifier);
|
|
370
|
+
});
|
|
244
371
|
return specifiers;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export const extractBareModuleSpecifiers = (code: string) => extractSpecifiers(code, isBareModuleSpecifier);
|
|
375
|
+
|
|
376
|
+
// Specifiers reached through an `import`/`export` statement only — the actual module graph. Excludes
|
|
377
|
+
// `import.meta.resolve()` targets, which are runtime asset lookups (WASM binaries, worker scripts) rather than graph
|
|
378
|
+
// edges, so a consumer walking imports (the UnoCSS source tracer) doesn't try to parse a multi-MB binary as source.
|
|
379
|
+
export const extractImportSpecifiers = (code: string) => extractSpecifiers(code, (_specifier, site) => site === "import");
|
|
380
|
+
|
|
381
|
+
export const extractImportMetaResolveSpecifiers = (code: string) => extractSpecifiers(code, (_specifier, site) => site === "meta-resolve");
|
|
382
|
+
|
|
383
|
+
export const extractModuleSpecifiers = (code: string) => extractSpecifiers(code, () => true);
|
|
384
|
+
|
|
385
|
+
export function resolveImportMapSpecifier(imports: Readonly<Record<string, string>>, specifier: string) {
|
|
386
|
+
if (imports[specifier]) return imports[specifier];
|
|
387
|
+
let bestPrefix = "";
|
|
388
|
+
for (const prefix of Object.keys(imports)) if (prefix.endsWith("/") && specifier.startsWith(prefix) && prefix.length > bestPrefix.length) bestPrefix = prefix;
|
|
389
|
+
return bestPrefix ? `${imports[bestPrefix]}${specifier.slice(bestPrefix.length)}` : undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Replace the parsed string-literal specifiers at `keepSite` with their import-map targets. Working from the scanner's
|
|
393
|
+
// ranges — not a text match — is what makes this safe on minified bundles, where a string constant can read exactly
|
|
394
|
+
// like an import clause (`from"in $)}function S$(`).
|
|
395
|
+
const rewriteSpecifiers = (code: string, imports: Readonly<Record<string, string>>, keepSite: ResolutionSite) => {
|
|
396
|
+
// collectModuleSpecifiers scans forward, so ranges arrive in ascending order: emit the gap before each one and skip
|
|
397
|
+
// past it, then join once. Splicing the string per replacement would recopy the whole source each time.
|
|
398
|
+
const segments: string[] = [];
|
|
399
|
+
let copied = 0;
|
|
400
|
+
collectModuleSpecifiers(code, (specifier, _names, _notLinked, site, range) => {
|
|
401
|
+
const target = site === keepSite ? resolveImportMapSpecifier(imports, specifier) : undefined;
|
|
402
|
+
if (!target || !range) return;
|
|
403
|
+
segments.push(code.slice(copied, range.start), JSON.stringify(target));
|
|
404
|
+
copied = range.end;
|
|
405
|
+
});
|
|
406
|
+
if (!segments.length) return code;
|
|
407
|
+
segments.push(code.slice(copied));
|
|
408
|
+
return segments.join("");
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// @esm.sh/tsx rewrites import declarations but deliberately leaves import.meta.resolve() calls intact. Replace only
|
|
412
|
+
// their parsed string-literal arguments with resolved URLs so blob: modules never need a browser-native import map.
|
|
413
|
+
export const rewriteImportMetaResolveSpecifiers = (code: string, imports: Readonly<Record<string, string>>) => rewriteSpecifiers(code, imports, "meta-resolve");
|
|
414
|
+
|
|
415
|
+
// The counterpart for code no compiler pass ever sees: host facade bundles are built with the runtime singletons
|
|
416
|
+
// externalized (that externalization is what keeps React a singleton), so their bare imports would otherwise survive to
|
|
417
|
+
// the browser and need a native import map. Rewriting them to the emitted chunk URLs is what lets that map go away.
|
|
418
|
+
export const rewriteImportSpecifiers = (code: string, imports: Readonly<Record<string, string>>) => rewriteSpecifiers(code, imports, "import");
|
|
419
|
+
|
|
420
|
+
// Maps each specifier linked when the module is instantiated to the export names bound from it ("default" included;
|
|
421
|
+
// namespace, side-effect and re-export imports demand no specific names). Type-only and dynamic imports are excluded:
|
|
422
|
+
// neither is linked at instantiation, so probing them would fetch something the failed module never requested.
|
|
423
|
+
// importProbe runs this over *compiled* code, where the import-map rewrite already resolved bare specifiers.
|
|
424
|
+
export function extractImportBindings(code: string) {
|
|
425
|
+
const bindings = new Map<string, string[]>();
|
|
426
|
+
collectModuleSpecifiers(code, (specifier, names, notLinked) => {
|
|
427
|
+
if (!notLinked) bindings.set(specifier, [...new Set([...(bindings.get(specifier) ?? []), ...names])]);
|
|
428
|
+
});
|
|
429
|
+
return bindings;
|
|
245
430
|
}
|
|
246
431
|
|
|
247
432
|
export function hasImportMapEntry(imports: Record<string, string> | Set<string> | Map<string, string>, specifier: string) {
|
|
@@ -251,25 +436,251 @@ export function hasImportMapEntry(imports: Record<string, string> | Set<string>
|
|
|
251
436
|
return false;
|
|
252
437
|
}
|
|
253
438
|
|
|
254
|
-
export
|
|
439
|
+
export type EsmShDependencyRanges = Readonly<Record<string, string>>;
|
|
440
|
+
type EsmShImportUrlOptions = { dev?: boolean; dependencyRanges?: EsmShDependencyRanges };
|
|
441
|
+
|
|
442
|
+
export function toEsmShImportUrl(specifier: string, external: readonly string[] = DEFAULT_ESM_SH_EXTERNALS, options?: EsmShImportUrlOptions) {
|
|
255
443
|
if (!canUseEsmShFallback(specifier)) throw new Error(`Cannot create an esm.sh fallback for ${specifier}`);
|
|
256
444
|
const packageName = getBarePackageName(specifier);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
445
|
+
// Dev builds: production bundles strip the dev-only dispatcher fields (e.g. getOwner) that the dev jsx-runtime
|
|
446
|
+
// calls while an externalized reconciler (react-reconciler inside @react-three/fiber) owns the render pass.
|
|
447
|
+
const devParam = options?.dev ? "&dev" : "";
|
|
448
|
+
// `bundle`/`external` both fork esm.sh's build path, so a bundled `three` is a different module object than the plain
|
|
449
|
+
// `/three@x/es2022/three.mjs` that `@react-three/fiber` imports. Shared-instance packages use that unbundled path so
|
|
450
|
+
// R3F's `instanceof THREE.Layers/Color` checks see the same constructors as user-created Three.js objects. Their
|
|
451
|
+
// identity comes from agreeing with a dependent's own pinned range (R3F asks for `three@>=0.156`), so our side stays
|
|
452
|
+
// unconstrained too and both float onto latest, landing on the same pinned build. The invariant is that agreement,
|
|
453
|
+
// not version-freedom — it would break the day R3F pins an upper bound or we constrain our side.
|
|
454
|
+
if (isSharedInstancePackage(specifier)) return `https://esm.sh/${specifier}${specifier.includes("?") ? "&" : "?"}target=es2022${devParam}`;
|
|
455
|
+
// Keep the repository's declared semver range as the single source of truth. Protocol-based specs describe local,
|
|
456
|
+
// aliased, or non-registry packages that esm.sh cannot resolve and therefore stay version-free here.
|
|
457
|
+
const dependencyRange = options?.dependencyRanges?.[packageName];
|
|
458
|
+
const constrainedSpecifier = dependencyRange && !dependencyRange.includes(":") ? `${packageName}@${encodeURIComponent(dependencyRange)}${specifier.slice(packageName.length)}` : specifier;
|
|
459
|
+
const separator = constrainedSpecifier.includes("?") ? "&" : "?";
|
|
460
|
+
const externalParam = external.length ? `&external=${external.join(",")}` : "";
|
|
461
|
+
return `https://esm.sh/${constrainedSpecifier}${separator}bundle&target=es2022${devParam}${externalParam}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
type EsmShPreloadFetch = (url: string) => Promise<Pick<Response, "ok" | "status">>;
|
|
465
|
+
type EsmShPreloadOptions = { fetch?: EsmShPreloadFetch; attempts?: number; retryDelayMs?: number };
|
|
466
|
+
const esmShPreloadCache = new Map<string, Promise<void>>();
|
|
467
|
+
|
|
468
|
+
// A browser module loader memoizes a rejected URL for the page lifetime, so retrying the generated blob cannot recover
|
|
469
|
+
// from a transient esm.sh cold-build failure. Warm the entry URL with ordinary fetch first — a version range still
|
|
470
|
+
// leaves it a mutable entry esm.sh has to resolve and build, not a pinned artifact. Failures remain retryable, while a
|
|
471
|
+
// 2xx means esm.sh has finished resolving/building before import() sees the URL.
|
|
472
|
+
export function preloadEsmShImportUrl(url: string, options?: EsmShPreloadOptions): Promise<void> {
|
|
473
|
+
let preload = esmShPreloadCache.get(url);
|
|
474
|
+
if (!preload) {
|
|
475
|
+
const fetcher = options?.fetch ?? fetch;
|
|
476
|
+
// 3 attempts at 250ms exponential backoff spans ~750ms of retrying, which covers a typical esm.sh cold build
|
|
477
|
+
// without stalling first paint. There is no ack to wait on — esm.sh answers 5xx while the build is still running.
|
|
478
|
+
const attempts = options?.attempts ?? 3;
|
|
479
|
+
const retryDelayMs = options?.retryDelayMs ?? 250;
|
|
480
|
+
preload = (async () => {
|
|
481
|
+
let lastFailure: unknown;
|
|
482
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
483
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** (attempt - 1)));
|
|
484
|
+
try {
|
|
485
|
+
const response = await fetcher(url);
|
|
486
|
+
if (response.ok) return;
|
|
487
|
+
lastFailure = new Error(`HTTP ${response.status}`);
|
|
488
|
+
// esm.sh answers 5xx while a cold build is still running, and 429 when npm rate-limits it — both transient,
|
|
489
|
+
// same classification the probes below use. Any other 4xx is a decided answer (unknown package, rejected
|
|
490
|
+
// specifier); retrying burns the full backoff for the same result.
|
|
491
|
+
if (response.status < 500 && response.status !== 429) break;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
lastFailure = error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
throw new Error(`Failed to preload esm.sh module ${url}`, { cause: lastFailure });
|
|
497
|
+
})();
|
|
498
|
+
preload.catch(() => esmShPreloadCache.delete(url));
|
|
499
|
+
esmShPreloadCache.set(url, preload);
|
|
500
|
+
}
|
|
501
|
+
return preload;
|
|
261
502
|
}
|
|
262
503
|
|
|
504
|
+
export type EsmShProbe = { alive: true } | { alive: false; detail: string };
|
|
505
|
+
type EsmShProbeFetch = (url: string) => Promise<Pick<Response, "ok" | "status" | "text" | "json">>;
|
|
506
|
+
// esm.sh 404/2xx verdicts are deterministic per specifier, so they're cached for the process lifetime; transient
|
|
507
|
+
// fail-open verdicts (5xx/rate-limit/network) are evicted on resolve so they don't pin. Tests inject a fetcher and
|
|
508
|
+
// must use per-test specifiers to dodge the shared cache.
|
|
509
|
+
const esmShProbeCache = new Map<string, Promise<EsmShProbe | null>>();
|
|
510
|
+
|
|
511
|
+
// A dead entry usually means the model hallucinated the package or its layout; the registry's real subpaths are the
|
|
512
|
+
// heal signal that lets it rewrite the import in one shot.
|
|
513
|
+
const MAX_HINT_SUBPATHS = 8; // enough real subpaths for the model to spot the right one without bloating the diagnostic
|
|
514
|
+
const registryExportsHint = async (packageName: string, fetcher: EsmShProbeFetch) => {
|
|
515
|
+
const response = await fetcher(`https://registry.npmjs.org/${packageName}/latest`);
|
|
516
|
+
if (!response.ok) return response.status === 404 ? "the package does not exist on npm" : "";
|
|
517
|
+
const { exports: exportsMap } = (await response.json()) as { exports?: Record<string, unknown> };
|
|
518
|
+
const subpaths = Object.keys(exportsMap ?? {}).filter((key) => key.startsWith("./"));
|
|
519
|
+
return subpaths.length ? `its real exports subpaths are ${subpaths.slice(0, MAX_HINT_SUBPATHS).join(", ")}${subpaths.length > MAX_HINT_SUBPATHS ? `, … (${subpaths.length} total)` : ""}` : "";
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// Would the esm.sh fallback actually answer this specifier at render time? Null means the fallback never applies
|
|
523
|
+
// (relative path, `@/` alias) — the caller should leave its own resolution verdict alone. Only a deterministic 404
|
|
524
|
+
// condemns an import: network failures and 5xx must not fail checks that run offline or during an esm.sh incident. #1372
|
|
525
|
+
export const probeEsmShSpecifier = (specifier: string, fetcher: EsmShProbeFetch = fetch): Promise<EsmShProbe | null> => {
|
|
526
|
+
let probe = esmShProbeCache.get(specifier);
|
|
527
|
+
if (!probe) {
|
|
528
|
+
probe = (async (): Promise<EsmShProbe | null> => {
|
|
529
|
+
let url: string;
|
|
530
|
+
try {
|
|
531
|
+
// Deliberately unranged: this is a package-existence check, and the cache is keyed by bare specifier. A
|
|
532
|
+
// package that exists unranged exists ranged, so the only gap is a declared range with no esm.sh build —
|
|
533
|
+
// probe says alive, render 404s. #1573 tracks threading ranges through if that ever bites.
|
|
534
|
+
url = toEsmShImportUrl(specifier);
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
// A non-404 status esm.sh can't build deterministically (5xx incident, 429/403 npm rate-limit) or a network
|
|
539
|
+
// failure fails open so offline `check` still passes, but that verdict isn't deterministic; evict it so a
|
|
540
|
+
// genuinely-dead import isn't pinned `alive` for the host's lifetime.
|
|
541
|
+
const failOpenTransiently = (): EsmShProbe => {
|
|
542
|
+
esmShProbeCache.delete(specifier);
|
|
543
|
+
return { alive: true };
|
|
544
|
+
};
|
|
545
|
+
try {
|
|
546
|
+
const response = await fetcher(url);
|
|
547
|
+
if (response.status === 404) {
|
|
548
|
+
const body = (await response.text()).trim().split("\n")[0];
|
|
549
|
+
const hint = await registryExportsHint(getBarePackageName(specifier), fetcher).catch(() => "");
|
|
550
|
+
return { alive: false, detail: `The renderer's esm.sh fallback fails too (404: ${body})${hint ? `; ${hint}` : ""}.` };
|
|
551
|
+
}
|
|
552
|
+
// Only a 2xx means esm.sh actually resolved the build; anything else joins the fail-open path above.
|
|
553
|
+
return response.ok ? { alive: true } : failOpenTransiently();
|
|
554
|
+
} catch {
|
|
555
|
+
return failOpenTransiently();
|
|
556
|
+
}
|
|
557
|
+
})();
|
|
558
|
+
esmShProbeCache.set(specifier, probe);
|
|
559
|
+
}
|
|
560
|
+
return probe;
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// `build` fetch-bundles remote http(s) module imports and dev/the runtime import them directly, so the URL itself is
|
|
564
|
+
// the authority to consult before condemning a remote specifier's TS2307 (#1468). Same doctrine as the esm.sh probe:
|
|
565
|
+
// only a deterministic 404 condemns; transient verdicts (5xx/rate-limit/network) fail open and are evicted so they don't pin.
|
|
566
|
+
const remoteModuleProbeCache = new Map<string, Promise<EsmShProbe>>();
|
|
567
|
+
export const probeRemoteModuleUrl = (url: string, fetcher: EsmShProbeFetch = fetch): Promise<EsmShProbe> => {
|
|
568
|
+
let probe = remoteModuleProbeCache.get(url);
|
|
569
|
+
if (!probe) {
|
|
570
|
+
probe = (async (): Promise<EsmShProbe> => {
|
|
571
|
+
const failOpenTransiently = (): EsmShProbe => {
|
|
572
|
+
remoteModuleProbeCache.delete(url);
|
|
573
|
+
return { alive: true };
|
|
574
|
+
};
|
|
575
|
+
try {
|
|
576
|
+
const response = await fetcher(url);
|
|
577
|
+
if (response.status === 404) return { alive: false, detail: `The renderer's remote module fetch fails too (404: ${(await response.text()).trim().split("\n")[0]}).` };
|
|
578
|
+
return response.ok ? { alive: true } : failOpenTransiently();
|
|
579
|
+
} catch {
|
|
580
|
+
return failOpenTransiently();
|
|
581
|
+
}
|
|
582
|
+
})();
|
|
583
|
+
remoteModuleProbeCache.set(url, probe);
|
|
584
|
+
}
|
|
585
|
+
return probe;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// One probe for every specifier `check`'s local TS resolution can't see: remote http(s) URLs consult the origin itself,
|
|
589
|
+
// bare specifiers consult the esm.sh fallback, anything else (relative path, `@/` alias) keeps the caller's own verdict.
|
|
590
|
+
export const probeImportSpecifier = (specifier: string, fetcher: EsmShProbeFetch = fetch): Promise<EsmShProbe | null> => (/^https?:\/\//i.test(specifier) ? probeRemoteModuleUrl(specifier, fetcher) : probeEsmShSpecifier(specifier, fetcher));
|
|
591
|
+
|
|
263
592
|
export function prepareRendererImportMap(map: RendererImportMap | null | undefined, baseUrl = typeof location === "undefined" ? undefined : location.href) {
|
|
264
|
-
const
|
|
265
|
-
|
|
593
|
+
const resolveTarget = (target: string) => (baseUrl ? new URL(target, baseUrl).href : target);
|
|
594
|
+
const resolveEntries = (entries: Record<string, string>) => Object.fromEntries(Object.entries(entries).map(([specifier, target]) => [specifier, resolveTarget(target)]));
|
|
595
|
+
const imports = resolveEntries(map?.imports ?? {});
|
|
596
|
+
const styles = map?.styles?.map(resolveTarget);
|
|
597
|
+
const scopes = map?.scopes && Object.fromEntries(Object.entries(map.scopes).map(([scope, entries]) => [scope, resolveEntries(entries)]));
|
|
598
|
+
return { imports, ...(scopes ? { scopes } : {}), ...(styles?.length ? { styles } : {}) };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function literalImportMap(map: RendererImportMap): ImportMapProvider {
|
|
602
|
+
return { resolve: () => map };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Loads a remote/JSON import map once. `prefetch()` and `resolve()` share the in-flight promise so an idle-time
|
|
606
|
+
// prefetch and a later render both reuse the same fetch instead of hitting the network twice.
|
|
607
|
+
export function urlImportMap(source: string, options?: { kind?: ImportMapProvider["kind"]; fetch?: (source: string) => Promise<Pick<Response, "ok" | "status" | "statusText" | "json">> }): ImportMapProvider {
|
|
608
|
+
const fetcher = options?.fetch ?? fetch;
|
|
609
|
+
let loadPromise: Promise<RendererImportMap> | undefined;
|
|
610
|
+
const load = () =>
|
|
611
|
+
(loadPromise ??= (async () => {
|
|
612
|
+
const response = await fetcher(source);
|
|
613
|
+
if (!response.ok) throw new Error(`Failed to load import map ${source}: ${response.status} ${response.statusText}`);
|
|
614
|
+
const baseUrl = typeof location === "undefined" ? source : new URL(source, location.href).href;
|
|
615
|
+
return prepareRendererImportMap((await response.json()) as RendererImportMap, baseUrl);
|
|
616
|
+
})());
|
|
617
|
+
return { kind: options?.kind ?? "base", prefetch: load, resolve: load };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Node-only: resolve `packageName`'s package.json#genui.importMap into a provider. Relative targets (./dist/…) are
|
|
621
|
+
// rebased to absolute disk paths so Vite/Rollup can load them; absolute paths and URLs pass through. Throws if the
|
|
622
|
+
// package declares no genui.importMap. Resolution is from the caller's cwd (where the user installed the package),
|
|
623
|
+
// not this file's location — so `bunx @genui/cli -i @macaron/ui` finds the user's package, not the CLI's. node:
|
|
624
|
+
// imports stay inside resolve() so this module remains browser-bundleable.
|
|
625
|
+
export function packageImportMap(packageName: string, options?: { resolve?: (name: string) => string }): ImportMapProvider {
|
|
626
|
+
return {
|
|
627
|
+
resolve: async () => {
|
|
628
|
+
const [{ createRequire }, { readFile }, { dirname, resolve: resolvePath }] = await Promise.all([import("node:module"), import("node:fs/promises"), import("node:path")]);
|
|
629
|
+
const resolveName = options?.resolve ?? createRequire(resolvePath("package.json")).resolve;
|
|
630
|
+
const manifestPath = resolveName(`${packageName}/package.json`);
|
|
631
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { genui?: { importMap?: RendererImportMap } };
|
|
632
|
+
const importMap = manifest.genui?.importMap;
|
|
633
|
+
if (!importMap) throw new Error(`${packageName} has no package.json#genui.importMap — add it to declare the GenUI importmap contract`);
|
|
634
|
+
const packageDir = dirname(manifestPath);
|
|
635
|
+
const rebase = (target: string) => (target.startsWith(".") ? resolvePath(packageDir, target) : target);
|
|
636
|
+
return { imports: Object.fromEntries(Object.entries(importMap.imports ?? {}).map(([specifier, target]) => [specifier, rebase(target)])), styles: importMap.styles?.map(rebase) };
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// esm.sh fallback only answers for `missing` specifiers, so the resolver can merge its output without re-checking
|
|
642
|
+
// coverage. In dev, locally installed packages are preferred so peer deps like React stay on the preview's runtime.
|
|
643
|
+
export function esmShFallback(options?: { external?: readonly string[]; dev?: boolean; dependencyRanges?: EsmShDependencyRanges; hasLocalPackage?: (packageName: string) => Promise<boolean> }): ImportMapProvider {
|
|
644
|
+
return {
|
|
645
|
+
kind: "fallback",
|
|
646
|
+
resolve: async ({ missing }) => {
|
|
647
|
+
const entries = await Promise.all(
|
|
648
|
+
missing.filter(canUseEsmShFallback).map(async (specifier) => {
|
|
649
|
+
const isLocal = await options?.hasLocalPackage?.(getBarePackageName(specifier));
|
|
650
|
+
return [specifier, isLocal ? `/node_modules/${specifier}` : toEsmShImportUrl(specifier, options?.external, { dev: options?.dev, dependencyRanges: options?.dependencyRanges })] as const;
|
|
651
|
+
}),
|
|
652
|
+
);
|
|
653
|
+
return { imports: Object.fromEntries(entries) };
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function createImportMapResolver(providers: ImportMapProvider[]) {
|
|
659
|
+
return {
|
|
660
|
+
// Idle/build-time warmup only touches base providers. Fallback providers stay lazy until resolve() has scanned the
|
|
661
|
+
// TSX for missing specifiers, so prefetching them would fetch remote maps the rendered code may never reference.
|
|
662
|
+
prefetch: () => Promise.all(providers.filter((provider) => provider.kind !== "fallback").map((provider) => provider.prefetch?.())).then(() => undefined),
|
|
663
|
+
async resolve({ code = "" }: { code?: string } = {}) {
|
|
664
|
+
const required = [...extractBareModuleSpecifiers(code)];
|
|
665
|
+
let imports: Record<string, string> = {};
|
|
666
|
+
let scopes: RendererImportMap["scopes"];
|
|
667
|
+
let styles: string[] | undefined;
|
|
668
|
+
for (const provider of providers) {
|
|
669
|
+
const covered = new Set(Object.keys(imports));
|
|
670
|
+
const missing = required.filter((specifier) => !hasImportMapEntry(covered, specifier));
|
|
671
|
+
if (provider.kind === "fallback" && missing.length === 0) continue;
|
|
672
|
+
const next = await provider.resolve({ code, missing, imports });
|
|
673
|
+
imports = { ...imports, ...next.imports };
|
|
674
|
+
// scopes is an advanced compat field with no producer today, so a later provider replaces an earlier one's
|
|
675
|
+
// entries for the same scope key rather than deep-merging them. Revisit if two providers ever both emit scopes.
|
|
676
|
+
if (next.scopes) scopes = { ...scopes, ...next.scopes };
|
|
677
|
+
if (next.styles?.length) styles = [...(styles ?? []), ...next.styles];
|
|
678
|
+
}
|
|
679
|
+
return { imports, ...(scopes ? { scopes } : {}), ...(styles?.length ? { styles: [...new Set(styles)] } : {}) };
|
|
680
|
+
},
|
|
681
|
+
};
|
|
266
682
|
}
|
|
267
683
|
|
|
268
|
-
export async function mergeFallbackImports(imports: Record<string, string>, code: string, options?: { hasLocalPackage?: (packageName: string) => Promise<boolean> }) {
|
|
269
|
-
|
|
270
|
-
const knownSpecifiers = new Set(Object.keys(next));
|
|
271
|
-
const fallbackSpecifiers = [...extractBareModuleSpecifiers(code)].filter((specifier) => !hasImportMapEntry(knownSpecifiers, specifier) && canUseEsmShFallback(specifier));
|
|
272
|
-
const localPackageMatches = await Promise.all(fallbackSpecifiers.map((specifier) => options?.hasLocalPackage?.(getBarePackageName(specifier))));
|
|
273
|
-
for (const [index, specifier] of fallbackSpecifiers.entries()) next[specifier] = localPackageMatches[index] ? `/node_modules/${specifier}` : toEsmShImportUrl(specifier);
|
|
274
|
-
return next;
|
|
684
|
+
export async function mergeFallbackImports(imports: Record<string, string>, code: string, options?: { dependencyRanges?: EsmShDependencyRanges; hasLocalPackage?: (packageName: string) => Promise<boolean> }) {
|
|
685
|
+
return (await createImportMapResolver([literalImportMap({ imports }), esmShFallback(options)]).resolve({ code })).imports;
|
|
275
686
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { extractImportBindings } from "./importMap";
|
|
2
|
+
|
|
3
|
+
export type GenUIImportDiagnostic = { specifier: string; target: string; detail: string };
|
|
4
|
+
|
|
5
|
+
// Replaces the browser's opaque blob-module failure once the probe has named the imports that actually broke.
|
|
6
|
+
// `message` doubles as the heal signal fed back to the generating model, so it must stay self-contained.
|
|
7
|
+
export class GenUIImportError extends Error {
|
|
8
|
+
readonly diagnostics: GenUIImportDiagnostic[];
|
|
9
|
+
constructor(diagnostics: GenUIImportDiagnostic[], cause: unknown) {
|
|
10
|
+
super(`Unresolvable imports:\n${diagnostics.map(({ specifier, target, detail }) => `- '${specifier}'${target === specifier ? "" : ` (→ ${target})`} ${detail}`).join("\n")}`, { cause });
|
|
11
|
+
this.name = "GenUIImportError";
|
|
12
|
+
this.diagnostics = diagnostics;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Cap the export listing so a barrel module with hundreds of exports can't bloat the heal message; enough for the
|
|
17
|
+
// model to spot the name it meant (e.g. `avataaars` vs `avataars`) and infer the rest. Mirrors MAX_HINT_SUBPATHS.
|
|
18
|
+
const MAX_EXPORT_HINTS = 20;
|
|
19
|
+
const listAvailableExports = (module: Record<string, unknown>) => {
|
|
20
|
+
const keys = Object.keys(module).toSorted();
|
|
21
|
+
if (!keys.length) return "";
|
|
22
|
+
return `; module exports: ${keys.slice(0, MAX_EXPORT_HINTS).join(", ")}${keys.length > MAX_EXPORT_HINTS ? `, … (${keys.length - MAX_EXPORT_HINTS} more)` : ""}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Recover the bare specifier the model wrote from a compiled (import-map-rewritten) target. An exact entry wins; a
|
|
26
|
+
// trailing-slash prefix entry (`"lucide-react/"` → `".../lucide-react/"`) maps the whole subpath back, mirroring
|
|
27
|
+
// hasImportMapEntry's prefix semantics — so the heal signal names `lucide-react/icons/home`, not the resolved URL.
|
|
28
|
+
const reverseResolveSpecifier = (entries: [string, string][], target: string) => {
|
|
29
|
+
for (const [specifier, mapped] of entries) if (mapped === target) return specifier;
|
|
30
|
+
for (const [specifier, mapped] of entries) if (specifier.endsWith("/") && target.startsWith(mapped)) return specifier + target.slice(mapped.length);
|
|
31
|
+
return target;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Re-imports each dependency of a failed compiled module one by one to name what the blanket blob error hid. Costs no
|
|
35
|
+
// extra network traffic: a dependency that linked is already in the module map, and a dependency that failed reuses the
|
|
36
|
+
// engine's own rejection rather than re-fetching it to explain. `imports` (the resolved importmap) maps rewritten
|
|
37
|
+
// targets back to the bare specifiers the model wrote.
|
|
38
|
+
export async function probeModuleImports(code: string, imports?: Record<string, string>): Promise<GenUIImportDiagnostic[]> {
|
|
39
|
+
const importEntries = Object.entries(imports ?? {});
|
|
40
|
+
const probes = [...extractImportBindings(code)].map(async ([target, names]): Promise<GenUIImportDiagnostic | null> => {
|
|
41
|
+
const specifier = reverseResolveSpecifier(importEntries, target);
|
|
42
|
+
let module: Record<string, unknown>;
|
|
43
|
+
try {
|
|
44
|
+
module = (await import(/* @vite-ignore */ target)) as Record<string, unknown>;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return { specifier, target, detail: `failed to load: ${error instanceof Error ? error.message : String(error)}` };
|
|
47
|
+
}
|
|
48
|
+
const missing = names.filter((name) => !(name in module));
|
|
49
|
+
return missing.length ? { specifier, target, detail: `has no export named ${missing.map((name) => `'${name}'`).join(", ")}${listAvailableExports(module)}` } : null;
|
|
50
|
+
});
|
|
51
|
+
return (await Promise.all(probes)).filter((diagnostic) => diagnostic !== null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The probe must never replace the real failure with one of its own — fall back to the original error on any surprise.
|
|
55
|
+
export async function enrichModuleImportError(error: unknown, code: string, imports?: Record<string, string>) {
|
|
56
|
+
try {
|
|
57
|
+
const diagnostics = await probeModuleImports(code, imports);
|
|
58
|
+
return diagnostics.length ? new GenUIImportError(diagnostics, error) : error;
|
|
59
|
+
} catch {
|
|
60
|
+
return error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { createTsxCompiler, type TsxCompiler } from "./compiler";
|
|
2
|
+
import {
|
|
3
|
+
esmShFallback,
|
|
4
|
+
extractImportMetaResolveSpecifiers,
|
|
5
|
+
extractImportSpecifiers,
|
|
6
|
+
extractModuleSpecifiers,
|
|
7
|
+
hasImportMapEntry,
|
|
8
|
+
isBareModuleSpecifier,
|
|
9
|
+
isLocalRootRelativeTarget,
|
|
10
|
+
isSharedInstancePackage,
|
|
11
|
+
prepareRendererImportMap,
|
|
12
|
+
resolveImportMapSpecifier,
|
|
13
|
+
type EsmShDependencyRanges,
|
|
14
|
+
type ImportMapProvider,
|
|
15
|
+
type RendererImportMap,
|
|
16
|
+
} from "./importMap";
|
|
17
|
+
|
|
18
|
+
type EsmShModuleResponse = Pick<Response, "ok" | "status" | "statusText" | "text"> & { headers: Pick<Headers, "get"> } & Partial<Pick<Response, "url">>;
|
|
19
|
+
export type EsmShModuleResolver = (url: string, imports: Readonly<Record<string, string>>) => Promise<string>;
|
|
20
|
+
type EsmShModuleResolverOptions = {
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
compiler?: TsxCompiler;
|
|
23
|
+
fetch?: (url: string) => Promise<EsmShModuleResponse>;
|
|
24
|
+
preload?: (url: string) => Promise<unknown> | unknown;
|
|
25
|
+
createModuleUrl?: (code: string) => string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const importMapCacheKey = (entries: readonly (readonly string[])[]) => JSON.stringify(entries.toSorted(([a], [b]) => a.localeCompare(b)));
|
|
29
|
+
|
|
30
|
+
// esm.sh bundles still contain bare runtime imports when React/scheduler are externalized. Fetch the pinned bundle and
|
|
31
|
+
// run it through the same @esm.sh/tsx compiler as user code, so those imports become concrete host-module URLs before
|
|
32
|
+
// the browser sees the module. This keeps one transform-based resolver and removes the need for a page-level importmap.
|
|
33
|
+
export function createEsmShModuleResolver(options?: EsmShModuleResolverOptions): EsmShModuleResolver {
|
|
34
|
+
const compiler = options?.compiler ?? createTsxCompiler();
|
|
35
|
+
const fetcher = options?.fetch ?? (fetch as (url: string) => Promise<EsmShModuleResponse>);
|
|
36
|
+
const createModuleUrl = options?.createModuleUrl ?? ((code: string) => URL.createObjectURL(new Blob([code], { type: "text/javascript" })));
|
|
37
|
+
const moduleByKey = new Map<string, Promise<string>>();
|
|
38
|
+
const sourceByUrl = new Map<string, Promise<{ sourceUrl: string; source: string; specifiers: string[] }>>();
|
|
39
|
+
// Every specifier in one resolve pass gets the same `imports` object, and normalizing it is O(map) — which grows with
|
|
40
|
+
// each stream chunk. Key on object identity so the pass runs once per chunk instead of once per esm.sh package.
|
|
41
|
+
const normalizedImports = new WeakMap<Readonly<Record<string, string>>, Record<string, string>>();
|
|
42
|
+
|
|
43
|
+
const loadSource = (url: string) => {
|
|
44
|
+
let pending = sourceByUrl.get(url);
|
|
45
|
+
if (!pending) {
|
|
46
|
+
pending = (async () => {
|
|
47
|
+
await options?.preload?.(url);
|
|
48
|
+
const entry = await fetcher(url);
|
|
49
|
+
if (!entry.ok) throw new Error(`Failed to fetch esm.sh module ${url}: ${entry.status} ${entry.statusText}`);
|
|
50
|
+
// esm.sh answers an entry URL with a re-export shim, not the bundle itself, so `x-esm-path` is the only way to
|
|
51
|
+
// reach the source worth compiling. Falling back to the shim would compile four `export *` lines and leave the
|
|
52
|
+
// real bundle's bare `react` import to the browser, which has no import map to resolve it — fail loudly instead.
|
|
53
|
+
const pinnedPath = entry.headers.get("x-esm-path");
|
|
54
|
+
if (!pinnedPath) throw new Error(`esm.sh returned no x-esm-path for ${url}; cannot locate the pinned bundle to compile`);
|
|
55
|
+
const sourceUrl = new URL(pinnedPath, url).href;
|
|
56
|
+
const sourceResponse = await fetcher(sourceUrl);
|
|
57
|
+
if (!sourceResponse.ok) throw new Error(`Failed to fetch esm.sh module ${sourceUrl}: ${sourceResponse.status} ${sourceResponse.statusText}`);
|
|
58
|
+
// Scan the bundle's specifiers once here, not per resolve — these bundles run to hundreds of KB.
|
|
59
|
+
const source = await sourceResponse.text();
|
|
60
|
+
return { sourceUrl, source, specifiers: [...extractModuleSpecifiers(source)] };
|
|
61
|
+
})();
|
|
62
|
+
pending.catch(() => sourceByUrl.delete(url));
|
|
63
|
+
sourceByUrl.set(url, pending);
|
|
64
|
+
}
|
|
65
|
+
return pending;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return async (url, imports) => {
|
|
69
|
+
const { sourceUrl, source, specifiers } = await loadSource(url);
|
|
70
|
+
// A blob: module cannot resolve `/src/...` relative to itself. Normalize the host map before compiling so the
|
|
71
|
+
// transform emits absolute page URLs and the resulting module is independent of browser-native import maps.
|
|
72
|
+
let compilerImports = normalizedImports.get(imports);
|
|
73
|
+
if (!compilerImports) normalizedImports.set(imports, (compilerImports = prepareRendererImportMap({ imports }, options?.baseUrl).imports));
|
|
74
|
+
// Key on the entries this bundle actually links, not the whole map: the map grows with every stream chunk, and a
|
|
75
|
+
// whole-map key would recompile the same bundle into a fresh blob: for each unrelated addition — re-running its
|
|
76
|
+
// side effects (`customElements.define` in @number-flow/react throws on the second module object).
|
|
77
|
+
const linked = specifiers.flatMap((specifier) => {
|
|
78
|
+
const target = resolveImportMapSpecifier(compilerImports, specifier);
|
|
79
|
+
return target ? [[specifier, target]] : [];
|
|
80
|
+
});
|
|
81
|
+
const key = `${url}\n${importMapCacheKey(linked)}`;
|
|
82
|
+
let moduleUrl = moduleByKey.get(key);
|
|
83
|
+
if (!moduleUrl) {
|
|
84
|
+
moduleUrl = compiler.compile(source, { filename: sourceUrl, lang: "js", importMap: { imports: compilerImports } }).then(({ code }) => createModuleUrl(code));
|
|
85
|
+
moduleUrl.catch(() => moduleByKey.delete(key));
|
|
86
|
+
moduleByKey.set(key, moduleUrl);
|
|
87
|
+
}
|
|
88
|
+
return moduleUrl;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function transformedEsmShFallback(options?: {
|
|
93
|
+
dev?: boolean;
|
|
94
|
+
dependencyRanges?: EsmShDependencyRanges;
|
|
95
|
+
baseUrl?: string;
|
|
96
|
+
hasLocalPackage?: (packageName: string) => Promise<boolean>;
|
|
97
|
+
resolveEsmSh?: EsmShModuleResolver;
|
|
98
|
+
compiler?: TsxCompiler;
|
|
99
|
+
fetch?: (url: string) => Promise<EsmShModuleResponse>;
|
|
100
|
+
preload?: (url: string) => Promise<unknown> | unknown;
|
|
101
|
+
}): ImportMapProvider {
|
|
102
|
+
const resolveEsmSh = options?.resolveEsmSh ?? createEsmShModuleResolver({ compiler: options?.compiler, fetch: options?.fetch, preload: options?.preload, baseUrl: options?.baseUrl });
|
|
103
|
+
// Decorate the plain fallback rather than restate it: the specifier filter, the local-package preference and the
|
|
104
|
+
// esm.sh URL shape all stay single-sourced, and this provider only decides what to do with each resolved target.
|
|
105
|
+
const base = esmShFallback({ dev: options?.dev, dependencyRanges: options?.dependencyRanges, hasLocalPackage: options?.hasLocalPackage });
|
|
106
|
+
// A failed compile evicts its cache entry so a later render can retry, which means every stream chunk re-attempts and
|
|
107
|
+
// would re-warn — hundreds of identical lines burying the first, actually diagnostic one. Warn once per target.
|
|
108
|
+
const warned = new Set<string>();
|
|
109
|
+
return {
|
|
110
|
+
kind: "fallback",
|
|
111
|
+
resolve: async ({ code, missing, imports = {} }) => {
|
|
112
|
+
const metaResolveSpecifiers = extractImportMetaResolveSpecifiers(code);
|
|
113
|
+
const importedSpecifiers = extractImportSpecifiers(code);
|
|
114
|
+
const entries = await Promise.all(
|
|
115
|
+
Object.entries((await base.resolve({ code, missing, imports })).imports ?? {}).map(async ([specifier, target]) => {
|
|
116
|
+
if (isLocalRootRelativeTarget(target)) return [specifier, target] as const;
|
|
117
|
+
// Two independent reasons to use the target as-is rather than compile it to a blob: an asset reached only
|
|
118
|
+
// through import.meta.resolve() is a value, not a JS module; a shared-instance package must keep its
|
|
119
|
+
// canonical esm.sh URL so a dependent's bundle importing that same URL gets the same module object. Either
|
|
120
|
+
// way the URL is only warmed, then the compiler rewrites the reference straight to it — and a failed warm
|
|
121
|
+
// changes nothing about what we return, so it stays outside the compile fallback below.
|
|
122
|
+
const isAssetOnly = metaResolveSpecifiers.has(specifier) && !importedSpecifiers.has(specifier);
|
|
123
|
+
if (isSharedInstancePackage(specifier) || isAssetOnly) {
|
|
124
|
+
await Promise.resolve(options?.preload?.(target)).catch(() => {});
|
|
125
|
+
return [specifier, target] as const;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return [specifier, await resolveEsmSh(target, imports)] as const;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// Dropping the entry would make this specifier permanently `missing`, so every stream chunk re-resolves and
|
|
131
|
+
// re-fetches it, and the heal message loses the `(→ https://esm.sh/…)` part that reverse-resolves from the
|
|
132
|
+
// import map. Fall back to the uncompiled target: the browser still fails on it, but with a diagnosable URL.
|
|
133
|
+
if (!warned.has(target)) {
|
|
134
|
+
warned.add(target);
|
|
135
|
+
console.warn(`Falling back to the uncompiled esm.sh target for ${specifier} (${target})`, error);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [specifier, target] as const;
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
return { imports: Object.fromEntries(entries) };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A remote https module worth compiling is one that ships source the browser can't `import` directly. Already-JS URLs
|
|
148
|
+
// (esm.sh builds, .mjs) import natively, so they pass through; only these source extensions need a transpile pass.
|
|
149
|
+
// Only the path decides — a source extension inside a query/hash (`?path=lib/index.ts`) belongs to a native URL that must
|
|
150
|
+
// pass through, not a source module to compile. Splitting off `[?#]` keeps `a.tsx?v=1` matching while dropping that case.
|
|
151
|
+
const REMOTE_SOURCE_EXTENSION = /\.(?:tsx|ts|jsx|mts|cts)$/;
|
|
152
|
+
export const isRemoteSourceModuleSpecifier = (specifier: string) => /^https?:\/\//.test(specifier) && REMOTE_SOURCE_EXTENSION.test(specifier.split(/[?#]/, 1)[0]);
|
|
153
|
+
// The compiler picks its parser from the filename extension, but `filename` must be the post-redirect URL (to rebase
|
|
154
|
+
// relative imports) which a CDN often strips to a signed, extension-less path — so JSX/TS in a redirected module would
|
|
155
|
+
// parse as plain JS and fail. Derive `lang` from the REQUEST URL, which always carries a real source extension, and pass
|
|
156
|
+
// it explicitly so the parser is fixed regardless of where the fetch redirected to.
|
|
157
|
+
const langFromRemoteUrl = (url: string): "ts" | "tsx" | "jsx" => {
|
|
158
|
+
const ext = REMOTE_SOURCE_EXTENSION.exec(url.split(/[?#]/, 1)[0])?.[0];
|
|
159
|
+
return ext === ".tsx" ? "tsx" : ext === ".jsx" ? "jsx" : "ts"; // .mts/.cts/.ts all parse as ts; only .tsx/.jsx enable JSX
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Fetches remote https TSX/TS/JSX components, compiles each to a blob: module, and hands back `{ [httpsUrl]: blobUrl }`
|
|
163
|
+
// so the transform's importMap rewrites the original specifiers. Recurses into a remote module's own remote imports
|
|
164
|
+
// (a global import graph breaks cycles even across concurrent entry branches; a per-provider cache dedupes DAGs and
|
|
165
|
+
// survives across renders). Every bare import is rewritten by @esm.sh/tsx: host-owned packages use the mappings already
|
|
166
|
+
// resolved by earlier providers, and unknown packages use transformed esm.sh bundles.
|
|
167
|
+
export function remoteModuleImportMap(options?: { baseUrl?: string; compiler?: TsxCompiler; fetch?: typeof fetch; dev?: boolean; dependencyRanges?: EsmShDependencyRanges; preloadEsmSh?: (url: string) => Promise<unknown> | unknown; resolveEsmSh?: EsmShModuleResolver }): ImportMapProvider {
|
|
168
|
+
const compiler = options?.compiler ?? createTsxCompiler();
|
|
169
|
+
const fetcher = options?.fetch ?? fetch;
|
|
170
|
+
const blobByUrl = new Map<string, Promise<string>>();
|
|
171
|
+
// No `hasLocalPackage`: a bare dep the host map doesn't already own is never probed against the host's local
|
|
172
|
+
// /node_modules — it goes to esm.sh in every env, unlike the top-level chain's dev local-package preference. (Deps
|
|
173
|
+
// the host map DOES own bind to its targets, so one copy is shared instead of a second esm.sh build.)
|
|
174
|
+
// `dev` must match the top-level chain's: a remote module's esm.sh dep bundles its own reconciler, and a production
|
|
175
|
+
// one mixed with the page's dev jsx-runtime hits the missing-`getOwner` dispatcher.
|
|
176
|
+
const esmSh = transformedEsmShFallback({ dev: options?.dev, dependencyRanges: options?.dependencyRanges, baseUrl: options?.baseUrl, preload: options?.preloadEsmSh, resolveEsmSh: options?.resolveEsmSh, compiler, fetch: fetcher as unknown as (url: string) => Promise<EsmShModuleResponse> });
|
|
177
|
+
|
|
178
|
+
// module → the remote modules it directly imports, recorded synchronously as each edge is discovered. A per-branch
|
|
179
|
+
// `visited` set can't catch a cycle reached through two separate entry branches (a↔b where the entry imports both) —
|
|
180
|
+
// each branch caches an in-flight promise before the other's traversal sees the loop, so they await each other and
|
|
181
|
+
// hang. This global graph makes the cross-branch cycle observable regardless of which branch discovers the edge first.
|
|
182
|
+
const importsOf = new Map<string, Set<string>>();
|
|
183
|
+
const reaches = (from: string, target: string): boolean => {
|
|
184
|
+
const seen = new Set<string>();
|
|
185
|
+
const stack = [from];
|
|
186
|
+
while (stack.length) {
|
|
187
|
+
const url = stack.pop()!;
|
|
188
|
+
if (url === target) return true;
|
|
189
|
+
if (seen.has(url)) continue;
|
|
190
|
+
seen.add(url);
|
|
191
|
+
const deps = importsOf.get(url);
|
|
192
|
+
if (deps) stack.push(...deps);
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// The host map is identical for every node in one graph walk, so `key` (its `importMapCacheKey`) is computed once per
|
|
198
|
+
// resolve() and threaded down with it — re-serializing per module would be O(nodes × map size).
|
|
199
|
+
type BaseMap = { imports: Readonly<Record<string, string>>; key: string };
|
|
200
|
+
|
|
201
|
+
// Resolve a remote module's own imports to a self-contained import map, keyed by ABSOLUTE URL so the transform (given
|
|
202
|
+
// the module's URL as `filename`) matches them after rebasing relatives: nested remote source → blob, bare npm dep →
|
|
203
|
+
// transformed esm.sh blobs. Relative browser-native deps (./x.mjs, ./x.css) need no entry — the transform rebases
|
|
204
|
+
// them to absolute URLs against `filename` on its own. Host runtime entries are passed through to the compiler too.
|
|
205
|
+
// `identityUrl` is how importers reference this module (its request URL — the cycle graph's key, and the URL half of
|
|
206
|
+
// the blobByUrl key). `rebaseUrl` is the post-redirect `response.url`, used ONLY to resolve the module's relative
|
|
207
|
+
// imports. They must stay separate: keying edges by `rebaseUrl` would disconnect the graph at every redirect (a
|
|
208
|
+
// redirected node's own edges land under its final URL while every importer refers to it by its request URL → missed
|
|
209
|
+
// cross-branch cycle).
|
|
210
|
+
const collect = async (code: string, identityUrl: string, rebaseUrl: string, base: BaseMap): Promise<Record<string, string>> => {
|
|
211
|
+
const specifiers = [...extractModuleSpecifiers(code)];
|
|
212
|
+
// Two passes on purpose. Pass 1 records every edge and checks cycles synchronously (no await) so a concurrent sibling
|
|
213
|
+
// branch sees the edges — but it starts NO async work, so a mid-loop cyclic throw can't strand an already-launched
|
|
214
|
+
// resolveModule() as an unhandled rejection. Pass 2 launches the fetches once the whole edge set is known valid.
|
|
215
|
+
let deps = importsOf.get(identityUrl);
|
|
216
|
+
if (!deps) importsOf.set(identityUrl, (deps = new Set()));
|
|
217
|
+
const bareMissing: string[] = [];
|
|
218
|
+
for (const specifier of specifiers) {
|
|
219
|
+
// Rebase only relative/absolute paths against the module URL. A bare specifier whose subpath happens to end in a
|
|
220
|
+
// source extension (`@acme/ui/Button.tsx`, `pkg/foo.ts`) must stay bare for esm.sh — rebasing it first would turn
|
|
221
|
+
// it into a bogus `https://…/pkg/foo.ts` remote fetch that 404s before the fallback can map the package.
|
|
222
|
+
if (isBareModuleSpecifier(specifier)) {
|
|
223
|
+
if (!hasImportMapEntry(base.imports, specifier)) bareMissing.push(specifier);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const absolute = new URL(specifier, rebaseUrl).href;
|
|
227
|
+
if (!isRemoteSourceModuleSpecifier(absolute)) continue;
|
|
228
|
+
// A cycle exists iff the dependency can already reach this module (reaches() returns true for a direct self-import too).
|
|
229
|
+
if (reaches(absolute, identityUrl)) throw new Error(`Cyclic remote module import: ${absolute}`);
|
|
230
|
+
deps.add(absolute);
|
|
231
|
+
}
|
|
232
|
+
const [remote, bare] = await Promise.all([Promise.all([...deps].map((absolute) => resolveModule(absolute, base).then((blob) => [absolute, blob] as const))), esmSh.resolve({ code, missing: bareMissing, imports: base.imports })]);
|
|
233
|
+
return { ...base.imports, ...bare.imports, ...Object.fromEntries(remote) };
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const resolveModule = (url: string, base: BaseMap): Promise<string> => {
|
|
237
|
+
const cacheKey = `${url}\n${base.key}`;
|
|
238
|
+
let blob = blobByUrl.get(cacheKey);
|
|
239
|
+
if (!blob) {
|
|
240
|
+
blob = (async () => {
|
|
241
|
+
const response = await fetcher(url);
|
|
242
|
+
if (!response.ok) throw new Error(`Failed to fetch remote module ${url}: ${response.status} ${response.statusText}`);
|
|
243
|
+
const source = await response.text();
|
|
244
|
+
// A CDN/raw endpoint may redirect to a versioned/storage URL; rebase the module's relative imports against the
|
|
245
|
+
// final `response.url` (falling back to the request URL) so `./Leaf.tsx` resolves from the right directory.
|
|
246
|
+
const finalUrl = response.url || url;
|
|
247
|
+
const imports = await collect(source, url, finalUrl, base);
|
|
248
|
+
// filename = the module's own URL so the transform rebases its relative imports against it (matching the
|
|
249
|
+
// absolute-keyed entries above, and turning relative browser-native imports into absolute URLs natively).
|
|
250
|
+
const { code } = await compiler.compile(source, { filename: finalUrl, lang: langFromRemoteUrl(url), importMap: { imports } });
|
|
251
|
+
return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
|
|
252
|
+
})();
|
|
253
|
+
// A failed fetch/compile must not pin this URL for the provider's lifetime — evict the rejected promise so a
|
|
254
|
+
// later render retries, mirroring probeEsmShSpecifier's fail-open eviction.
|
|
255
|
+
blob.catch(() => {
|
|
256
|
+
blobByUrl.delete(cacheKey);
|
|
257
|
+
importsOf.delete(url);
|
|
258
|
+
});
|
|
259
|
+
blobByUrl.set(cacheKey, blob);
|
|
260
|
+
}
|
|
261
|
+
return blob;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// The entry code isn't fetched or compiled here — the outer resolver chain compiles it and maps its bare specifiers,
|
|
265
|
+
// so this provider only surfaces its remote-source imports as blob: entries (keyed by their already-absolute URL).
|
|
266
|
+
return {
|
|
267
|
+
resolve: async ({ code, imports = {} }): Promise<RendererImportMap> => {
|
|
268
|
+
const compilerImports = prepareRendererImportMap({ imports }, options?.baseUrl).imports;
|
|
269
|
+
const base: BaseMap = { imports: compilerImports, key: importMapCacheKey(Object.entries(compilerImports)) };
|
|
270
|
+
const entries = await Promise.all([...extractModuleSpecifiers(code)].flatMap((specifier) => (isRemoteSourceModuleSpecifier(specifier) ? [resolveModule(specifier, base).then((blob) => [specifier, blob] as const)] : [])));
|
|
271
|
+
return { imports: Object.fromEntries(entries) };
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { Component, createElement, useEffect, type ComponentType, type ReactNode } from "react";
|
|
2
2
|
import { createRoot, type Root } from "react-dom/client";
|
|
3
3
|
import { renderToString } from "react-dom/server";
|
|
4
|
-
import { createGeneratedComponentSlot, type GeneratedComponentSlot } from "./state";
|
|
4
|
+
import { createGeneratedComponentSlot, type GeneratedComponent, type GeneratedComponentSlot } from "./state";
|
|
5
|
+
export type { GeneratedComponent } from "./state";
|
|
6
|
+
import { enrichModuleImportError } from "./importProbe";
|
|
7
|
+
export { GenUIImportError, type GenUIImportDiagnostic } from "./importProbe";
|
|
5
8
|
import { createTsxCompiler, type TsxCompiler, type CompileResult } from "./compiler";
|
|
6
9
|
import { GenUIRenderContext, getGenUIRenderContextValue } from "./renderContext";
|
|
7
10
|
import { getGenUIHookSignature } from "./hookSignature";
|
|
8
11
|
import type { RendererImportMap } from "./importMap";
|
|
9
12
|
|
|
10
13
|
export type GenUIRenderPhase = "transform" | "compile" | "render";
|
|
11
|
-
export type GenUIRendererCallbacks = { onReady?: (component:
|
|
14
|
+
export type GenUIRendererCallbacks = { onReady?: (component: GeneratedComponent, url?: string, code?: string) => void; onRendered?: (component: GeneratedComponent, code: string, serial?: number) => void; onError?: (error: Error, phase: GenUIRenderPhase) => void };
|
|
12
15
|
export type GenUIRendererClearOptions = { preserveVisualState?: boolean };
|
|
13
16
|
export type GenUIRendererFlushMode = "microtask" | "immediate";
|
|
14
|
-
|
|
17
|
+
// `filename` is the base @esm.sh/tsx resolves relative specifiers against. Hosts that render a file from disk pass its
|
|
18
|
+
// path so `./data` stays `./data`; without one the default `_.tsx` turns it into the unresolvable `_.tsx/data` (#1601).
|
|
19
|
+
export type GenUIRendererOptions = { importmap?: RendererImportMap; callbacks?: GenUIRendererCallbacks; compiler?: TsxCompiler; preserveStateOnUpdate?: boolean; flushMode?: GenUIRendererFlushMode; filename?: string };
|
|
15
20
|
export type GenUIRendererUpdateMode = "push" | "render";
|
|
16
21
|
|
|
17
22
|
const attachedRenderer = Symbol.for("@macaron/genui-runtime:attached-renderer");
|
|
@@ -20,9 +25,9 @@ const normalizeError = (error: unknown) => (error instanceof Error ? error : new
|
|
|
20
25
|
const COMPILED_COMPONENT_CACHE_LIMIT = 256;
|
|
21
26
|
let rendererScopeSerial = 0;
|
|
22
27
|
const createRendererScope = () => `renderer:${(rendererScopeSerial += 1).toString(36)}`;
|
|
23
|
-
const compiledComponentCache = new Map<string,
|
|
28
|
+
const compiledComponentCache = new Map<string, GeneratedComponent>();
|
|
24
29
|
const getPreserveBoundaryKey = (epoch: number) => (epoch === 0 ? "boundary" : `boundary:${epoch}`);
|
|
25
|
-
const rememberCompiledComponent = (code: string, component:
|
|
30
|
+
const rememberCompiledComponent = (code: string, component: GeneratedComponent) => {
|
|
26
31
|
if (compiledComponentCache.has(code)) compiledComponentCache.delete(code);
|
|
27
32
|
compiledComponentCache.set(code, component);
|
|
28
33
|
if (compiledComponentCache.size > COMPILED_COMPONENT_CACHE_LIMIT) {
|
|
@@ -31,22 +36,27 @@ const rememberCompiledComponent = (code: string, component: unknown) => {
|
|
|
31
36
|
}
|
|
32
37
|
};
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
type RuntimeErrorBoundaryState = { hasError: boolean; message?: string; lastResetKey?: number };
|
|
40
|
+
|
|
41
|
+
class RuntimeErrorBoundary extends Component<{ resetKey?: number; onError?: (error: Error) => void; children?: ReactNode }, RuntimeErrorBoundaryState> {
|
|
42
|
+
state: RuntimeErrorBoundaryState = { hasError: false };
|
|
43
|
+
// React forwards whatever was thrown, including primitives and null; normalize before reading `.message`.
|
|
44
|
+
static getDerivedStateFromError(error: unknown) {
|
|
45
|
+
return { hasError: true, message: normalizeError(error).message };
|
|
38
46
|
}
|
|
39
|
-
static getDerivedStateFromProps(props: { resetKey?: number }, state:
|
|
47
|
+
static getDerivedStateFromProps(props: { resetKey?: number }, state: RuntimeErrorBoundaryState) {
|
|
40
48
|
// Streaming errors bump resetKey to clear the caught state in place; recreating the boundary via `key`
|
|
41
49
|
// would unmount the entire subtree below and produce visible flicker on every retry.
|
|
42
|
-
if (props.resetKey !== state.lastResetKey) return { hasError: false, lastResetKey: props.resetKey };
|
|
50
|
+
if (props.resetKey !== state.lastResetKey) return { hasError: false, message: undefined, lastResetKey: props.resetKey };
|
|
43
51
|
return null;
|
|
44
52
|
}
|
|
45
|
-
componentDidCatch(error:
|
|
46
|
-
this.props.onError?.(error);
|
|
53
|
+
componentDidCatch(error: unknown) {
|
|
54
|
+
this.props.onError?.(normalizeError(error));
|
|
47
55
|
}
|
|
48
56
|
render() {
|
|
49
|
-
|
|
57
|
+
// Hosts that pass onError paint their own error UI over this; hosts that don't (few-shots, batch) used to be
|
|
58
|
+
// left with a bare "ERROR" and nothing to diagnose from.
|
|
59
|
+
if (this.state.hasError) return this.state.message ? `ERROR: ${this.state.message}` : "ERROR";
|
|
50
60
|
return this.props.children;
|
|
51
61
|
}
|
|
52
62
|
}
|
|
@@ -71,12 +81,15 @@ export class GenUIRenderer {
|
|
|
71
81
|
private currentBuffer = "";
|
|
72
82
|
private compileScheduled = false;
|
|
73
83
|
private compileScheduleToken = 0;
|
|
84
|
+
private compileRunning = false;
|
|
85
|
+
private compilePending: { partial: boolean; serial?: number } | null = null;
|
|
74
86
|
private scheduledPartial = false;
|
|
75
87
|
private scheduledSerial?: number;
|
|
76
88
|
private errorEpoch = 0;
|
|
77
89
|
private finished = false;
|
|
78
90
|
private flushMode: GenUIRendererFlushMode;
|
|
79
91
|
private importmap?: RendererImportMap;
|
|
92
|
+
private filename?: string;
|
|
80
93
|
private lastCompiledCode = "";
|
|
81
94
|
private lastGoodHookSignature = "";
|
|
82
95
|
private lastHookSignature: string | null = null;
|
|
@@ -95,6 +108,7 @@ export class GenUIRenderer {
|
|
|
95
108
|
this.compiler = options.compiler ?? createTsxCompiler();
|
|
96
109
|
this.flushMode = options.flushMode ?? "microtask";
|
|
97
110
|
this.importmap = options.importmap;
|
|
111
|
+
this.filename = options.filename;
|
|
98
112
|
this.preserveStateOnUpdate = options.preserveStateOnUpdate ?? true;
|
|
99
113
|
this.rendererScope = createRendererScope();
|
|
100
114
|
this.slot = createGeneratedComponentSlot();
|
|
@@ -124,7 +138,9 @@ export class GenUIRenderer {
|
|
|
124
138
|
|
|
125
139
|
detach() {
|
|
126
140
|
// Bump compileSerial so any in-flight compile resolves into a no-op instead of adopting onto a dead renderer.
|
|
141
|
+
// Drop the queued follow-up too: it would start a fresh chain (self-consistent serial) that the bump can't invalidate.
|
|
127
142
|
this.compileSerial += 1;
|
|
143
|
+
this.compilePending = null;
|
|
128
144
|
const previous = this.target as AttachableHost | null;
|
|
129
145
|
if (previous && previous[attachedRenderer] === this) delete previous[attachedRenderer];
|
|
130
146
|
this.root?.unmount();
|
|
@@ -221,6 +237,7 @@ export class GenUIRenderer {
|
|
|
221
237
|
this.scheduledPartial = false;
|
|
222
238
|
this.scheduledSerial = undefined;
|
|
223
239
|
this.compileScheduleToken += 1;
|
|
240
|
+
this.compilePending = null;
|
|
224
241
|
this.lastCompiledCode = "";
|
|
225
242
|
if (options.preserveVisualState) return this;
|
|
226
243
|
this.lastGoodHookSignature = "";
|
|
@@ -259,13 +276,36 @@ export class GenUIRenderer {
|
|
|
259
276
|
}
|
|
260
277
|
|
|
261
278
|
private async compile(partial: boolean, renderSerial?: number) {
|
|
279
|
+
// A compile chain (transform + blob import) spans multiple event-loop turns, so under per-frame pushes starting a
|
|
280
|
+
// new chain per push keeps bumping compileSerial and every in-flight result resolves superseded — nothing commits
|
|
281
|
+
// until the pushes stop (the storyboard's per-rAF typewriter froze previews this way). Run chains single-flight:
|
|
282
|
+
// remember only the latest request and start it once the current chain settles; compileOnce reads currentBuffer
|
|
283
|
+
// fresh at start, so all pushes queued behind a chain coalesce into one follow-up compile.
|
|
284
|
+
if (this.compileRunning) {
|
|
285
|
+
this.compilePending = { partial, serial: renderSerial };
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
this.compileRunning = true;
|
|
289
|
+
try {
|
|
290
|
+
await this.compileOnce(partial, renderSerial);
|
|
291
|
+
} finally {
|
|
292
|
+
this.compileRunning = false;
|
|
293
|
+
const pending = this.compilePending;
|
|
294
|
+
this.compilePending = null;
|
|
295
|
+
if (pending) void this.compile(pending.partial, pending.serial);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async compileOnce(partial: boolean, renderSerial?: number) {
|
|
262
300
|
const serial = (this.compileSerial += 1);
|
|
263
301
|
const code = this.currentBuffer;
|
|
264
302
|
let result: CompileResult;
|
|
265
303
|
try {
|
|
266
|
-
result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode });
|
|
304
|
+
result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode, filename: this.filename });
|
|
267
305
|
} catch (error) {
|
|
268
|
-
|
|
306
|
+
// A queued follow-up means this frame is already superseded; its error is stale noise (pre-single-flight, the
|
|
307
|
+
// newer compile's serial bump suppressed it the same way).
|
|
308
|
+
if (serial !== this.compileSerial || this.compilePending) return;
|
|
269
309
|
this.callbacks.onError?.(normalizeError(error), "transform");
|
|
270
310
|
return;
|
|
271
311
|
}
|
|
@@ -294,17 +334,26 @@ export class GenUIRenderer {
|
|
|
294
334
|
this.slot.setCurrent(component);
|
|
295
335
|
this.renderComponent(component, code, renderSerial, result.source);
|
|
296
336
|
} catch (error) {
|
|
297
|
-
if (serial !== this.compileSerial) return;
|
|
337
|
+
if (serial !== this.compileSerial || this.compilePending) return;
|
|
298
338
|
this.callbacks.onError?.(normalizeError(error), "compile");
|
|
299
339
|
}
|
|
300
340
|
}
|
|
301
341
|
|
|
302
|
-
private async importCompiledComponent(code: string) {
|
|
342
|
+
private async importCompiledComponent(code: string): Promise<{ component: GeneratedComponent; moduleUrl?: string }> {
|
|
303
343
|
const cached = compiledComponentCache.get(code);
|
|
304
344
|
if (cached) return { component: cached };
|
|
305
345
|
const moduleUrl = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
|
|
306
346
|
try {
|
|
307
|
-
|
|
347
|
+
let module: { default?: GeneratedComponent };
|
|
348
|
+
try {
|
|
349
|
+
module = (await import(/* @vite-ignore */ moduleUrl)) as { default?: GeneratedComponent };
|
|
350
|
+
} catch (error) {
|
|
351
|
+
// The browser reports a failed blob module as one opaque "Failed to fetch dynamically imported module" without
|
|
352
|
+
// naming the entry that broke; re-probe each dependency to raise an actionable, heal-ready error instead. #1371
|
|
353
|
+
throw await enrichModuleImportError(error, code, this.importmap?.imports);
|
|
354
|
+
}
|
|
355
|
+
// A missing default is a transient "stream not finished yet" frame, not a dependency failure: the blob import
|
|
356
|
+
// resolved, so every dependency already linked, and probing would re-scan cached modules to no effect.
|
|
308
357
|
if (!module.default) throw new Error("No default export found in compiled module.");
|
|
309
358
|
rememberCompiledComponent(code, module.default);
|
|
310
359
|
return { component: module.default, moduleUrl };
|
|
@@ -325,7 +374,7 @@ export class GenUIRenderer {
|
|
|
325
374
|
this.moduleUrl = null;
|
|
326
375
|
}
|
|
327
376
|
|
|
328
|
-
private scheduleRenderedCallback(component:
|
|
377
|
+
private scheduleRenderedCallback(component: GeneratedComponent, code: string, renderSerial: number | undefined, compileSerial: number) {
|
|
329
378
|
const notify = () => {
|
|
330
379
|
if (compileSerial === this.compileSerial) this.callbacks.onRendered?.(component, code, renderSerial);
|
|
331
380
|
};
|
package/src/state.ts
CHANGED
|
@@ -2,12 +2,20 @@ import { createElement, type ComponentType, type ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
export type GeneratedFunctionComponent = (props: Record<string, unknown>) => ReactNode | Promise<ReactNode>;
|
|
4
4
|
export type GeneratedComponent = ComponentType<Record<string, unknown>> | GeneratedFunctionComponent;
|
|
5
|
-
export type GeneratedComponentSlot = {
|
|
5
|
+
export type GeneratedComponentSlot = {
|
|
6
|
+
Component: GeneratedFunctionComponent;
|
|
7
|
+
current: () => GeneratedComponent | null;
|
|
8
|
+
lastGood: () => GeneratedComponent | null;
|
|
9
|
+
setCurrent: (component: GeneratedComponent) => void;
|
|
10
|
+
markRendered: (component?: GeneratedComponent | null) => void;
|
|
11
|
+
restoreLastGood: () => boolean;
|
|
12
|
+
clear: () => void;
|
|
13
|
+
};
|
|
6
14
|
const isClassComponent = (component: unknown) => typeof component === "function" && Boolean((component as { prototype?: { isReactComponent?: unknown } }).prototype?.isReactComponent);
|
|
7
15
|
|
|
8
16
|
export function createGeneratedComponentSlot(): GeneratedComponentSlot {
|
|
9
|
-
const currentRef = { current: null as
|
|
10
|
-
const lastGoodRef = { current: null as
|
|
17
|
+
const currentRef = { current: null as GeneratedComponent | null };
|
|
18
|
+
const lastGoodRef = { current: null as GeneratedComponent | null };
|
|
11
19
|
const Component = (props: Record<string, unknown>) => {
|
|
12
20
|
const Current = currentRef.current;
|
|
13
21
|
if (!Current) return null;
|