partial-react 0.0.0 → 0.0.1
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/package.json +28 -9
- package/src/compiler.ts +39 -0
- package/src/hookSignature.ts +247 -0
- package/src/importMap.ts +275 -0
- package/src/renderContext.ts +25 -0
- package/src/runtime.tsx +389 -0
- package/src/state.ts +38 -0
- package/index.js +0 -1
package/package.json
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partial-react",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
"description": "A state-preserving renderer for prefix-growing streaming React/TSX.",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/MindLab-Research/macaron-genui-demo",
|
|
9
|
+
"directory": "lib/partial-react"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/runtime.tsx",
|
|
13
|
+
"./compiler": "./src/compiler.ts",
|
|
14
|
+
"./import-map": "./src/importMap.ts",
|
|
15
|
+
"./render-context": "./src/renderContext.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src/**/*",
|
|
19
|
+
"!src/**/*.test.*"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@esm.sh/tsx": "^1.5.0",
|
|
23
|
+
"partial-tsx": "0.0.1"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": "^19.0.0",
|
|
27
|
+
"react-dom": "^19.0.0"
|
|
28
|
+
},
|
|
29
|
+
"sideEffects": false
|
|
30
|
+
}
|
package/src/compiler.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import initTsx, { transform as transformTsx } from "@esm.sh/tsx";
|
|
2
|
+
import tsxWasmUrl from "@esm.sh/tsx/pkg/tsx_bg.wasm?url";
|
|
3
|
+
import { normalizeGeneratedTsx } from "partial-tsx";
|
|
4
|
+
import type { RendererImportMap } from "./importMap";
|
|
5
|
+
|
|
6
|
+
export type CompileOptions = { importMap?: RendererImportMap; partial?: boolean; previousCode?: string; filename?: string; jsxImportSource?: string };
|
|
7
|
+
export type CompileResult = { code: string; source: string; changed: boolean };
|
|
8
|
+
export type TsxCompiler = { compile: (code: string, options?: CompileOptions) => Promise<CompileResult> };
|
|
9
|
+
|
|
10
|
+
let initPromise: Promise<unknown> | null = null;
|
|
11
|
+
const loadNodeWasm = async () => {
|
|
12
|
+
if (typeof Bun === "undefined") throw new Error("Bun runtime is required to load local TSX WASM in tests.");
|
|
13
|
+
const wasmPath = new URL(import.meta.resolve("@esm.sh/tsx/pkg/tsx_bg.wasm"));
|
|
14
|
+
return Bun.file(wasmPath).arrayBuffer();
|
|
15
|
+
};
|
|
16
|
+
const initCompiler = () =>
|
|
17
|
+
(initPromise ??= (async () => {
|
|
18
|
+
try {
|
|
19
|
+
if (typeof window === "undefined") return initTsx(await loadNodeWasm());
|
|
20
|
+
} catch {
|
|
21
|
+
// Browser builds should use Vite's asset URL; Node tests use the direct filesystem path above.
|
|
22
|
+
}
|
|
23
|
+
return initTsx(tsxWasmUrl);
|
|
24
|
+
})().catch((error) => {
|
|
25
|
+
initPromise = null;
|
|
26
|
+
throw error;
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
export function createTsxCompiler(): TsxCompiler {
|
|
30
|
+
return {
|
|
31
|
+
async compile(code, options = {}) {
|
|
32
|
+
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" });
|
|
35
|
+
const compiled = new TextDecoder().decode(result.code);
|
|
36
|
+
return { code: compiled, source, changed: compiled !== options.previousCode };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// A tiny hand-rolled TSX lexer that extracts the ordered list of React hook calls from generated source. The renderer compares
|
|
2
|
+
// successive signatures: same layout keeps preserved state, a changed layout remounts the stable boundary so React reassigns hook cells.
|
|
3
|
+
const identifierPartPattern = /[\w$]/;
|
|
4
|
+
const hookCallPattern = /use[A-Z][\w$]*/y;
|
|
5
|
+
const regexPrefixKeywords = new Set(["return", "throw", "case", "delete", "void", "typeof", "yield", "await", "in", "of"]);
|
|
6
|
+
const jsxPrefixKeywords = new Set(["return", "throw", "case", "yield", "await"]);
|
|
7
|
+
const skipQuotedString = (code: string, index: number) => {
|
|
8
|
+
const quote = code[index];
|
|
9
|
+
index += 1;
|
|
10
|
+
while (index < code.length) {
|
|
11
|
+
if (code[index] === "\\") index += 2;
|
|
12
|
+
else if (code[index] === quote) return index + 1;
|
|
13
|
+
else index += 1;
|
|
14
|
+
}
|
|
15
|
+
return code.length;
|
|
16
|
+
};
|
|
17
|
+
const skipLineComment = (code: string, index: number) => {
|
|
18
|
+
const end = code.indexOf("\n", index + 2);
|
|
19
|
+
return end === -1 ? code.length : end;
|
|
20
|
+
};
|
|
21
|
+
const skipBlockComment = (code: string, index: number) => {
|
|
22
|
+
const end = code.indexOf("*/", index + 2);
|
|
23
|
+
return end === -1 ? code.length : end + 2;
|
|
24
|
+
};
|
|
25
|
+
const isRegexLiteralStart = (code: string, index: number) => {
|
|
26
|
+
const previous = previousNonSpaceIndex(code, index);
|
|
27
|
+
if (previous < 0) return true;
|
|
28
|
+
if ("([{=,:;!&|?+-*~^<>".includes(code[previous])) return true;
|
|
29
|
+
if (!identifierPartPattern.test(code[previous])) return false;
|
|
30
|
+
let word = "";
|
|
31
|
+
for (let cursor = previous; cursor >= 0 && identifierPartPattern.test(code[cursor]); cursor -= 1) word = code[cursor] + word;
|
|
32
|
+
return regexPrefixKeywords.has(word);
|
|
33
|
+
};
|
|
34
|
+
const skipRegexLiteral = (code: string, index: number) => {
|
|
35
|
+
index += 1;
|
|
36
|
+
let inClass = false;
|
|
37
|
+
while (index < code.length) {
|
|
38
|
+
const char = code[index];
|
|
39
|
+
if (char === "\\") index += 2;
|
|
40
|
+
else if (char === "[") {
|
|
41
|
+
inClass = true;
|
|
42
|
+
index += 1;
|
|
43
|
+
} else if (char === "]") {
|
|
44
|
+
inClass = false;
|
|
45
|
+
index += 1;
|
|
46
|
+
} else if (char === "/" && !inClass) {
|
|
47
|
+
index += 1;
|
|
48
|
+
while (/[A-Za-z]/.test(code[index] ?? "")) index += 1;
|
|
49
|
+
return index;
|
|
50
|
+
} else index += 1;
|
|
51
|
+
}
|
|
52
|
+
return code.length;
|
|
53
|
+
};
|
|
54
|
+
type HookSignatureContext = { localHookNames: Set<string>; reactNamespaces: Set<string> };
|
|
55
|
+
const previousNonSpaceIndex = (code: string, index: number) => {
|
|
56
|
+
let previous = index - 1;
|
|
57
|
+
while (previous >= 0 && /\s/.test(code[previous])) previous -= 1;
|
|
58
|
+
return previous;
|
|
59
|
+
};
|
|
60
|
+
const readIdentifierEndingBefore = (code: string, index: number) => {
|
|
61
|
+
let end = index;
|
|
62
|
+
while (end > 0 && /\s/.test(code[end - 1] ?? "")) end -= 1;
|
|
63
|
+
let start = end;
|
|
64
|
+
while (start > 0 && identifierPartPattern.test(code[start - 1] ?? "")) start -= 1;
|
|
65
|
+
return start === end ? null : { name: code.slice(start, end), start };
|
|
66
|
+
};
|
|
67
|
+
const readMemberNamespaceBefore = (code: string, index: number) => {
|
|
68
|
+
const dot = previousNonSpaceIndex(code, index);
|
|
69
|
+
if (dot < 0 || code[dot] !== ".") return null;
|
|
70
|
+
const namespace = readIdentifierEndingBefore(code, code[dot - 1] === "?" ? dot - 1 : dot);
|
|
71
|
+
if (!namespace) return "";
|
|
72
|
+
const previous = previousNonSpaceIndex(code, namespace.start);
|
|
73
|
+
if (previous >= 0 && (identifierPartPattern.test(code[previous]) || ".)]}".includes(code[previous]))) return "";
|
|
74
|
+
return namespace.name;
|
|
75
|
+
};
|
|
76
|
+
const skipHookTypeArguments = (code: string, index: number) => {
|
|
77
|
+
if (code[index] !== "<") return index;
|
|
78
|
+
let depth = 0;
|
|
79
|
+
while (index < code.length) {
|
|
80
|
+
const char = code[index];
|
|
81
|
+
if (char === '"' || char === "'") index = skipQuotedString(code, index);
|
|
82
|
+
else if (char === "/" && code[index + 1] === "/") index = skipLineComment(code, index);
|
|
83
|
+
else if (char === "/" && code[index + 1] === "*") index = skipBlockComment(code, index);
|
|
84
|
+
else if (char === "<") {
|
|
85
|
+
depth += 1;
|
|
86
|
+
index += 1;
|
|
87
|
+
} else if (char === ">" && code[index - 1] === "=") index += 1;
|
|
88
|
+
else if (char === ">") {
|
|
89
|
+
depth -= 1;
|
|
90
|
+
index += 1;
|
|
91
|
+
if (depth === 0) return index;
|
|
92
|
+
} else index += 1;
|
|
93
|
+
}
|
|
94
|
+
return -1;
|
|
95
|
+
};
|
|
96
|
+
const isFunctionDeclarationName = (code: string, index: number) => /\bfunction\s*\*?\s*$/.test(code.slice(0, index).trimEnd());
|
|
97
|
+
const collectReactHookNamespaces = (code: string) => {
|
|
98
|
+
const namespaces = new Set(["React", "react"]);
|
|
99
|
+
for (const match of code.matchAll(/(?:^|[;\n])\s*import\s+((?:(?![;\n]\s*import\b)[\s\S])*?)\s+from\s+["']react["']/g)) {
|
|
100
|
+
const clause = match[1].trim();
|
|
101
|
+
if (/^type\b/.test(clause)) continue;
|
|
102
|
+
if (!clause.startsWith("{") && !clause.startsWith("*")) {
|
|
103
|
+
const defaultMatch = /^([A-Za-z_$][\w$]*)/.exec(clause);
|
|
104
|
+
if (defaultMatch) namespaces.add(defaultMatch[1]);
|
|
105
|
+
}
|
|
106
|
+
const namespaceMatch = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause);
|
|
107
|
+
if (namespaceMatch) namespaces.add(namespaceMatch[1]);
|
|
108
|
+
}
|
|
109
|
+
return namespaces;
|
|
110
|
+
};
|
|
111
|
+
const collectLocalHookNames = (code: string) => {
|
|
112
|
+
const names = new Set<string>();
|
|
113
|
+
for (const match of code.matchAll(/\bfunction\s*\*?\s+(use[A-Z][\w$]*)\s*\(/g)) names.add(match[1]);
|
|
114
|
+
for (const match of code.matchAll(/\b(?:const|let|var)\s+(use[A-Z][\w$]*)\s*=/g)) names.add(match[1]);
|
|
115
|
+
return names;
|
|
116
|
+
};
|
|
117
|
+
const readHookCall = (code: string, index: number, context: HookSignatureContext) => {
|
|
118
|
+
hookCallPattern.lastIndex = index;
|
|
119
|
+
const match = hookCallPattern.exec(code);
|
|
120
|
+
if (!match || match.index !== index) return null;
|
|
121
|
+
const hookName = match[0];
|
|
122
|
+
const namespace = readMemberNamespaceBefore(code, index);
|
|
123
|
+
if (namespace !== null) {
|
|
124
|
+
if (!context.reactNamespaces.has(namespace)) return null;
|
|
125
|
+
} else {
|
|
126
|
+
if (identifierPartPattern.test(code[index - 1] ?? "")) return null;
|
|
127
|
+
if (isFunctionDeclarationName(code, index)) return null;
|
|
128
|
+
}
|
|
129
|
+
let end = index + hookName.length;
|
|
130
|
+
while (/\s/.test(code[end] ?? "")) end += 1;
|
|
131
|
+
if (code[end] === "<") {
|
|
132
|
+
end = skipHookTypeArguments(code, end);
|
|
133
|
+
if (end === -1) return null;
|
|
134
|
+
while (/\s/.test(code[end] ?? "")) end += 1;
|
|
135
|
+
}
|
|
136
|
+
if (code[end] !== "(") return null;
|
|
137
|
+
return { name: context.localHookNames.has(hookName) ? "useCustomHook" : hookName, end: end + 1 };
|
|
138
|
+
};
|
|
139
|
+
const shouldStartJsxTag = (code: string, index: number) => {
|
|
140
|
+
if (!/[A-Za-z>]/.test(code[index + 1] ?? "")) return false;
|
|
141
|
+
const previous = previousNonSpaceIndex(code, index);
|
|
142
|
+
if (previous < 0 || "([{=,:;!?&|+-*~^<>".includes(code[previous])) return true;
|
|
143
|
+
if (!identifierPartPattern.test(code[previous])) return false;
|
|
144
|
+
let word = "";
|
|
145
|
+
for (let cursor = previous; cursor >= 0 && identifierPartPattern.test(code[cursor]); cursor -= 1) word = code[cursor] + word;
|
|
146
|
+
return jsxPrefixKeywords.has(word);
|
|
147
|
+
};
|
|
148
|
+
const scanHookSignatureJs = (code: string, hooks: string[], context: HookSignatureContext, start = 0, end = code.length): number => {
|
|
149
|
+
for (let index = start; index < end; ) {
|
|
150
|
+
const char = code[index];
|
|
151
|
+
if (char === "/" && code[index + 1] === "/") index = skipLineComment(code, index);
|
|
152
|
+
else if (char === "/" && code[index + 1] === "*") index = skipBlockComment(code, index);
|
|
153
|
+
else if (char === '"' || char === "'") index = skipQuotedString(code, index);
|
|
154
|
+
else if (char === "`") index = scanHookSignatureTemplate(code, hooks, context, index);
|
|
155
|
+
else if (char === "/" && isRegexLiteralStart(code, index)) index = skipRegexLiteral(code, index);
|
|
156
|
+
else if (char === "<" && shouldStartJsxTag(code, index)) index = scanHookSignatureJsx(code, hooks, context, index);
|
|
157
|
+
else {
|
|
158
|
+
const hook = readHookCall(code, index, context);
|
|
159
|
+
if (hook) {
|
|
160
|
+
hooks.push(hook.name);
|
|
161
|
+
index = hook.end;
|
|
162
|
+
} else index += 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return end;
|
|
166
|
+
};
|
|
167
|
+
const scanHookSignatureTemplate = (code: string, hooks: string[], context: HookSignatureContext, index: number) => {
|
|
168
|
+
index += 1;
|
|
169
|
+
while (index < code.length) {
|
|
170
|
+
if (code[index] === "\\") index += 2;
|
|
171
|
+
else if (code[index] === "`") return index + 1;
|
|
172
|
+
else if (code[index] === "$" && code[index + 1] === "{") index = scanHookSignatureBraces(code, hooks, context, index + 1);
|
|
173
|
+
else index += 1;
|
|
174
|
+
}
|
|
175
|
+
return code.length;
|
|
176
|
+
};
|
|
177
|
+
const scanHookSignatureBraces = (code: string, hooks: string[], context: HookSignatureContext, index: number) => {
|
|
178
|
+
let depth = 1;
|
|
179
|
+
index += 1;
|
|
180
|
+
while (index < code.length) {
|
|
181
|
+
const char = code[index];
|
|
182
|
+
if (char === "/" && code[index + 1] === "/") index = skipLineComment(code, index);
|
|
183
|
+
else if (char === "/" && code[index + 1] === "*") index = skipBlockComment(code, index);
|
|
184
|
+
else if (char === '"' || char === "'") index = skipQuotedString(code, index);
|
|
185
|
+
else if (char === "`") index = scanHookSignatureTemplate(code, hooks, context, index);
|
|
186
|
+
else if (char === "/" && isRegexLiteralStart(code, index)) index = skipRegexLiteral(code, index);
|
|
187
|
+
else if (char === "<" && shouldStartJsxTag(code, index)) index = scanHookSignatureJsx(code, hooks, context, index);
|
|
188
|
+
else if (char === "{") {
|
|
189
|
+
depth += 1;
|
|
190
|
+
index += 1;
|
|
191
|
+
} else if (char === "}") {
|
|
192
|
+
depth -= 1;
|
|
193
|
+
index += 1;
|
|
194
|
+
if (depth === 0) return index;
|
|
195
|
+
} else {
|
|
196
|
+
const hook = readHookCall(code, index, context);
|
|
197
|
+
if (hook) {
|
|
198
|
+
hooks.push(hook.name);
|
|
199
|
+
index = hook.end;
|
|
200
|
+
} else index += 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return code.length;
|
|
204
|
+
};
|
|
205
|
+
const scanJsxOpeningTag = (code: string, hooks: string[], context: HookSignatureContext, index: number) => {
|
|
206
|
+
while (index < code.length) {
|
|
207
|
+
const char = code[index];
|
|
208
|
+
if (char === "`") index = scanHookSignatureTemplate(code, hooks, context, index);
|
|
209
|
+
else if (char === '"' || char === "'") index = skipQuotedString(code, index);
|
|
210
|
+
else if (char === "{") index = scanHookSignatureBraces(code, hooks, context, index);
|
|
211
|
+
else if (char === ">") {
|
|
212
|
+
const previous = previousNonSpaceIndex(code, index);
|
|
213
|
+
return { index: index + 1, selfClosing: code[previous] === "/" };
|
|
214
|
+
} else index += 1;
|
|
215
|
+
}
|
|
216
|
+
return { index: code.length, selfClosing: true };
|
|
217
|
+
};
|
|
218
|
+
const scanHookSignatureJsx = (code: string, hooks: string[], context: HookSignatureContext, index: number): number => {
|
|
219
|
+
let depth = 0;
|
|
220
|
+
while (index < code.length) {
|
|
221
|
+
if (code.startsWith("</>", index)) {
|
|
222
|
+
index += 3;
|
|
223
|
+
depth -= 1;
|
|
224
|
+
if (depth <= 0) return index;
|
|
225
|
+
} else if (code.startsWith("</", index)) {
|
|
226
|
+
const close = code.indexOf(">", index + 2);
|
|
227
|
+
index = close === -1 ? code.length : close + 1;
|
|
228
|
+
depth -= 1;
|
|
229
|
+
if (depth <= 0) return index;
|
|
230
|
+
} else if (code.startsWith("<>", index)) {
|
|
231
|
+
depth += 1;
|
|
232
|
+
index += 2;
|
|
233
|
+
} else if (code[index] === "<" && /[A-Za-z]/.test(code[index + 1] ?? "")) {
|
|
234
|
+
const opened = scanJsxOpeningTag(code, hooks, context, index + 1);
|
|
235
|
+
index = opened.index;
|
|
236
|
+
if (!opened.selfClosing) depth += 1;
|
|
237
|
+
else if (depth <= 0) return index;
|
|
238
|
+
} else if (code[index] === "{") index = scanHookSignatureBraces(code, hooks, context, index);
|
|
239
|
+
else index += 1;
|
|
240
|
+
}
|
|
241
|
+
return code.length;
|
|
242
|
+
};
|
|
243
|
+
export const getGenUIHookSignature = (code: string) => {
|
|
244
|
+
const hooks: string[] = [];
|
|
245
|
+
scanHookSignatureJs(code, hooks, { localHookNames: collectLocalHookNames(code), reactNamespaces: collectReactHookNamespaces(code) });
|
|
246
|
+
return hooks.join("\n");
|
|
247
|
+
};
|
package/src/importMap.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
export type RendererImportMap = { imports?: Record<string, string> };
|
|
2
|
+
|
|
3
|
+
const ESM_SH_VERSION_OVERRIDES = { "@number-flow/react": "0.6.0" } as const;
|
|
4
|
+
const IDENTIFIER_CHAR_PATTERN = /[\w$]/;
|
|
5
|
+
const REGEX_PREFIX_WORDS = new Set(["return", "throw", "case", "delete", "typeof", "void", "new", "in", "of", "await", "yield"]);
|
|
6
|
+
const LOCAL_EXPORT_KEYWORDS = ["abstract", "async", "class", "const", "declare", "default", "enum", "function", "interface", "let", "namespace", "var"];
|
|
7
|
+
|
|
8
|
+
export const isBareModuleSpecifier = (specifier: string) => !specifier.startsWith(".") && !specifier.startsWith("/") && !/^(?:[a-z][a-z\d+.-]*:|#)/i.test(specifier);
|
|
9
|
+
export const getBarePackageName = (specifier: string) => (specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0] || specifier);
|
|
10
|
+
const canUseEsmShFallback = (specifier: string) => isBareModuleSpecifier(specifier) && !specifier.startsWith("@/");
|
|
11
|
+
|
|
12
|
+
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] ?? "");
|
|
13
|
+
|
|
14
|
+
const skipLineComment = (code: string, index: number) => {
|
|
15
|
+
const newlineIndex = code.indexOf("\n", index + 2);
|
|
16
|
+
return newlineIndex === -1 ? code.length : newlineIndex;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const skipBlockComment = (code: string, index: number) => {
|
|
20
|
+
const closeIndex = code.indexOf("*/", index + 2);
|
|
21
|
+
return closeIndex === -1 ? code.length : closeIndex + 2;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const skipWhitespaceAndComments = (code: string, index: number) => {
|
|
25
|
+
while (index < code.length) {
|
|
26
|
+
if (/\s/.test(code[index])) {
|
|
27
|
+
index += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (code[index] === "/" && code[index + 1] === "/") {
|
|
31
|
+
index = skipLineComment(code, index);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (code[index] === "/" && code[index + 1] === "*") {
|
|
35
|
+
index = skipBlockComment(code, index);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
return index;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const readStringLiteral = (code: string, index: number) => {
|
|
44
|
+
const quote = code[index];
|
|
45
|
+
if (quote !== '"' && quote !== "'") return null;
|
|
46
|
+
let value = "";
|
|
47
|
+
index += 1;
|
|
48
|
+
while (index < code.length) {
|
|
49
|
+
const char = code[index];
|
|
50
|
+
if (char === "\\") {
|
|
51
|
+
value += code.slice(index, index + 2);
|
|
52
|
+
index += 2;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === quote) return { value, end: index + 1 };
|
|
56
|
+
value += char;
|
|
57
|
+
index += 1;
|
|
58
|
+
}
|
|
59
|
+
return { value, end: code.length };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const skipTemplateLiteral = (code: string, index: number, onExpression?: (start: number, end: number) => void) => {
|
|
63
|
+
index += 1;
|
|
64
|
+
while (index < code.length) {
|
|
65
|
+
if (code[index] === "\\") {
|
|
66
|
+
index += 2;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (code[index] === "`") return index + 1;
|
|
70
|
+
if (code[index] === "$" && code[index + 1] === "{") {
|
|
71
|
+
const expressionStart = index + 2;
|
|
72
|
+
const expressionEnd = findBalancedBlockEnd(code, index + 1, "{", "}");
|
|
73
|
+
onExpression?.(expressionStart, Math.max(expressionStart, expressionEnd - 1));
|
|
74
|
+
index = expressionEnd;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
index += 1;
|
|
78
|
+
}
|
|
79
|
+
return code.length;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const findBalancedBlockEnd = (code: string, index: number, opener: string, closer: string) => {
|
|
83
|
+
let depth = 0;
|
|
84
|
+
while (index < code.length) {
|
|
85
|
+
const char = code[index];
|
|
86
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
87
|
+
index = skipLineComment(code, index);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
91
|
+
index = skipBlockComment(code, index);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (isLikelyRegexLiteralStart(code, index)) {
|
|
95
|
+
index = skipRegexLiteral(code, index);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (char === '"' || char === "'") {
|
|
99
|
+
index = readStringLiteral(code, index)?.end ?? index + 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (char === "`") {
|
|
103
|
+
index = skipTemplateLiteral(code, index);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === opener) depth += 1;
|
|
107
|
+
if (char === closer) {
|
|
108
|
+
depth -= 1;
|
|
109
|
+
if (depth === 0) return index + 1;
|
|
110
|
+
}
|
|
111
|
+
index += 1;
|
|
112
|
+
}
|
|
113
|
+
return code.length;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const isLikelyRegexLiteralStart = (code: string, index: number) => {
|
|
117
|
+
if (code[index] !== "/" || code[index + 1] === "/" || code[index + 1] === "*") return false;
|
|
118
|
+
let previous = index - 1;
|
|
119
|
+
while (previous >= 0 && /\s/.test(code[previous])) previous -= 1;
|
|
120
|
+
if (previous < 0) return true;
|
|
121
|
+
const previousChar = code[previous];
|
|
122
|
+
if ("([{=,:;!&|?+-*~^<>".includes(previousChar)) return true;
|
|
123
|
+
if (!/[A-Za-z_$]/.test(previousChar)) return false;
|
|
124
|
+
let word = "";
|
|
125
|
+
for (let cursor = previous; cursor >= 0 && /[A-Za-z0-9_$]/.test(code[cursor]); cursor -= 1) word = code[cursor] + word;
|
|
126
|
+
return REGEX_PREFIX_WORDS.has(word);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const skipRegexLiteral = (code: string, index: number) => {
|
|
130
|
+
index += 1;
|
|
131
|
+
let inClass = false;
|
|
132
|
+
while (index < code.length) {
|
|
133
|
+
const char = code[index];
|
|
134
|
+
if (char === "\\") {
|
|
135
|
+
index += 2;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
index += 1;
|
|
139
|
+
if (char === "[" && !inClass) inClass = true;
|
|
140
|
+
else if (char === "]" && inClass) inClass = false;
|
|
141
|
+
else if (char === "/" && !inClass) {
|
|
142
|
+
while (index < code.length && /[A-Za-z]/.test(code[index])) index += 1;
|
|
143
|
+
return index;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return code.length;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const readFromSpecifier = (code: string, index: number) => {
|
|
150
|
+
while (index < code.length) {
|
|
151
|
+
const char = code[index];
|
|
152
|
+
if (char === ";") return null;
|
|
153
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
154
|
+
index = skipLineComment(code, index);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
158
|
+
index = skipBlockComment(code, index);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (isLikelyRegexLiteralStart(code, index)) {
|
|
162
|
+
index = skipRegexLiteral(code, index);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (char === '"' || char === "'") {
|
|
166
|
+
index = readStringLiteral(code, index)?.end ?? index + 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (char === "`") {
|
|
170
|
+
index = skipTemplateLiteral(code, index);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (isKeywordAt(code, index, "from")) {
|
|
174
|
+
const literal = readStringLiteral(code, skipWhitespaceAndComments(code, index + "from".length));
|
|
175
|
+
if (literal) return { specifier: literal.value, end: literal.end };
|
|
176
|
+
index += "from".length;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
index += 1;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const readExportFromSpecifier = (code: string, index: number) => {
|
|
185
|
+
let next = skipWhitespaceAndComments(code, index);
|
|
186
|
+
if (isKeywordAt(code, next, "type")) next = skipWhitespaceAndComments(code, next + "type".length);
|
|
187
|
+
if (code[next] === "*" || code[next] === "{") return readFromSpecifier(code, next);
|
|
188
|
+
for (const keyword of LOCAL_EXPORT_KEYWORDS) if (isKeywordAt(code, next, keyword)) return null;
|
|
189
|
+
return readFromSpecifier(code, next);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const collectBareModuleSpecifiers = (code: string, specifiers: Set<string>, start = 0, end = code.length) => {
|
|
193
|
+
for (let index = start; index < end; index += 1) {
|
|
194
|
+
const char = code[index];
|
|
195
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
196
|
+
index = skipLineComment(code, index);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
200
|
+
index = skipBlockComment(code, index);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (isLikelyRegexLiteralStart(code, index)) {
|
|
204
|
+
index = skipRegexLiteral(code, index);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (char === '"' || char === "'") {
|
|
208
|
+
index = readStringLiteral(code, index)?.end ?? index;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (char === "`") {
|
|
212
|
+
index = skipTemplateLiteral(code, index, (expressionStart, expressionEnd) => collectBareModuleSpecifiers(code, specifiers, expressionStart, expressionEnd));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (isKeywordAt(code, index, "import")) {
|
|
216
|
+
const afterImport = skipWhitespaceAndComments(code, index + "import".length);
|
|
217
|
+
if (code[afterImport] === ".") {
|
|
218
|
+
index = afterImport;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (code[afterImport] === "(") {
|
|
222
|
+
const literal = readStringLiteral(code, skipWhitespaceAndComments(code, afterImport + 1));
|
|
223
|
+
if (literal?.value && isBareModuleSpecifier(literal.value)) specifiers.add(literal.value);
|
|
224
|
+
index = literal?.end ?? afterImport;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const sideEffect = readStringLiteral(code, afterImport);
|
|
228
|
+
const from = sideEffect ? { specifier: sideEffect.value, end: sideEffect.end } : readFromSpecifier(code, afterImport);
|
|
229
|
+
if (from?.specifier && isBareModuleSpecifier(from.specifier)) specifiers.add(from.specifier);
|
|
230
|
+
index = from?.end ?? afterImport;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (isKeywordAt(code, index, "export")) {
|
|
234
|
+
const from = readExportFromSpecifier(code, index + "export".length);
|
|
235
|
+
if (from?.specifier && isBareModuleSpecifier(from.specifier)) specifiers.add(from.specifier);
|
|
236
|
+
index = from?.end ?? index;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export function extractBareModuleSpecifiers(code: string) {
|
|
242
|
+
const specifiers = new Set<string>();
|
|
243
|
+
collectBareModuleSpecifiers(code, specifiers);
|
|
244
|
+
return specifiers;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function hasImportMapEntry(imports: Record<string, string> | Set<string> | Map<string, string>, specifier: string) {
|
|
248
|
+
const keys = imports instanceof Set ? imports : imports instanceof Map ? new Set(imports.keys()) : new Set(Object.keys(imports));
|
|
249
|
+
if (keys.has(specifier)) return true;
|
|
250
|
+
for (const key of keys) if (key.endsWith("/") && specifier.startsWith(key)) return true;
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function toEsmShImportUrl(specifier: string) {
|
|
255
|
+
if (!canUseEsmShFallback(specifier)) throw new Error(`Cannot create an esm.sh fallback for ${specifier}`);
|
|
256
|
+
const packageName = getBarePackageName(specifier);
|
|
257
|
+
const version = ESM_SH_VERSION_OVERRIDES[packageName as keyof typeof ESM_SH_VERSION_OVERRIDES];
|
|
258
|
+
const versionedSpecifier = version ? `${packageName}@${version}${specifier.slice(packageName.length)}` : specifier;
|
|
259
|
+
const separator = versionedSpecifier.includes("?") ? "&" : "?";
|
|
260
|
+
return `https://esm.sh/${versionedSpecifier}${separator}bundle&target=es2022&external=react,react-dom,scheduler`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function prepareRendererImportMap(map: RendererImportMap | null | undefined, baseUrl = typeof location === "undefined" ? undefined : location.href) {
|
|
264
|
+
const imports = Object.fromEntries(Object.entries(map?.imports ?? {}).map(([specifier, target]) => [specifier, baseUrl ? new URL(target, baseUrl).href : target]));
|
|
265
|
+
return { imports };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function mergeFallbackImports(imports: Record<string, string>, code: string, options?: { hasLocalPackage?: (packageName: string) => Promise<boolean> }) {
|
|
269
|
+
const next = { ...imports };
|
|
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;
|
|
275
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext, useContext, type Context } from "react";
|
|
2
|
+
|
|
3
|
+
export type GenUIRenderContextValue = { rendererScope: string; streamingPartialFrame: boolean; nextStreamingRenderKey?: () => string };
|
|
4
|
+
const defaultRendererScope = "default";
|
|
5
|
+
const renderContextValues = new Map<string, { complete: GenUIRenderContextValue; streaming: GenUIRenderContextValue }>();
|
|
6
|
+
const getRenderContextValues = (rendererScope: string) => {
|
|
7
|
+
const cached = renderContextValues.get(rendererScope);
|
|
8
|
+
if (cached) return cached;
|
|
9
|
+
const values = { complete: Object.freeze({ rendererScope, streamingPartialFrame: false }), streaming: Object.freeze({ rendererScope, streamingPartialFrame: true }) };
|
|
10
|
+
renderContextValues.set(rendererScope, values);
|
|
11
|
+
return values;
|
|
12
|
+
};
|
|
13
|
+
const completeRenderContextValue = getRenderContextValues(defaultRendererScope).complete;
|
|
14
|
+
|
|
15
|
+
const renderContextKey = Symbol.for("@macaron/genui-runtime:render-context");
|
|
16
|
+
const globalRenderContext = globalThis as typeof globalThis & { [renderContextKey]?: Context<GenUIRenderContextValue> };
|
|
17
|
+
|
|
18
|
+
export const getGenUIRenderContextValue = (streamingPartialFrame: boolean, rendererScope = defaultRendererScope, nextStreamingRenderKey?: () => string): GenUIRenderContextValue => {
|
|
19
|
+
if (nextStreamingRenderKey) return { rendererScope, streamingPartialFrame, nextStreamingRenderKey };
|
|
20
|
+
const values = getRenderContextValues(rendererScope);
|
|
21
|
+
return streamingPartialFrame ? values.streaming : values.complete;
|
|
22
|
+
};
|
|
23
|
+
// Production chunks can load separate copies of this module; the global symbol keeps charts and the renderer on one Context.
|
|
24
|
+
export const GenUIRenderContext = (globalRenderContext[renderContextKey] ??= createContext<GenUIRenderContextValue>(completeRenderContextValue));
|
|
25
|
+
export const useGenUIRenderContext = () => useContext(GenUIRenderContext);
|
package/src/runtime.tsx
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { Component, createElement, useEffect, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
import { renderToString } from "react-dom/server";
|
|
4
|
+
import { createGeneratedComponentSlot, type GeneratedComponentSlot } from "./state";
|
|
5
|
+
import { createTsxCompiler, type TsxCompiler, type CompileResult } from "./compiler";
|
|
6
|
+
import { GenUIRenderContext, getGenUIRenderContextValue } from "./renderContext";
|
|
7
|
+
import { getGenUIHookSignature } from "./hookSignature";
|
|
8
|
+
import type { RendererImportMap } from "./importMap";
|
|
9
|
+
|
|
10
|
+
export type GenUIRenderPhase = "transform" | "compile" | "render";
|
|
11
|
+
export type GenUIRendererCallbacks = { onReady?: (component: unknown, url?: string, code?: string) => void; onRendered?: (component: unknown, code: string, serial?: number) => void; onError?: (error: Error, phase: GenUIRenderPhase) => void };
|
|
12
|
+
export type GenUIRendererClearOptions = { preserveVisualState?: boolean };
|
|
13
|
+
export type GenUIRendererFlushMode = "microtask" | "immediate";
|
|
14
|
+
export type GenUIRendererOptions = { importmap?: RendererImportMap; callbacks?: GenUIRendererCallbacks; compiler?: TsxCompiler; preserveStateOnUpdate?: boolean; flushMode?: GenUIRendererFlushMode };
|
|
15
|
+
export type GenUIRendererUpdateMode = "push" | "render";
|
|
16
|
+
|
|
17
|
+
const attachedRenderer = Symbol.for("@macaron/genui-runtime:attached-renderer");
|
|
18
|
+
type AttachableHost = HTMLElement & { [attachedRenderer]?: GenUIRenderer };
|
|
19
|
+
const normalizeError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
|
|
20
|
+
const COMPILED_COMPONENT_CACHE_LIMIT = 256;
|
|
21
|
+
let rendererScopeSerial = 0;
|
|
22
|
+
const createRendererScope = () => `renderer:${(rendererScopeSerial += 1).toString(36)}`;
|
|
23
|
+
const compiledComponentCache = new Map<string, unknown>();
|
|
24
|
+
const getPreserveBoundaryKey = (epoch: number) => (epoch === 0 ? "boundary" : `boundary:${epoch}`);
|
|
25
|
+
const rememberCompiledComponent = (code: string, component: unknown) => {
|
|
26
|
+
if (compiledComponentCache.has(code)) compiledComponentCache.delete(code);
|
|
27
|
+
compiledComponentCache.set(code, component);
|
|
28
|
+
if (compiledComponentCache.size > COMPILED_COMPONENT_CACHE_LIMIT) {
|
|
29
|
+
const oldestKey = compiledComponentCache.keys().next().value;
|
|
30
|
+
if (oldestKey !== undefined) compiledComponentCache.delete(oldestKey);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
class RuntimeErrorBoundary extends Component<{ resetKey?: number; onError?: (error: Error) => void; children?: ReactNode }, { hasError: boolean; lastResetKey?: number }> {
|
|
35
|
+
state = { hasError: false, lastResetKey: undefined as number | undefined };
|
|
36
|
+
static getDerivedStateFromError() {
|
|
37
|
+
return { hasError: true };
|
|
38
|
+
}
|
|
39
|
+
static getDerivedStateFromProps(props: { resetKey?: number }, state: { hasError: boolean; lastResetKey?: number }) {
|
|
40
|
+
// Streaming errors bump resetKey to clear the caught state in place; recreating the boundary via `key`
|
|
41
|
+
// 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 };
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
componentDidCatch(error: Error) {
|
|
46
|
+
this.props.onError?.(error);
|
|
47
|
+
}
|
|
48
|
+
render() {
|
|
49
|
+
if (this.state.hasError) return "ERROR";
|
|
50
|
+
return this.props.children;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const RenderNotify = ({ onCommit, onReady }: { onCommit?: () => void; onReady?: () => void }) => {
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
onCommit?.();
|
|
57
|
+
if (typeof document !== "undefined" && document.hidden) {
|
|
58
|
+
onReady?.();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const frameId = requestAnimationFrame(() => onReady?.());
|
|
62
|
+
return () => cancelAnimationFrame(frameId);
|
|
63
|
+
}, [onCommit, onReady]);
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export class GenUIRenderer {
|
|
68
|
+
private callbacks: GenUIRendererCallbacks;
|
|
69
|
+
private compiler: TsxCompiler;
|
|
70
|
+
private compileSerial = 0;
|
|
71
|
+
private currentBuffer = "";
|
|
72
|
+
private compileScheduled = false;
|
|
73
|
+
private compileScheduleToken = 0;
|
|
74
|
+
private scheduledPartial = false;
|
|
75
|
+
private scheduledSerial?: number;
|
|
76
|
+
private errorEpoch = 0;
|
|
77
|
+
private finished = false;
|
|
78
|
+
private flushMode: GenUIRendererFlushMode;
|
|
79
|
+
private importmap?: RendererImportMap;
|
|
80
|
+
private lastCompiledCode = "";
|
|
81
|
+
private lastGoodHookSignature = "";
|
|
82
|
+
private lastHookSignature: string | null = null;
|
|
83
|
+
private moduleUrl: string | null = null;
|
|
84
|
+
private preserveBoundaryEpoch = 0;
|
|
85
|
+
private preserveStateOnUpdate: boolean;
|
|
86
|
+
private renderRound = 0;
|
|
87
|
+
private rendererScope: string;
|
|
88
|
+
private root: Root | null = null;
|
|
89
|
+
private slot: GeneratedComponentSlot;
|
|
90
|
+
private target: HTMLElement | null = null;
|
|
91
|
+
private updateMode: GenUIRendererUpdateMode = "render";
|
|
92
|
+
|
|
93
|
+
private constructor(target: HTMLElement | null | undefined, options: GenUIRendererOptions) {
|
|
94
|
+
this.callbacks = options.callbacks ?? {};
|
|
95
|
+
this.compiler = options.compiler ?? createTsxCompiler();
|
|
96
|
+
this.flushMode = options.flushMode ?? "microtask";
|
|
97
|
+
this.importmap = options.importmap;
|
|
98
|
+
this.preserveStateOnUpdate = options.preserveStateOnUpdate ?? true;
|
|
99
|
+
this.rendererScope = createRendererScope();
|
|
100
|
+
this.slot = createGeneratedComponentSlot();
|
|
101
|
+
if (target) this.attach(target);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static async create(target?: HTMLElement | null, options: GenUIRendererOptions = {}) {
|
|
105
|
+
return new GenUIRenderer(target, options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
attach(target: HTMLElement) {
|
|
109
|
+
const element = target as AttachableHost;
|
|
110
|
+
if (element[attachedRenderer] && element[attachedRenderer] !== this) element[attachedRenderer].detach();
|
|
111
|
+
if (this.target === target && this.root) {
|
|
112
|
+
element[attachedRenderer] = this;
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
const previous = this.target as AttachableHost | null;
|
|
116
|
+
if (previous && previous[attachedRenderer] === this) delete previous[attachedRenderer];
|
|
117
|
+
this.root?.unmount();
|
|
118
|
+
this.target = target;
|
|
119
|
+
this.root = createRoot(target);
|
|
120
|
+
element[attachedRenderer] = this;
|
|
121
|
+
this.renderComponent();
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
detach() {
|
|
126
|
+
// Bump compileSerial so any in-flight compile resolves into a no-op instead of adopting onto a dead renderer.
|
|
127
|
+
this.compileSerial += 1;
|
|
128
|
+
const previous = this.target as AttachableHost | null;
|
|
129
|
+
if (previous && previous[attachedRenderer] === this) delete previous[attachedRenderer];
|
|
130
|
+
this.root?.unmount();
|
|
131
|
+
this.root = null;
|
|
132
|
+
this.target = null;
|
|
133
|
+
this.rendererScope = createRendererScope();
|
|
134
|
+
this.revokeModuleUrl();
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setPreserveStateOnUpdate(value: boolean) {
|
|
139
|
+
this.preserveStateOnUpdate = value;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setFlushMode(value: GenUIRendererFlushMode) {
|
|
144
|
+
this.flushMode = value;
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setImportMap(importmap: RendererImportMap) {
|
|
149
|
+
this.importmap = importmap;
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
render(code: string, serial?: number) {
|
|
154
|
+
this.finished = true;
|
|
155
|
+
this.currentBuffer = code;
|
|
156
|
+
this.updateMode = "render";
|
|
157
|
+
this.scheduleCompile(false, serial);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
pushCode(code: string, serial?: number) {
|
|
161
|
+
this.finished = false;
|
|
162
|
+
this.currentBuffer += code;
|
|
163
|
+
this.updateMode = "push";
|
|
164
|
+
this.scheduleCompile(true, serial);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
finish(code?: string, serial?: number) {
|
|
168
|
+
this.finished = true;
|
|
169
|
+
if (code !== undefined) this.currentBuffer = code;
|
|
170
|
+
this.scheduleCompile(false, serial);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getCurrentBuffer() {
|
|
174
|
+
return this.currentBuffer;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
getUpdateMode() {
|
|
178
|
+
return this.updateMode;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
restoreLastGood() {
|
|
182
|
+
if (!this.slot.restoreLastGood()) return false;
|
|
183
|
+
this.errorEpoch += 1;
|
|
184
|
+
this.renderComponent(this.slot.current(), this.currentBuffer, undefined, this.currentBuffer, this.lastGoodHookSignature);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private buildStreamingRenderContext(streamingPartialFrame: boolean) {
|
|
189
|
+
let streamingRenderKeyIndex = 0;
|
|
190
|
+
return getGenUIRenderContextValue(streamingPartialFrame, this.rendererScope, () => `chart-${streamingRenderKeyIndex++}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private canRenderComponent(component: unknown, streamingPartialFrame: boolean) {
|
|
194
|
+
const renderContext = this.buildStreamingRenderContext(streamingPartialFrame);
|
|
195
|
+
const previousConsoleError = console.error;
|
|
196
|
+
let firstConsoleError: string | undefined;
|
|
197
|
+
let caughtError: unknown;
|
|
198
|
+
try {
|
|
199
|
+
// Preflight intentionally renders bad partial frames; keep React's development console noise off the debug page.
|
|
200
|
+
console.error = (...args) => {
|
|
201
|
+
firstConsoleError ??= args.map(String).join(" ");
|
|
202
|
+
};
|
|
203
|
+
renderToString(createElement(GenUIRenderContext.Provider, { value: renderContext }, createElement(component as ComponentType<Record<string, unknown>>)));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
caughtError = error;
|
|
206
|
+
} finally {
|
|
207
|
+
console.error = previousConsoleError;
|
|
208
|
+
}
|
|
209
|
+
if (caughtError === undefined && firstConsoleError !== undefined) caughtError = new Error(firstConsoleError);
|
|
210
|
+
if (caughtError === undefined) return true;
|
|
211
|
+
this.callbacks.onError?.(normalizeError(caughtError), "render");
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
clear(options: GenUIRendererClearOptions = {}) {
|
|
216
|
+
this.compileSerial += 1;
|
|
217
|
+
this.currentBuffer = "";
|
|
218
|
+
// `clear` terminates the current stream; the next `pushCode`/`render` selects partial vs full mode again.
|
|
219
|
+
this.finished = true;
|
|
220
|
+
this.compileScheduled = false;
|
|
221
|
+
this.scheduledPartial = false;
|
|
222
|
+
this.scheduledSerial = undefined;
|
|
223
|
+
this.compileScheduleToken += 1;
|
|
224
|
+
this.lastCompiledCode = "";
|
|
225
|
+
if (options.preserveVisualState) return this;
|
|
226
|
+
this.lastGoodHookSignature = "";
|
|
227
|
+
this.lastHookSignature = null;
|
|
228
|
+
this.preserveBoundaryEpoch = 0;
|
|
229
|
+
this.rendererScope = createRendererScope();
|
|
230
|
+
this.slot.clear();
|
|
231
|
+
this.revokeModuleUrl();
|
|
232
|
+
this.root?.render(null);
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private scheduleCompile(partial: boolean, serial?: number) {
|
|
237
|
+
if (this.flushMode === "immediate") {
|
|
238
|
+
this.compileScheduled = false;
|
|
239
|
+
this.scheduledPartial = false;
|
|
240
|
+
this.scheduledSerial = undefined;
|
|
241
|
+
this.compileScheduleToken += 1;
|
|
242
|
+
void this.compile(partial, serial);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.scheduledPartial = partial;
|
|
246
|
+
this.scheduledSerial = serial;
|
|
247
|
+
if (this.compileScheduled) return;
|
|
248
|
+
this.compileScheduled = true;
|
|
249
|
+
const scheduleToken = (this.compileScheduleToken += 1);
|
|
250
|
+
queueMicrotask(() => {
|
|
251
|
+
if (scheduleToken !== this.compileScheduleToken) return;
|
|
252
|
+
const nextPartial = this.scheduledPartial;
|
|
253
|
+
const nextSerial = this.scheduledSerial;
|
|
254
|
+
this.compileScheduled = false;
|
|
255
|
+
this.scheduledPartial = false;
|
|
256
|
+
this.scheduledSerial = undefined;
|
|
257
|
+
void this.compile(nextPartial, nextSerial);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async compile(partial: boolean, renderSerial?: number) {
|
|
262
|
+
const serial = (this.compileSerial += 1);
|
|
263
|
+
const code = this.currentBuffer;
|
|
264
|
+
let result: CompileResult;
|
|
265
|
+
try {
|
|
266
|
+
result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (serial !== this.compileSerial) return;
|
|
269
|
+
this.callbacks.onError?.(normalizeError(error), "transform");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (serial !== this.compileSerial) return;
|
|
273
|
+
if (!result.changed) {
|
|
274
|
+
const component = this.slot.current();
|
|
275
|
+
// The final full compile can equal the last partial frame; re-render once with finished=true so the chart render
|
|
276
|
+
// context settles from streaming to complete (charts key off streamingPartialFrame and would otherwise stay streaming).
|
|
277
|
+
if (component && this.preserveStateOnUpdate && this.updateMode === "push" && this.finished) {
|
|
278
|
+
this.renderComponent(component, code, renderSerial, result.source);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (component) this.scheduleRenderedCallback(component, code, renderSerial, serial);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const { component, moduleUrl } = await this.importCompiledComponent(result.code);
|
|
286
|
+
if (serial !== this.compileSerial) {
|
|
287
|
+
if (moduleUrl) URL.revokeObjectURL(moduleUrl);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this.lastCompiledCode = result.code;
|
|
291
|
+
if (moduleUrl) this.adoptModuleUrl(moduleUrl);
|
|
292
|
+
else this.revokeModuleUrl();
|
|
293
|
+
this.callbacks.onReady?.(component, moduleUrl, code);
|
|
294
|
+
this.slot.setCurrent(component);
|
|
295
|
+
this.renderComponent(component, code, renderSerial, result.source);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (serial !== this.compileSerial) return;
|
|
298
|
+
this.callbacks.onError?.(normalizeError(error), "compile");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private async importCompiledComponent(code: string) {
|
|
303
|
+
const cached = compiledComponentCache.get(code);
|
|
304
|
+
if (cached) return { component: cached };
|
|
305
|
+
const moduleUrl = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
|
|
306
|
+
try {
|
|
307
|
+
const module = (await import(/* @vite-ignore */ moduleUrl)) as { default?: unknown };
|
|
308
|
+
if (!module.default) throw new Error("No default export found in compiled module.");
|
|
309
|
+
rememberCompiledComponent(code, module.default);
|
|
310
|
+
return { component: module.default, moduleUrl };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
URL.revokeObjectURL(moduleUrl);
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private adoptModuleUrl(moduleUrl: string) {
|
|
318
|
+
if (this.moduleUrl && this.moduleUrl !== moduleUrl) URL.revokeObjectURL(this.moduleUrl);
|
|
319
|
+
this.moduleUrl = moduleUrl;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private revokeModuleUrl() {
|
|
323
|
+
if (!this.moduleUrl) return;
|
|
324
|
+
URL.revokeObjectURL(this.moduleUrl);
|
|
325
|
+
this.moduleUrl = null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private scheduleRenderedCallback(component: unknown, code: string, renderSerial: number | undefined, compileSerial: number) {
|
|
329
|
+
const notify = () => {
|
|
330
|
+
if (compileSerial === this.compileSerial) this.callbacks.onRendered?.(component, code, renderSerial);
|
|
331
|
+
};
|
|
332
|
+
if (typeof document !== "undefined" && document.hidden) {
|
|
333
|
+
notify();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(notify);
|
|
337
|
+
else queueMicrotask(notify);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private renderComponent(component = this.slot.current(), renderedCode = this.currentBuffer, renderSerial?: number, sourceCode = renderedCode, hookSignature = getGenUIHookSignature(sourceCode)) {
|
|
341
|
+
if (!this.root || !component) return;
|
|
342
|
+
this.renderRound += 1;
|
|
343
|
+
const renderedComponent = component;
|
|
344
|
+
// streamingPartialFrame only drives the chart render context below (chart keys remount per partial frame). Preserve
|
|
345
|
+
// stays on throughout streaming: each partial frame can have a different hook count, so the hook-signature diff in
|
|
346
|
+
// the preserve branch remounts the stable wrapper whenever the layout changes, which is what would otherwise reuse
|
|
347
|
+
// the wrong hook cells and violate the Rules of Hooks (React #310).
|
|
348
|
+
const streamingPartialFrame = this.updateMode === "push" && !this.finished;
|
|
349
|
+
const preserve = this.preserveStateOnUpdate;
|
|
350
|
+
const renderedHookSignature = hookSignature;
|
|
351
|
+
const lastGood = this.slot.lastGood();
|
|
352
|
+
if (preserve && streamingPartialFrame && lastGood && lastGood !== renderedComponent && !this.canRenderComponent(renderedComponent, streamingPartialFrame)) {
|
|
353
|
+
this.slot.restoreLastGood();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (preserve) {
|
|
357
|
+
// Preserving state across hook layout changes reuses the wrong hook cells; remount only that stable wrapper epoch.
|
|
358
|
+
// lastHookSignature is null only before the first committed frame; "" is a real hookless signature, so a hookless→hooked
|
|
359
|
+
// transition (the common JSX-first-then-useState stream shape) must still bump the epoch and remount (React #310).
|
|
360
|
+
if (this.lastHookSignature !== null && this.lastHookSignature !== renderedHookSignature) this.preserveBoundaryEpoch += 1;
|
|
361
|
+
this.lastHookSignature = renderedHookSignature;
|
|
362
|
+
}
|
|
363
|
+
const renderContext = this.buildStreamingRenderContext(streamingPartialFrame);
|
|
364
|
+
const onRenderError = (error: Error) => {
|
|
365
|
+
this.callbacks.onError?.(error, "render");
|
|
366
|
+
if (!preserve) return;
|
|
367
|
+
if (this.slot.lastGood() === renderedComponent || !this.restoreLastGood()) this.errorEpoch += 1;
|
|
368
|
+
};
|
|
369
|
+
this.root.render(
|
|
370
|
+
createElement(
|
|
371
|
+
RuntimeErrorBoundary,
|
|
372
|
+
{ key: preserve ? getPreserveBoundaryKey(this.preserveBoundaryEpoch) : `boundary:${this.renderRound}`, resetKey: this.errorEpoch, onError: onRenderError },
|
|
373
|
+
createElement(GenUIRenderContext.Provider, { value: renderContext }, createElement(preserve ? this.slot.Component : (renderedComponent as ComponentType<Record<string, unknown>>), { key: "generated-component" })),
|
|
374
|
+
createElement(RenderNotify, {
|
|
375
|
+
key: `render-notify:${this.renderRound}`,
|
|
376
|
+
onCommit: () => {
|
|
377
|
+
if (this.slot.current() === renderedComponent) {
|
|
378
|
+
this.slot.markRendered(renderedComponent);
|
|
379
|
+
if (preserve) this.lastGoodHookSignature = renderedHookSignature;
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
onReady: () => {
|
|
383
|
+
this.callbacks.onRendered?.(renderedComponent, renderedCode, renderSerial);
|
|
384
|
+
},
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createElement, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type GeneratedFunctionComponent = (props: Record<string, unknown>) => ReactNode | Promise<ReactNode>;
|
|
4
|
+
export type GeneratedComponent = ComponentType<Record<string, unknown>> | GeneratedFunctionComponent;
|
|
5
|
+
export type GeneratedComponentSlot = { Component: GeneratedFunctionComponent; current: () => unknown; lastGood: () => unknown; setCurrent: (component: unknown) => void; markRendered: (component?: unknown) => void; restoreLastGood: () => boolean; clear: () => void };
|
|
6
|
+
const isClassComponent = (component: unknown) => typeof component === "function" && Boolean((component as { prototype?: { isReactComponent?: unknown } }).prototype?.isReactComponent);
|
|
7
|
+
|
|
8
|
+
export function createGeneratedComponentSlot(): GeneratedComponentSlot {
|
|
9
|
+
const currentRef = { current: null as unknown };
|
|
10
|
+
const lastGoodRef = { current: null as unknown };
|
|
11
|
+
const Component = (props: Record<string, unknown>) => {
|
|
12
|
+
const Current = currentRef.current;
|
|
13
|
+
if (!Current) return null;
|
|
14
|
+
// Function components are invoked inside this stable wrapper so React keeps their hooks on the wrapper fiber.
|
|
15
|
+
if (typeof Current === "function" && !isClassComponent(Current)) return (Current as (props: Record<string, unknown>) => ReactNode | Promise<ReactNode>)(props);
|
|
16
|
+
return createElement(Current as never, props);
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
Component,
|
|
20
|
+
current: () => currentRef.current,
|
|
21
|
+
lastGood: () => lastGoodRef.current,
|
|
22
|
+
setCurrent(component) {
|
|
23
|
+
currentRef.current = component;
|
|
24
|
+
},
|
|
25
|
+
markRendered(component = currentRef.current) {
|
|
26
|
+
if (component) lastGoodRef.current = component;
|
|
27
|
+
},
|
|
28
|
+
restoreLastGood() {
|
|
29
|
+
if (!lastGoodRef.current) return false;
|
|
30
|
+
currentRef.current = lastGoodRef.current;
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
clear() {
|
|
34
|
+
currentRef.current = null;
|
|
35
|
+
lastGoodRef.current = null;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// Placeholder
|