partial-react 0.0.1 → 0.0.2
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 +8 -7
- package/src/hookSignature.ts +1 -1
- package/src/importMap.ts +211 -21
- package/src/remoteModule.ts +120 -0
- package/src/{runtime.tsx → runtime.ts} +38 -9
- 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.2",
|
|
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.2"
|
|
24
25
|
},
|
|
25
26
|
"peerDependencies": {
|
|
26
27
|
"react": "^19.0.0",
|
package/src/compiler.ts
CHANGED
|
@@ -3,22 +3,23 @@ import tsxWasmUrl from "@esm.sh/tsx/pkg/tsx_bg.wasm?url";
|
|
|
3
3
|
import { normalizeGeneratedTsx } from "partial-tsx";
|
|
4
4
|
import 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,8 @@ 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 result = transformTsx({ filename: options.filename ?? "_.tsx", code: source, target: "es2022", importMap: options.importMap, jsxImportSource: options.jsxImportSource ?? "react" });
|
|
34
|
+
const source = options.partial ? normalizeGeneratedTsx(code, { mode: "streaming" }) : code;
|
|
35
|
+
const result = transformTsx({ filename: options.filename ?? "_.tsx", code: source, lang: options.lang, target: "es2022", importMap: options.importMap, jsxImportSource: options.jsxImportSource ?? "react" });
|
|
35
36
|
const compiled = new TextDecoder().decode(result.code);
|
|
36
37
|
return { code: compiled, source, changed: compiled !== options.previousCode };
|
|
37
38
|
},
|
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,26 @@
|
|
|
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
|
+
|
|
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[] };
|
|
6
|
+
export type ImportMapProvider = { kind?: "base" | "fallback"; prefetch?: () => Promise<unknown> | void; resolve: (context: ImportMapProviderContext) => RendererImportMap | Promise<RendererImportMap> };
|
|
2
7
|
|
|
3
8
|
const ESM_SH_VERSION_OVERRIDES = { "@number-flow/react": "0.6.0" } as const;
|
|
9
|
+
const DEFAULT_ESM_SH_EXTERNALS = ["react", "react-dom", "scheduler"] as const;
|
|
10
|
+
export const RUNTIME_SINGLETON_SPECIFIERS = ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime", "scheduler"] as const;
|
|
11
|
+
export type RuntimeSingletonSpecifier = (typeof RUNTIME_SINGLETON_SPECIFIERS)[number];
|
|
12
|
+
export const isRuntimeSingletonSpecifier = (specifier: string): specifier is RuntimeSingletonSpecifier => RUNTIME_SINGLETON_SPECIFIERS.includes(specifier as RuntimeSingletonSpecifier);
|
|
13
|
+
// A trailing-slash import-map key also owns matching singleton subpaths.
|
|
14
|
+
export const isRuntimeSingletonImportMapKey = (specifier: string) => isRuntimeSingletonSpecifier(specifier) || (specifier.endsWith("/") && RUNTIME_SINGLETON_SPECIFIERS.some((runtimeSpecifier) => runtimeSpecifier.startsWith(specifier)));
|
|
4
15
|
const IDENTIFIER_CHAR_PATTERN = /[\w$]/;
|
|
5
16
|
const REGEX_PREFIX_WORDS = new Set(["return", "throw", "case", "delete", "typeof", "void", "new", "in", "of", "await", "yield"]);
|
|
6
17
|
const LOCAL_EXPORT_KEYWORDS = ["abstract", "async", "class", "const", "declare", "default", "enum", "function", "interface", "let", "namespace", "var"];
|
|
7
18
|
|
|
8
19
|
export const isBareModuleSpecifier = (specifier: string) => !specifier.startsWith(".") && !specifier.startsWith("/") && !/^(?:[a-z][a-z\d+.-]*:|#)/i.test(specifier);
|
|
9
20
|
export const getBarePackageName = (specifier: string) => (specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0] || specifier);
|
|
21
|
+
// Shared #1178 contract: /src/… and /node_modules/… targets are root-relative disk paths each engine rebases against
|
|
22
|
+
// its own root (dev: Vite's resolved root; build: cwd). Single-sourced so a new local prefix lands in both engines.
|
|
23
|
+
export const isLocalRootRelativeTarget = (target: string) => target.startsWith("/src/") || target.startsWith("/node_modules/");
|
|
10
24
|
const canUseEsmShFallback = (specifier: string) => isBareModuleSpecifier(specifier) && !specifier.startsWith("@/");
|
|
11
25
|
|
|
12
26
|
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 +66,13 @@ const readStringLiteral = (code: string, index: number) => {
|
|
|
52
66
|
index += 2;
|
|
53
67
|
continue;
|
|
54
68
|
}
|
|
55
|
-
if (char === quote) return { value, end: index + 1 };
|
|
69
|
+
if (char === quote) return { value, end: index + 1, terminated: true };
|
|
56
70
|
value += char;
|
|
57
71
|
index += 1;
|
|
58
72
|
}
|
|
59
|
-
|
|
73
|
+
// Unterminated literal (a streamed import mid-chunk, e.g. `import X from "https://x/Foo.ts`). Report end so scanning
|
|
74
|
+
// still advances past it, but flag it so callers don't treat a half-typed specifier as a real import to fetch.
|
|
75
|
+
return { value, end: code.length, terminated: false };
|
|
60
76
|
};
|
|
61
77
|
|
|
62
78
|
const skipTemplateLiteral = (code: string, index: number, onExpression?: (start: number, end: number) => void) => {
|
|
@@ -172,7 +188,7 @@ const readFromSpecifier = (code: string, index: number) => {
|
|
|
172
188
|
}
|
|
173
189
|
if (isKeywordAt(code, index, "from")) {
|
|
174
190
|
const literal = readStringLiteral(code, skipWhitespaceAndComments(code, index + "from".length));
|
|
175
|
-
if (literal) return { specifier: literal.value, end: literal.end };
|
|
191
|
+
if (literal) return { specifier: literal.value, end: literal.end, terminated: literal.terminated };
|
|
176
192
|
index += "from".length;
|
|
177
193
|
continue;
|
|
178
194
|
}
|
|
@@ -189,7 +205,7 @@ const readExportFromSpecifier = (code: string, index: number) => {
|
|
|
189
205
|
return readFromSpecifier(code, next);
|
|
190
206
|
};
|
|
191
207
|
|
|
192
|
-
const
|
|
208
|
+
const collectModuleSpecifiers = (code: string, specifiers: Set<string>, includeSpecifier: (specifier: string) => boolean, start = 0, end = code.length) => {
|
|
193
209
|
for (let index = start; index < end; index += 1) {
|
|
194
210
|
const char = code[index];
|
|
195
211
|
if (char === "/" && code[index + 1] === "/") {
|
|
@@ -209,7 +225,7 @@ const collectBareModuleSpecifiers = (code: string, specifiers: Set<string>, star
|
|
|
209
225
|
continue;
|
|
210
226
|
}
|
|
211
227
|
if (char === "`") {
|
|
212
|
-
index = skipTemplateLiteral(code, index, (expressionStart, expressionEnd) =>
|
|
228
|
+
index = skipTemplateLiteral(code, index, (expressionStart, expressionEnd) => collectModuleSpecifiers(code, specifiers, includeSpecifier, expressionStart, expressionEnd));
|
|
213
229
|
continue;
|
|
214
230
|
}
|
|
215
231
|
if (isKeywordAt(code, index, "import")) {
|
|
@@ -220,19 +236,19 @@ const collectBareModuleSpecifiers = (code: string, specifiers: Set<string>, star
|
|
|
220
236
|
}
|
|
221
237
|
if (code[afterImport] === "(") {
|
|
222
238
|
const literal = readStringLiteral(code, skipWhitespaceAndComments(code, afterImport + 1));
|
|
223
|
-
if (literal?.value &&
|
|
239
|
+
if (literal?.terminated && literal.value && includeSpecifier(literal.value)) specifiers.add(literal.value);
|
|
224
240
|
index = literal?.end ?? afterImport;
|
|
225
241
|
continue;
|
|
226
242
|
}
|
|
227
243
|
const sideEffect = readStringLiteral(code, afterImport);
|
|
228
|
-
const from = sideEffect ? { specifier: sideEffect.value, end: sideEffect.end } : readFromSpecifier(code, afterImport);
|
|
229
|
-
if (from?.specifier &&
|
|
244
|
+
const from = sideEffect ? { specifier: sideEffect.value, end: sideEffect.end, terminated: sideEffect.terminated } : readFromSpecifier(code, afterImport);
|
|
245
|
+
if (from?.terminated && from.specifier && includeSpecifier(from.specifier)) specifiers.add(from.specifier);
|
|
230
246
|
index = from?.end ?? afterImport;
|
|
231
247
|
continue;
|
|
232
248
|
}
|
|
233
249
|
if (isKeywordAt(code, index, "export")) {
|
|
234
250
|
const from = readExportFromSpecifier(code, index + "export".length);
|
|
235
|
-
if (from?.specifier &&
|
|
251
|
+
if (from?.terminated && from.specifier && includeSpecifier(from.specifier)) specifiers.add(from.specifier);
|
|
236
252
|
index = from?.end ?? index;
|
|
237
253
|
}
|
|
238
254
|
}
|
|
@@ -240,7 +256,13 @@ const collectBareModuleSpecifiers = (code: string, specifiers: Set<string>, star
|
|
|
240
256
|
|
|
241
257
|
export function extractBareModuleSpecifiers(code: string) {
|
|
242
258
|
const specifiers = new Set<string>();
|
|
243
|
-
|
|
259
|
+
collectModuleSpecifiers(code, specifiers, isBareModuleSpecifier);
|
|
260
|
+
return specifiers;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function extractModuleSpecifiers(code: string) {
|
|
264
|
+
const specifiers = new Set<string>();
|
|
265
|
+
collectModuleSpecifiers(code, specifiers, () => true);
|
|
244
266
|
return specifiers;
|
|
245
267
|
}
|
|
246
268
|
|
|
@@ -251,25 +273,193 @@ export function hasImportMapEntry(imports: Record<string, string> | Set<string>
|
|
|
251
273
|
return false;
|
|
252
274
|
}
|
|
253
275
|
|
|
254
|
-
export function toEsmShImportUrl(specifier: string) {
|
|
276
|
+
export function toEsmShImportUrl(specifier: string, external: readonly string[] = DEFAULT_ESM_SH_EXTERNALS) {
|
|
255
277
|
if (!canUseEsmShFallback(specifier)) throw new Error(`Cannot create an esm.sh fallback for ${specifier}`);
|
|
256
278
|
const packageName = getBarePackageName(specifier);
|
|
257
279
|
const version = ESM_SH_VERSION_OVERRIDES[packageName as keyof typeof ESM_SH_VERSION_OVERRIDES];
|
|
258
280
|
const versionedSpecifier = version ? `${packageName}@${version}${specifier.slice(packageName.length)}` : specifier;
|
|
259
281
|
const separator = versionedSpecifier.includes("?") ? "&" : "?";
|
|
260
|
-
|
|
282
|
+
const externalParam = external.length ? `&external=${external.join(",")}` : "";
|
|
283
|
+
return `https://esm.sh/${versionedSpecifier}${separator}bundle&target=es2022${externalParam}`;
|
|
261
284
|
}
|
|
262
285
|
|
|
286
|
+
export type EsmShProbe = { alive: true } | { alive: false; detail: string };
|
|
287
|
+
type EsmShProbeFetch = (url: string) => Promise<Pick<Response, "ok" | "status" | "text" | "json">>;
|
|
288
|
+
// esm.sh 404/2xx verdicts are deterministic per specifier, so they're cached for the process lifetime; transient
|
|
289
|
+
// fail-open verdicts (5xx/rate-limit/network) are evicted on resolve so they don't pin. Tests inject a fetcher and
|
|
290
|
+
// must use per-test specifiers to dodge the shared cache.
|
|
291
|
+
const esmShProbeCache = new Map<string, Promise<EsmShProbe | null>>();
|
|
292
|
+
|
|
293
|
+
// A dead entry usually means the model hallucinated the package or its layout; the registry's real subpaths are the
|
|
294
|
+
// heal signal that lets it rewrite the import in one shot.
|
|
295
|
+
const MAX_HINT_SUBPATHS = 8; // enough real subpaths for the model to spot the right one without bloating the diagnostic
|
|
296
|
+
const registryExportsHint = async (packageName: string, fetcher: EsmShProbeFetch) => {
|
|
297
|
+
const response = await fetcher(`https://registry.npmjs.org/${packageName}/latest`);
|
|
298
|
+
if (!response.ok) return response.status === 404 ? "the package does not exist on npm" : "";
|
|
299
|
+
const { exports: exportsMap } = (await response.json()) as { exports?: Record<string, unknown> };
|
|
300
|
+
const subpaths = Object.keys(exportsMap ?? {}).filter((key) => key.startsWith("./"));
|
|
301
|
+
return subpaths.length ? `its real exports subpaths are ${subpaths.slice(0, MAX_HINT_SUBPATHS).join(", ")}${subpaths.length > MAX_HINT_SUBPATHS ? `, … (${subpaths.length} total)` : ""}` : "";
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Would the esm.sh fallback actually answer this specifier at render time? Null means the fallback never applies
|
|
305
|
+
// (relative path, `@/` alias) — the caller should leave its own resolution verdict alone. Only a deterministic 404
|
|
306
|
+
// condemns an import: network failures and 5xx must not fail checks that run offline or during an esm.sh incident. #1372
|
|
307
|
+
export const probeEsmShSpecifier = (specifier: string, fetcher: EsmShProbeFetch = fetch): Promise<EsmShProbe | null> => {
|
|
308
|
+
let probe = esmShProbeCache.get(specifier);
|
|
309
|
+
if (!probe) {
|
|
310
|
+
probe = (async (): Promise<EsmShProbe | null> => {
|
|
311
|
+
let url: string;
|
|
312
|
+
try {
|
|
313
|
+
url = toEsmShImportUrl(specifier);
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
// A non-404 status esm.sh can't build deterministically (5xx incident, 429/403 npm rate-limit) or a network
|
|
318
|
+
// failure fails open so offline `check` still passes, but that verdict isn't deterministic; evict it so a
|
|
319
|
+
// genuinely-dead import isn't pinned `alive` for the host's lifetime.
|
|
320
|
+
const failOpenTransiently = (): EsmShProbe => {
|
|
321
|
+
esmShProbeCache.delete(specifier);
|
|
322
|
+
return { alive: true };
|
|
323
|
+
};
|
|
324
|
+
try {
|
|
325
|
+
const response = await fetcher(url);
|
|
326
|
+
if (response.status === 404) {
|
|
327
|
+
const body = (await response.text()).trim().split("\n")[0];
|
|
328
|
+
const hint = await registryExportsHint(getBarePackageName(specifier), fetcher).catch(() => "");
|
|
329
|
+
return { alive: false, detail: `The renderer's esm.sh fallback fails too (404: ${body})${hint ? `; ${hint}` : ""}.` };
|
|
330
|
+
}
|
|
331
|
+
// Only a 2xx means esm.sh actually resolved the build; anything else joins the fail-open path above.
|
|
332
|
+
return response.ok ? { alive: true } : failOpenTransiently();
|
|
333
|
+
} catch {
|
|
334
|
+
return failOpenTransiently();
|
|
335
|
+
}
|
|
336
|
+
})();
|
|
337
|
+
esmShProbeCache.set(specifier, probe);
|
|
338
|
+
}
|
|
339
|
+
return probe;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// `build` fetch-bundles remote http(s) module imports and dev/the runtime import them directly, so the URL itself is
|
|
343
|
+
// the authority to consult before condemning a remote specifier's TS2307 (#1468). Same doctrine as the esm.sh probe:
|
|
344
|
+
// only a deterministic 404 condemns; transient verdicts (5xx/rate-limit/network) fail open and are evicted so they don't pin.
|
|
345
|
+
const remoteModuleProbeCache = new Map<string, Promise<EsmShProbe>>();
|
|
346
|
+
export const probeRemoteModuleUrl = (url: string, fetcher: EsmShProbeFetch = fetch): Promise<EsmShProbe> => {
|
|
347
|
+
let probe = remoteModuleProbeCache.get(url);
|
|
348
|
+
if (!probe) {
|
|
349
|
+
probe = (async (): Promise<EsmShProbe> => {
|
|
350
|
+
const failOpenTransiently = (): EsmShProbe => {
|
|
351
|
+
remoteModuleProbeCache.delete(url);
|
|
352
|
+
return { alive: true };
|
|
353
|
+
};
|
|
354
|
+
try {
|
|
355
|
+
const response = await fetcher(url);
|
|
356
|
+
if (response.status === 404) return { alive: false, detail: `The renderer's remote module fetch fails too (404: ${(await response.text()).trim().split("\n")[0]}).` };
|
|
357
|
+
return response.ok ? { alive: true } : failOpenTransiently();
|
|
358
|
+
} catch {
|
|
359
|
+
return failOpenTransiently();
|
|
360
|
+
}
|
|
361
|
+
})();
|
|
362
|
+
remoteModuleProbeCache.set(url, probe);
|
|
363
|
+
}
|
|
364
|
+
return probe;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// One probe for every specifier `check`'s local TS resolution can't see: remote http(s) URLs consult the origin itself,
|
|
368
|
+
// bare specifiers consult the esm.sh fallback, anything else (relative path, `@/` alias) keeps the caller's own verdict.
|
|
369
|
+
export const probeImportSpecifier = (specifier: string, fetcher: EsmShProbeFetch = fetch): Promise<EsmShProbe | null> => (/^https?:\/\//i.test(specifier) ? probeRemoteModuleUrl(specifier, fetcher) : probeEsmShSpecifier(specifier, fetcher));
|
|
370
|
+
|
|
263
371
|
export function prepareRendererImportMap(map: RendererImportMap | null | undefined, baseUrl = typeof location === "undefined" ? undefined : location.href) {
|
|
264
|
-
const
|
|
265
|
-
|
|
372
|
+
const resolveTarget = (target: string) => (baseUrl ? new URL(target, baseUrl).href : target);
|
|
373
|
+
const resolveEntries = (entries: Record<string, string>) => Object.fromEntries(Object.entries(entries).map(([specifier, target]) => [specifier, resolveTarget(target)]));
|
|
374
|
+
const imports = resolveEntries(map?.imports ?? {});
|
|
375
|
+
const styles = map?.styles?.map(resolveTarget);
|
|
376
|
+
const scopes = map?.scopes && Object.fromEntries(Object.entries(map.scopes).map(([scope, entries]) => [scope, resolveEntries(entries)]));
|
|
377
|
+
return { imports, ...(scopes ? { scopes } : {}), ...(styles?.length ? { styles } : {}) };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function literalImportMap(map: RendererImportMap): ImportMapProvider {
|
|
381
|
+
return { resolve: () => map };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Loads a remote/JSON import map once. `prefetch()` and `resolve()` share the in-flight promise so an idle-time
|
|
385
|
+
// prefetch and a later render both reuse the same fetch instead of hitting the network twice.
|
|
386
|
+
export function urlImportMap(source: string, options?: { kind?: ImportMapProvider["kind"]; fetch?: (source: string) => Promise<Pick<Response, "ok" | "status" | "statusText" | "json">> }): ImportMapProvider {
|
|
387
|
+
const fetcher = options?.fetch ?? fetch;
|
|
388
|
+
let loadPromise: Promise<RendererImportMap> | undefined;
|
|
389
|
+
const load = () =>
|
|
390
|
+
(loadPromise ??= (async () => {
|
|
391
|
+
const response = await fetcher(source);
|
|
392
|
+
if (!response.ok) throw new Error(`Failed to load import map ${source}: ${response.status} ${response.statusText}`);
|
|
393
|
+
const baseUrl = typeof location === "undefined" ? source : new URL(source, location.href).href;
|
|
394
|
+
return prepareRendererImportMap((await response.json()) as RendererImportMap, baseUrl);
|
|
395
|
+
})());
|
|
396
|
+
return { kind: options?.kind ?? "base", prefetch: load, resolve: load };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Node-only: resolve `packageName`'s package.json#genui.importMap into a provider. Relative targets (./dist/…) are
|
|
400
|
+
// rebased to absolute disk paths so Vite/Rollup can load them; absolute paths and URLs pass through. Throws if the
|
|
401
|
+
// package declares no genui.importMap. Resolution is from the caller's cwd (where the user installed the package),
|
|
402
|
+
// not this file's location — so `bunx @genui/cli -i @macaron/ui` finds the user's package, not the CLI's. node:
|
|
403
|
+
// imports stay inside resolve() so this module remains browser-bundleable.
|
|
404
|
+
export function packageImportMap(packageName: string, options?: { resolve?: (name: string) => string }): ImportMapProvider {
|
|
405
|
+
return {
|
|
406
|
+
resolve: async () => {
|
|
407
|
+
const [{ createRequire }, { readFile }, { dirname, resolve: resolvePath }] = await Promise.all([import("node:module"), import("node:fs/promises"), import("node:path")]);
|
|
408
|
+
const resolveName = options?.resolve ?? createRequire(resolvePath("package.json")).resolve;
|
|
409
|
+
const manifestPath = resolveName(`${packageName}/package.json`);
|
|
410
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { genui?: { importMap?: RendererImportMap } };
|
|
411
|
+
const importMap = manifest.genui?.importMap;
|
|
412
|
+
if (!importMap) throw new Error(`${packageName} has no package.json#genui.importMap — add it to declare the GenUI importmap contract`);
|
|
413
|
+
const packageDir = dirname(manifestPath);
|
|
414
|
+
const rebase = (target: string) => (target.startsWith(".") ? resolvePath(packageDir, target) : target);
|
|
415
|
+
return { imports: Object.fromEntries(Object.entries(importMap.imports ?? {}).map(([specifier, target]) => [specifier, rebase(target)])), styles: importMap.styles?.map(rebase) };
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// esm.sh fallback only answers for `missing` specifiers, so the resolver can merge its output without re-checking
|
|
421
|
+
// coverage. In dev, locally installed packages are preferred so peer deps like React stay on the preview's runtime.
|
|
422
|
+
export function esmShFallback(options?: { external?: readonly string[]; hasLocalPackage?: (packageName: string) => Promise<boolean> }): ImportMapProvider {
|
|
423
|
+
return {
|
|
424
|
+
kind: "fallback",
|
|
425
|
+
resolve: async ({ missing }) => {
|
|
426
|
+
const entries = await Promise.all(
|
|
427
|
+
missing.filter(canUseEsmShFallback).map(async (specifier) => {
|
|
428
|
+
const isLocal = await options?.hasLocalPackage?.(getBarePackageName(specifier));
|
|
429
|
+
return [specifier, isLocal ? `/node_modules/${specifier}` : toEsmShImportUrl(specifier, options?.external)] as const;
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
return { imports: Object.fromEntries(entries) };
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function createImportMapResolver(providers: ImportMapProvider[]) {
|
|
438
|
+
return {
|
|
439
|
+
// Idle/build-time warmup only touches base providers. Fallback providers stay lazy until resolve() has scanned the
|
|
440
|
+
// TSX for missing specifiers, so prefetching them would fetch remote maps the rendered code may never reference.
|
|
441
|
+
prefetch: () => Promise.all(providers.filter((provider) => provider.kind !== "fallback").map((provider) => provider.prefetch?.())).then(() => undefined),
|
|
442
|
+
async resolve({ code = "" }: { code?: string } = {}) {
|
|
443
|
+
const required = [...extractBareModuleSpecifiers(code)];
|
|
444
|
+
let imports: Record<string, string> = {};
|
|
445
|
+
let scopes: RendererImportMap["scopes"];
|
|
446
|
+
let styles: string[] | undefined;
|
|
447
|
+
for (const provider of providers) {
|
|
448
|
+
const covered = new Set(Object.keys(imports));
|
|
449
|
+
const missing = required.filter((specifier) => !hasImportMapEntry(covered, specifier));
|
|
450
|
+
if (provider.kind === "fallback" && missing.length === 0) continue;
|
|
451
|
+
const next = await provider.resolve({ code, missing });
|
|
452
|
+
imports = { ...imports, ...next.imports };
|
|
453
|
+
// scopes is an advanced compat field with no producer today, so a later provider replaces an earlier one's
|
|
454
|
+
// entries for the same scope key rather than deep-merging them. Revisit if two providers ever both emit scopes.
|
|
455
|
+
if (next.scopes) scopes = { ...scopes, ...next.scopes };
|
|
456
|
+
if (next.styles?.length) styles = [...(styles ?? []), ...next.styles];
|
|
457
|
+
}
|
|
458
|
+
return { imports, ...(scopes ? { scopes } : {}), ...(styles?.length ? { styles: [...new Set(styles)] } : {}) };
|
|
459
|
+
},
|
|
460
|
+
};
|
|
266
461
|
}
|
|
267
462
|
|
|
268
463
|
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;
|
|
464
|
+
return (await createImportMapResolver([literalImportMap({ imports }), esmShFallback(options)]).resolve({ code })).imports;
|
|
275
465
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createTsxCompiler, type TsxCompiler } from "./compiler";
|
|
2
|
+
import { esmShFallback, extractModuleSpecifiers, isBareModuleSpecifier, isRuntimeSingletonSpecifier, type ImportMapProvider, type RendererImportMap } from "./importMap";
|
|
3
|
+
|
|
4
|
+
// A remote https module worth compiling is one that ships source the browser can't `import` directly. Already-JS URLs
|
|
5
|
+
// (esm.sh builds, .mjs) import natively, so they pass through; only these source extensions need a transpile pass.
|
|
6
|
+
// Only the path decides — a source extension inside a query/hash (`?path=lib/index.ts`) belongs to a native URL that must
|
|
7
|
+
// pass through, not a source module to compile. Splitting off `[?#]` keeps `a.tsx?v=1` matching while dropping that case.
|
|
8
|
+
const REMOTE_SOURCE_EXTENSION = /\.(?:tsx|ts|jsx|mts|cts)$/;
|
|
9
|
+
export const isRemoteSourceModuleSpecifier = (specifier: string) => /^https?:\/\//.test(specifier) && REMOTE_SOURCE_EXTENSION.test(specifier.split(/[?#]/, 1)[0]);
|
|
10
|
+
// The compiler picks its parser from the filename extension, but `filename` must be the post-redirect URL (to rebase
|
|
11
|
+
// relative imports) which a CDN often strips to a signed, extension-less path — so JSX/TS in a redirected module would
|
|
12
|
+
// parse as plain JS and fail. Derive `lang` from the REQUEST URL, which always carries a real source extension, and pass
|
|
13
|
+
// it explicitly so the parser is fixed regardless of where the fetch redirected to.
|
|
14
|
+
const langFromRemoteUrl = (url: string): "ts" | "tsx" | "jsx" => {
|
|
15
|
+
const ext = REMOTE_SOURCE_EXTENSION.exec(url.split(/[?#]/, 1)[0])?.[0];
|
|
16
|
+
return ext === ".tsx" ? "tsx" : ext === ".jsx" ? "jsx" : "ts"; // .mts/.cts/.ts all parse as ts; only .tsx/.jsx enable JSX
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Fetches remote https TSX/TS/JSX components, compiles each to a blob: module, and hands back `{ [httpsUrl]: blobUrl }`
|
|
20
|
+
// so the transform's importMap rewrites the original specifiers. Recurses into a remote module's own remote imports
|
|
21
|
+
// (a global import graph breaks cycles even across concurrent entry branches; a per-provider cache dedupes DAGs and
|
|
22
|
+
// survives across renders). React runtime singletons stay bare and resolve through the page's importmap, exactly like
|
|
23
|
+
// the esm.sh fallback path.
|
|
24
|
+
export function remoteModuleImportMap(options?: { compiler?: TsxCompiler; fetch?: typeof fetch }): ImportMapProvider {
|
|
25
|
+
const compiler = options?.compiler ?? createTsxCompiler();
|
|
26
|
+
const fetcher = options?.fetch ?? fetch;
|
|
27
|
+
const blobByUrl = new Map<string, Promise<string>>();
|
|
28
|
+
// No `hasLocalPackage`: a remote module's own bare deps deliberately don't bind to the host's local /node_modules —
|
|
29
|
+
// third-party remote code resolves through esm.sh in every env, unlike the top-level chain's dev local-package preference.
|
|
30
|
+
const esmSh = esmShFallback();
|
|
31
|
+
|
|
32
|
+
// module → the remote modules it directly imports, recorded synchronously as each edge is discovered. A per-branch
|
|
33
|
+
// `visited` set can't catch a cycle reached through two separate entry branches (a↔b where the entry imports both) —
|
|
34
|
+
// each branch caches an in-flight promise before the other's traversal sees the loop, so they await each other and
|
|
35
|
+
// hang. This global graph makes the cross-branch cycle observable regardless of which branch discovers the edge first.
|
|
36
|
+
const importsOf = new Map<string, Set<string>>();
|
|
37
|
+
const reaches = (from: string, target: string): boolean => {
|
|
38
|
+
const seen = new Set<string>();
|
|
39
|
+
const stack = [from];
|
|
40
|
+
while (stack.length) {
|
|
41
|
+
const url = stack.pop()!;
|
|
42
|
+
if (url === target) return true;
|
|
43
|
+
if (seen.has(url)) continue;
|
|
44
|
+
seen.add(url);
|
|
45
|
+
const deps = importsOf.get(url);
|
|
46
|
+
if (deps) stack.push(...deps);
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Resolve a remote module's own imports to a self-contained import map, keyed by ABSOLUTE URL so the transform (given
|
|
52
|
+
// the module's URL as `filename`) matches them after rebasing relatives: nested remote source → blob, bare npm dep →
|
|
53
|
+
// esm.sh. Relative browser-native deps (./x.mjs, ./x.css) need no entry — the transform rebases them to absolute URLs
|
|
54
|
+
// against `filename` on its own. Runtime singletons stay bare for the page importmap; esm.sh builds pass through.
|
|
55
|
+
// `identityUrl` is how importers reference this module (its request URL — the key for blobByUrl, the cycle graph, and
|
|
56
|
+
// eviction). `rebaseUrl` is the post-redirect `response.url`, used ONLY to resolve the module's relative imports. They
|
|
57
|
+
// must stay separate: keying edges by `rebaseUrl` would disconnect the graph at every redirect (a redirected node's
|
|
58
|
+
// own edges land under its final URL while every importer refers to it by its request URL → missed cross-branch cycle).
|
|
59
|
+
const collect = async (code: string, identityUrl: string, rebaseUrl: string): Promise<Record<string, string>> => {
|
|
60
|
+
const specifiers = [...extractModuleSpecifiers(code)];
|
|
61
|
+
// Two passes on purpose. Pass 1 records every edge and checks cycles synchronously (no await) so a concurrent sibling
|
|
62
|
+
// branch sees the edges — but it starts NO async work, so a mid-loop cyclic throw can't strand an already-launched
|
|
63
|
+
// resolveModule() as an unhandled rejection. Pass 2 launches the fetches once the whole edge set is known valid.
|
|
64
|
+
let deps = importsOf.get(identityUrl);
|
|
65
|
+
if (!deps) importsOf.set(identityUrl, (deps = new Set()));
|
|
66
|
+
const bareMissing: string[] = [];
|
|
67
|
+
for (const specifier of specifiers) {
|
|
68
|
+
// Rebase only relative/absolute paths against the module URL. A bare specifier whose subpath happens to end in a
|
|
69
|
+
// source extension (`@acme/ui/Button.tsx`, `pkg/foo.ts`) must stay bare for esm.sh — rebasing it first would turn
|
|
70
|
+
// it into a bogus `https://…/pkg/foo.ts` remote fetch that 404s before the fallback can map the package.
|
|
71
|
+
if (isBareModuleSpecifier(specifier)) {
|
|
72
|
+
if (!isRuntimeSingletonSpecifier(specifier)) bareMissing.push(specifier);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const absolute = new URL(specifier, rebaseUrl).href;
|
|
76
|
+
if (!isRemoteSourceModuleSpecifier(absolute)) continue;
|
|
77
|
+
// A cycle exists iff the dependency can already reach this module (reaches() returns true for a direct self-import too).
|
|
78
|
+
if (reaches(absolute, identityUrl)) throw new Error(`Cyclic remote module import: ${absolute}`);
|
|
79
|
+
deps.add(absolute);
|
|
80
|
+
}
|
|
81
|
+
const [remote, bare] = await Promise.all([Promise.all([...deps].map((absolute) => resolveModule(absolute).then((blob) => [absolute, blob] as const))), esmSh.resolve({ code, missing: bareMissing })]);
|
|
82
|
+
return { ...bare.imports, ...Object.fromEntries(remote) };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const resolveModule = (url: string): Promise<string> => {
|
|
86
|
+
let blob = blobByUrl.get(url);
|
|
87
|
+
if (!blob) {
|
|
88
|
+
blob = (async () => {
|
|
89
|
+
const response = await fetcher(url);
|
|
90
|
+
if (!response.ok) throw new Error(`Failed to fetch remote module ${url}: ${response.status} ${response.statusText}`);
|
|
91
|
+
const source = await response.text();
|
|
92
|
+
// A CDN/raw endpoint may redirect to a versioned/storage URL; rebase the module's relative imports against the
|
|
93
|
+
// final `response.url` (falling back to the request URL) so `./Leaf.tsx` resolves from the right directory.
|
|
94
|
+
const finalUrl = response.url || url;
|
|
95
|
+
const imports = await collect(source, url, finalUrl);
|
|
96
|
+
// filename = the module's own URL so the transform rebases its relative imports against it (matching the
|
|
97
|
+
// absolute-keyed entries above, and turning relative browser-native imports into absolute URLs natively).
|
|
98
|
+
const { code } = await compiler.compile(source, { filename: finalUrl, lang: langFromRemoteUrl(url), importMap: { imports } });
|
|
99
|
+
return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
|
|
100
|
+
})();
|
|
101
|
+
// A failed fetch/compile must not pin this URL for the provider's lifetime — evict the rejected promise so a
|
|
102
|
+
// later render retries, mirroring probeEsmShSpecifier's fail-open eviction.
|
|
103
|
+
blob.catch(() => {
|
|
104
|
+
blobByUrl.delete(url);
|
|
105
|
+
importsOf.delete(url);
|
|
106
|
+
});
|
|
107
|
+
blobByUrl.set(url, blob);
|
|
108
|
+
}
|
|
109
|
+
return blob;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// The entry code isn't fetched or compiled here — the outer resolver chain compiles it and maps its bare specifiers,
|
|
113
|
+
// so this provider only surfaces its remote-source imports as blob: entries (keyed by their already-absolute URL).
|
|
114
|
+
return {
|
|
115
|
+
resolve: async ({ code }): Promise<RendererImportMap> => {
|
|
116
|
+
const entries = await Promise.all([...extractModuleSpecifiers(code)].flatMap((specifier) => (isRemoteSourceModuleSpecifier(specifier) ? [resolveModule(specifier).then((blob) => [specifier, blob] as const)] : [])));
|
|
117
|
+
return { imports: Object.fromEntries(entries) };
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
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";
|
|
5
6
|
import { createTsxCompiler, type TsxCompiler, type CompileResult } from "./compiler";
|
|
6
7
|
import { GenUIRenderContext, getGenUIRenderContextValue } from "./renderContext";
|
|
7
8
|
import { getGenUIHookSignature } from "./hookSignature";
|
|
8
9
|
import type { RendererImportMap } from "./importMap";
|
|
9
10
|
|
|
10
11
|
export type GenUIRenderPhase = "transform" | "compile" | "render";
|
|
11
|
-
export type GenUIRendererCallbacks = { onReady?: (component:
|
|
12
|
+
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
13
|
export type GenUIRendererClearOptions = { preserveVisualState?: boolean };
|
|
13
14
|
export type GenUIRendererFlushMode = "microtask" | "immediate";
|
|
14
15
|
export type GenUIRendererOptions = { importmap?: RendererImportMap; callbacks?: GenUIRendererCallbacks; compiler?: TsxCompiler; preserveStateOnUpdate?: boolean; flushMode?: GenUIRendererFlushMode };
|
|
@@ -20,9 +21,9 @@ const normalizeError = (error: unknown) => (error instanceof Error ? error : new
|
|
|
20
21
|
const COMPILED_COMPONENT_CACHE_LIMIT = 256;
|
|
21
22
|
let rendererScopeSerial = 0;
|
|
22
23
|
const createRendererScope = () => `renderer:${(rendererScopeSerial += 1).toString(36)}`;
|
|
23
|
-
const compiledComponentCache = new Map<string,
|
|
24
|
+
const compiledComponentCache = new Map<string, GeneratedComponent>();
|
|
24
25
|
const getPreserveBoundaryKey = (epoch: number) => (epoch === 0 ? "boundary" : `boundary:${epoch}`);
|
|
25
|
-
const rememberCompiledComponent = (code: string, component:
|
|
26
|
+
const rememberCompiledComponent = (code: string, component: GeneratedComponent) => {
|
|
26
27
|
if (compiledComponentCache.has(code)) compiledComponentCache.delete(code);
|
|
27
28
|
compiledComponentCache.set(code, component);
|
|
28
29
|
if (compiledComponentCache.size > COMPILED_COMPONENT_CACHE_LIMIT) {
|
|
@@ -71,6 +72,8 @@ export class GenUIRenderer {
|
|
|
71
72
|
private currentBuffer = "";
|
|
72
73
|
private compileScheduled = false;
|
|
73
74
|
private compileScheduleToken = 0;
|
|
75
|
+
private compileRunning = false;
|
|
76
|
+
private compilePending: { partial: boolean; serial?: number } | null = null;
|
|
74
77
|
private scheduledPartial = false;
|
|
75
78
|
private scheduledSerial?: number;
|
|
76
79
|
private errorEpoch = 0;
|
|
@@ -124,7 +127,9 @@ export class GenUIRenderer {
|
|
|
124
127
|
|
|
125
128
|
detach() {
|
|
126
129
|
// Bump compileSerial so any in-flight compile resolves into a no-op instead of adopting onto a dead renderer.
|
|
130
|
+
// Drop the queued follow-up too: it would start a fresh chain (self-consistent serial) that the bump can't invalidate.
|
|
127
131
|
this.compileSerial += 1;
|
|
132
|
+
this.compilePending = null;
|
|
128
133
|
const previous = this.target as AttachableHost | null;
|
|
129
134
|
if (previous && previous[attachedRenderer] === this) delete previous[attachedRenderer];
|
|
130
135
|
this.root?.unmount();
|
|
@@ -221,6 +226,7 @@ export class GenUIRenderer {
|
|
|
221
226
|
this.scheduledPartial = false;
|
|
222
227
|
this.scheduledSerial = undefined;
|
|
223
228
|
this.compileScheduleToken += 1;
|
|
229
|
+
this.compilePending = null;
|
|
224
230
|
this.lastCompiledCode = "";
|
|
225
231
|
if (options.preserveVisualState) return this;
|
|
226
232
|
this.lastGoodHookSignature = "";
|
|
@@ -259,13 +265,36 @@ export class GenUIRenderer {
|
|
|
259
265
|
}
|
|
260
266
|
|
|
261
267
|
private async compile(partial: boolean, renderSerial?: number) {
|
|
268
|
+
// A compile chain (transform + blob import) spans multiple event-loop turns, so under per-frame pushes starting a
|
|
269
|
+
// new chain per push keeps bumping compileSerial and every in-flight result resolves superseded — nothing commits
|
|
270
|
+
// until the pushes stop (the storyboard's per-rAF typewriter froze previews this way). Run chains single-flight:
|
|
271
|
+
// remember only the latest request and start it once the current chain settles; compileOnce reads currentBuffer
|
|
272
|
+
// fresh at start, so all pushes queued behind a chain coalesce into one follow-up compile.
|
|
273
|
+
if (this.compileRunning) {
|
|
274
|
+
this.compilePending = { partial, serial: renderSerial };
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.compileRunning = true;
|
|
278
|
+
try {
|
|
279
|
+
await this.compileOnce(partial, renderSerial);
|
|
280
|
+
} finally {
|
|
281
|
+
this.compileRunning = false;
|
|
282
|
+
const pending = this.compilePending;
|
|
283
|
+
this.compilePending = null;
|
|
284
|
+
if (pending) void this.compile(pending.partial, pending.serial);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async compileOnce(partial: boolean, renderSerial?: number) {
|
|
262
289
|
const serial = (this.compileSerial += 1);
|
|
263
290
|
const code = this.currentBuffer;
|
|
264
291
|
let result: CompileResult;
|
|
265
292
|
try {
|
|
266
293
|
result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode });
|
|
267
294
|
} catch (error) {
|
|
268
|
-
|
|
295
|
+
// A queued follow-up means this frame is already superseded; its error is stale noise (pre-single-flight, the
|
|
296
|
+
// newer compile's serial bump suppressed it the same way).
|
|
297
|
+
if (serial !== this.compileSerial || this.compilePending) return;
|
|
269
298
|
this.callbacks.onError?.(normalizeError(error), "transform");
|
|
270
299
|
return;
|
|
271
300
|
}
|
|
@@ -294,17 +323,17 @@ export class GenUIRenderer {
|
|
|
294
323
|
this.slot.setCurrent(component);
|
|
295
324
|
this.renderComponent(component, code, renderSerial, result.source);
|
|
296
325
|
} catch (error) {
|
|
297
|
-
if (serial !== this.compileSerial) return;
|
|
326
|
+
if (serial !== this.compileSerial || this.compilePending) return;
|
|
298
327
|
this.callbacks.onError?.(normalizeError(error), "compile");
|
|
299
328
|
}
|
|
300
329
|
}
|
|
301
330
|
|
|
302
|
-
private async importCompiledComponent(code: string) {
|
|
331
|
+
private async importCompiledComponent(code: string): Promise<{ component: GeneratedComponent; moduleUrl?: string }> {
|
|
303
332
|
const cached = compiledComponentCache.get(code);
|
|
304
333
|
if (cached) return { component: cached };
|
|
305
334
|
const moduleUrl = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
|
|
306
335
|
try {
|
|
307
|
-
const module = (await import(/* @vite-ignore */ moduleUrl)) as { default?:
|
|
336
|
+
const module = (await import(/* @vite-ignore */ moduleUrl)) as { default?: GeneratedComponent };
|
|
308
337
|
if (!module.default) throw new Error("No default export found in compiled module.");
|
|
309
338
|
rememberCompiledComponent(code, module.default);
|
|
310
339
|
return { component: module.default, moduleUrl };
|
|
@@ -325,7 +354,7 @@ export class GenUIRenderer {
|
|
|
325
354
|
this.moduleUrl = null;
|
|
326
355
|
}
|
|
327
356
|
|
|
328
|
-
private scheduleRenderedCallback(component:
|
|
357
|
+
private scheduleRenderedCallback(component: GeneratedComponent, code: string, renderSerial: number | undefined, compileSerial: number) {
|
|
329
358
|
const notify = () => {
|
|
330
359
|
if (compileSerial === this.compileSerial) this.callbacks.onRendered?.(component, code, renderSerial);
|
|
331
360
|
};
|
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;
|