partial-react 0.0.0 → 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 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,11 +1,31 @@
1
1
  {
2
2
  "name": "partial-react",
3
- "version": "0.0.0",
4
- "description": "Placeholder for partial-react",
5
- "main": "index.js",
6
- "scripts": {},
7
- "keywords": [],
8
- "author": "muspi-merol (https://www.npmjs.com/~muspi-merol)",
9
- "license": "UNLICENSED",
10
- "private": false
11
- }
3
+ "description": "A state-preserving renderer for prefix-growing streaming React/TSX.",
4
+ "version": "0.0.2",
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.ts",
13
+ "./compiler": "./src/compiler.ts",
14
+ "./import-map": "./src/importMap.ts",
15
+ "./remote-module": "./src/remoteModule.ts",
16
+ "./render-context": "./src/renderContext.ts"
17
+ },
18
+ "files": [
19
+ "src/**/*",
20
+ "!src/**/*.test.*"
21
+ ],
22
+ "dependencies": {
23
+ "@esm.sh/tsx": "^1.5.3",
24
+ "partial-tsx": "0.0.2"
25
+ },
26
+ "peerDependencies": {
27
+ "react": "^19.0.0",
28
+ "react-dom": "^19.0.0"
29
+ },
30
+ "sideEffects": false
31
+ }
@@ -0,0 +1,40 @@
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; lang?: "ts" | "tsx" | "js" | "jsx"; 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
+ // `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);
16
+ };
17
+ const initCompiler = () =>
18
+ (initPromise ??= (async () => {
19
+ try {
20
+ if (typeof window === "undefined") return initTsx(await loadNodeWasm());
21
+ } catch {
22
+ // Environments without a filesystem (e.g. web workers) fall back to Vite's asset URL below.
23
+ }
24
+ return initTsx(tsxWasmUrl);
25
+ })().catch((error) => {
26
+ initPromise = null;
27
+ throw error;
28
+ }));
29
+
30
+ export function createTsxCompiler(): TsxCompiler {
31
+ return {
32
+ async compile(code, options = {}) {
33
+ await initCompiler();
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" });
36
+ const compiled = new TextDecoder().decode(result.code);
37
+ return { code: compiled, source, changed: compiled !== options.previousCode };
38
+ },
39
+ };
40
+ }
@@ -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
+ };