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/src/runtime.ts ADDED
@@ -0,0 +1,418 @@
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 GeneratedComponent, type GeneratedComponentSlot } from "./state";
5
+ export type { GeneratedComponent } from "./state";
6
+ import { createTsxCompiler, type TsxCompiler, type CompileResult } from "./compiler";
7
+ import { GenUIRenderContext, getGenUIRenderContextValue } from "./renderContext";
8
+ import { getGenUIHookSignature } from "./hookSignature";
9
+ import type { RendererImportMap } from "./importMap";
10
+
11
+ export type GenUIRenderPhase = "transform" | "compile" | "render";
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 };
13
+ export type GenUIRendererClearOptions = { preserveVisualState?: boolean };
14
+ export type GenUIRendererFlushMode = "microtask" | "immediate";
15
+ export type GenUIRendererOptions = { importmap?: RendererImportMap; callbacks?: GenUIRendererCallbacks; compiler?: TsxCompiler; preserveStateOnUpdate?: boolean; flushMode?: GenUIRendererFlushMode };
16
+ export type GenUIRendererUpdateMode = "push" | "render";
17
+
18
+ const attachedRenderer = Symbol.for("@macaron/genui-runtime:attached-renderer");
19
+ type AttachableHost = HTMLElement & { [attachedRenderer]?: GenUIRenderer };
20
+ const normalizeError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
21
+ const COMPILED_COMPONENT_CACHE_LIMIT = 256;
22
+ let rendererScopeSerial = 0;
23
+ const createRendererScope = () => `renderer:${(rendererScopeSerial += 1).toString(36)}`;
24
+ const compiledComponentCache = new Map<string, GeneratedComponent>();
25
+ const getPreserveBoundaryKey = (epoch: number) => (epoch === 0 ? "boundary" : `boundary:${epoch}`);
26
+ const rememberCompiledComponent = (code: string, component: GeneratedComponent) => {
27
+ if (compiledComponentCache.has(code)) compiledComponentCache.delete(code);
28
+ compiledComponentCache.set(code, component);
29
+ if (compiledComponentCache.size > COMPILED_COMPONENT_CACHE_LIMIT) {
30
+ const oldestKey = compiledComponentCache.keys().next().value;
31
+ if (oldestKey !== undefined) compiledComponentCache.delete(oldestKey);
32
+ }
33
+ };
34
+
35
+ class RuntimeErrorBoundary extends Component<{ resetKey?: number; onError?: (error: Error) => void; children?: ReactNode }, { hasError: boolean; lastResetKey?: number }> {
36
+ state = { hasError: false, lastResetKey: undefined as number | undefined };
37
+ static getDerivedStateFromError() {
38
+ return { hasError: true };
39
+ }
40
+ static getDerivedStateFromProps(props: { resetKey?: number }, state: { hasError: boolean; lastResetKey?: number }) {
41
+ // Streaming errors bump resetKey to clear the caught state in place; recreating the boundary via `key`
42
+ // would unmount the entire subtree below and produce visible flicker on every retry.
43
+ if (props.resetKey !== state.lastResetKey) return { hasError: false, lastResetKey: props.resetKey };
44
+ return null;
45
+ }
46
+ componentDidCatch(error: Error) {
47
+ this.props.onError?.(error);
48
+ }
49
+ render() {
50
+ if (this.state.hasError) return "ERROR";
51
+ return this.props.children;
52
+ }
53
+ }
54
+
55
+ const RenderNotify = ({ onCommit, onReady }: { onCommit?: () => void; onReady?: () => void }) => {
56
+ useEffect(() => {
57
+ onCommit?.();
58
+ if (typeof document !== "undefined" && document.hidden) {
59
+ onReady?.();
60
+ return;
61
+ }
62
+ const frameId = requestAnimationFrame(() => onReady?.());
63
+ return () => cancelAnimationFrame(frameId);
64
+ }, [onCommit, onReady]);
65
+ return null;
66
+ };
67
+
68
+ export class GenUIRenderer {
69
+ private callbacks: GenUIRendererCallbacks;
70
+ private compiler: TsxCompiler;
71
+ private compileSerial = 0;
72
+ private currentBuffer = "";
73
+ private compileScheduled = false;
74
+ private compileScheduleToken = 0;
75
+ private compileRunning = false;
76
+ private compilePending: { partial: boolean; serial?: number } | null = null;
77
+ private scheduledPartial = false;
78
+ private scheduledSerial?: number;
79
+ private errorEpoch = 0;
80
+ private finished = false;
81
+ private flushMode: GenUIRendererFlushMode;
82
+ private importmap?: RendererImportMap;
83
+ private lastCompiledCode = "";
84
+ private lastGoodHookSignature = "";
85
+ private lastHookSignature: string | null = null;
86
+ private moduleUrl: string | null = null;
87
+ private preserveBoundaryEpoch = 0;
88
+ private preserveStateOnUpdate: boolean;
89
+ private renderRound = 0;
90
+ private rendererScope: string;
91
+ private root: Root | null = null;
92
+ private slot: GeneratedComponentSlot;
93
+ private target: HTMLElement | null = null;
94
+ private updateMode: GenUIRendererUpdateMode = "render";
95
+
96
+ private constructor(target: HTMLElement | null | undefined, options: GenUIRendererOptions) {
97
+ this.callbacks = options.callbacks ?? {};
98
+ this.compiler = options.compiler ?? createTsxCompiler();
99
+ this.flushMode = options.flushMode ?? "microtask";
100
+ this.importmap = options.importmap;
101
+ this.preserveStateOnUpdate = options.preserveStateOnUpdate ?? true;
102
+ this.rendererScope = createRendererScope();
103
+ this.slot = createGeneratedComponentSlot();
104
+ if (target) this.attach(target);
105
+ }
106
+
107
+ static async create(target?: HTMLElement | null, options: GenUIRendererOptions = {}) {
108
+ return new GenUIRenderer(target, options);
109
+ }
110
+
111
+ attach(target: HTMLElement) {
112
+ const element = target as AttachableHost;
113
+ if (element[attachedRenderer] && element[attachedRenderer] !== this) element[attachedRenderer].detach();
114
+ if (this.target === target && this.root) {
115
+ element[attachedRenderer] = this;
116
+ return this;
117
+ }
118
+ const previous = this.target as AttachableHost | null;
119
+ if (previous && previous[attachedRenderer] === this) delete previous[attachedRenderer];
120
+ this.root?.unmount();
121
+ this.target = target;
122
+ this.root = createRoot(target);
123
+ element[attachedRenderer] = this;
124
+ this.renderComponent();
125
+ return this;
126
+ }
127
+
128
+ detach() {
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.
131
+ this.compileSerial += 1;
132
+ this.compilePending = null;
133
+ const previous = this.target as AttachableHost | null;
134
+ if (previous && previous[attachedRenderer] === this) delete previous[attachedRenderer];
135
+ this.root?.unmount();
136
+ this.root = null;
137
+ this.target = null;
138
+ this.rendererScope = createRendererScope();
139
+ this.revokeModuleUrl();
140
+ return this;
141
+ }
142
+
143
+ setPreserveStateOnUpdate(value: boolean) {
144
+ this.preserveStateOnUpdate = value;
145
+ return this;
146
+ }
147
+
148
+ setFlushMode(value: GenUIRendererFlushMode) {
149
+ this.flushMode = value;
150
+ return this;
151
+ }
152
+
153
+ setImportMap(importmap: RendererImportMap) {
154
+ this.importmap = importmap;
155
+ return this;
156
+ }
157
+
158
+ render(code: string, serial?: number) {
159
+ this.finished = true;
160
+ this.currentBuffer = code;
161
+ this.updateMode = "render";
162
+ this.scheduleCompile(false, serial);
163
+ }
164
+
165
+ pushCode(code: string, serial?: number) {
166
+ this.finished = false;
167
+ this.currentBuffer += code;
168
+ this.updateMode = "push";
169
+ this.scheduleCompile(true, serial);
170
+ }
171
+
172
+ finish(code?: string, serial?: number) {
173
+ this.finished = true;
174
+ if (code !== undefined) this.currentBuffer = code;
175
+ this.scheduleCompile(false, serial);
176
+ }
177
+
178
+ getCurrentBuffer() {
179
+ return this.currentBuffer;
180
+ }
181
+
182
+ getUpdateMode() {
183
+ return this.updateMode;
184
+ }
185
+
186
+ restoreLastGood() {
187
+ if (!this.slot.restoreLastGood()) return false;
188
+ this.errorEpoch += 1;
189
+ this.renderComponent(this.slot.current(), this.currentBuffer, undefined, this.currentBuffer, this.lastGoodHookSignature);
190
+ return true;
191
+ }
192
+
193
+ private buildStreamingRenderContext(streamingPartialFrame: boolean) {
194
+ let streamingRenderKeyIndex = 0;
195
+ return getGenUIRenderContextValue(streamingPartialFrame, this.rendererScope, () => `chart-${streamingRenderKeyIndex++}`);
196
+ }
197
+
198
+ private canRenderComponent(component: unknown, streamingPartialFrame: boolean) {
199
+ const renderContext = this.buildStreamingRenderContext(streamingPartialFrame);
200
+ const previousConsoleError = console.error;
201
+ let firstConsoleError: string | undefined;
202
+ let caughtError: unknown;
203
+ try {
204
+ // Preflight intentionally renders bad partial frames; keep React's development console noise off the debug page.
205
+ console.error = (...args) => {
206
+ firstConsoleError ??= args.map(String).join(" ");
207
+ };
208
+ renderToString(createElement(GenUIRenderContext.Provider, { value: renderContext }, createElement(component as ComponentType<Record<string, unknown>>)));
209
+ } catch (error) {
210
+ caughtError = error;
211
+ } finally {
212
+ console.error = previousConsoleError;
213
+ }
214
+ if (caughtError === undefined && firstConsoleError !== undefined) caughtError = new Error(firstConsoleError);
215
+ if (caughtError === undefined) return true;
216
+ this.callbacks.onError?.(normalizeError(caughtError), "render");
217
+ return false;
218
+ }
219
+
220
+ clear(options: GenUIRendererClearOptions = {}) {
221
+ this.compileSerial += 1;
222
+ this.currentBuffer = "";
223
+ // `clear` terminates the current stream; the next `pushCode`/`render` selects partial vs full mode again.
224
+ this.finished = true;
225
+ this.compileScheduled = false;
226
+ this.scheduledPartial = false;
227
+ this.scheduledSerial = undefined;
228
+ this.compileScheduleToken += 1;
229
+ this.compilePending = null;
230
+ this.lastCompiledCode = "";
231
+ if (options.preserveVisualState) return this;
232
+ this.lastGoodHookSignature = "";
233
+ this.lastHookSignature = null;
234
+ this.preserveBoundaryEpoch = 0;
235
+ this.rendererScope = createRendererScope();
236
+ this.slot.clear();
237
+ this.revokeModuleUrl();
238
+ this.root?.render(null);
239
+ return this;
240
+ }
241
+
242
+ private scheduleCompile(partial: boolean, serial?: number) {
243
+ if (this.flushMode === "immediate") {
244
+ this.compileScheduled = false;
245
+ this.scheduledPartial = false;
246
+ this.scheduledSerial = undefined;
247
+ this.compileScheduleToken += 1;
248
+ void this.compile(partial, serial);
249
+ return;
250
+ }
251
+ this.scheduledPartial = partial;
252
+ this.scheduledSerial = serial;
253
+ if (this.compileScheduled) return;
254
+ this.compileScheduled = true;
255
+ const scheduleToken = (this.compileScheduleToken += 1);
256
+ queueMicrotask(() => {
257
+ if (scheduleToken !== this.compileScheduleToken) return;
258
+ const nextPartial = this.scheduledPartial;
259
+ const nextSerial = this.scheduledSerial;
260
+ this.compileScheduled = false;
261
+ this.scheduledPartial = false;
262
+ this.scheduledSerial = undefined;
263
+ void this.compile(nextPartial, nextSerial);
264
+ });
265
+ }
266
+
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) {
289
+ const serial = (this.compileSerial += 1);
290
+ const code = this.currentBuffer;
291
+ let result: CompileResult;
292
+ try {
293
+ result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode });
294
+ } catch (error) {
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;
298
+ this.callbacks.onError?.(normalizeError(error), "transform");
299
+ return;
300
+ }
301
+ if (serial !== this.compileSerial) return;
302
+ if (!result.changed) {
303
+ const component = this.slot.current();
304
+ // The final full compile can equal the last partial frame; re-render once with finished=true so the chart render
305
+ // context settles from streaming to complete (charts key off streamingPartialFrame and would otherwise stay streaming).
306
+ if (component && this.preserveStateOnUpdate && this.updateMode === "push" && this.finished) {
307
+ this.renderComponent(component, code, renderSerial, result.source);
308
+ return;
309
+ }
310
+ if (component) this.scheduleRenderedCallback(component, code, renderSerial, serial);
311
+ return;
312
+ }
313
+ try {
314
+ const { component, moduleUrl } = await this.importCompiledComponent(result.code);
315
+ if (serial !== this.compileSerial) {
316
+ if (moduleUrl) URL.revokeObjectURL(moduleUrl);
317
+ return;
318
+ }
319
+ this.lastCompiledCode = result.code;
320
+ if (moduleUrl) this.adoptModuleUrl(moduleUrl);
321
+ else this.revokeModuleUrl();
322
+ this.callbacks.onReady?.(component, moduleUrl, code);
323
+ this.slot.setCurrent(component);
324
+ this.renderComponent(component, code, renderSerial, result.source);
325
+ } catch (error) {
326
+ if (serial !== this.compileSerial || this.compilePending) return;
327
+ this.callbacks.onError?.(normalizeError(error), "compile");
328
+ }
329
+ }
330
+
331
+ private async importCompiledComponent(code: string): Promise<{ component: GeneratedComponent; moduleUrl?: string }> {
332
+ const cached = compiledComponentCache.get(code);
333
+ if (cached) return { component: cached };
334
+ const moduleUrl = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
335
+ try {
336
+ const module = (await import(/* @vite-ignore */ moduleUrl)) as { default?: GeneratedComponent };
337
+ if (!module.default) throw new Error("No default export found in compiled module.");
338
+ rememberCompiledComponent(code, module.default);
339
+ return { component: module.default, moduleUrl };
340
+ } catch (error) {
341
+ URL.revokeObjectURL(moduleUrl);
342
+ throw error;
343
+ }
344
+ }
345
+
346
+ private adoptModuleUrl(moduleUrl: string) {
347
+ if (this.moduleUrl && this.moduleUrl !== moduleUrl) URL.revokeObjectURL(this.moduleUrl);
348
+ this.moduleUrl = moduleUrl;
349
+ }
350
+
351
+ private revokeModuleUrl() {
352
+ if (!this.moduleUrl) return;
353
+ URL.revokeObjectURL(this.moduleUrl);
354
+ this.moduleUrl = null;
355
+ }
356
+
357
+ private scheduleRenderedCallback(component: GeneratedComponent, code: string, renderSerial: number | undefined, compileSerial: number) {
358
+ const notify = () => {
359
+ if (compileSerial === this.compileSerial) this.callbacks.onRendered?.(component, code, renderSerial);
360
+ };
361
+ if (typeof document !== "undefined" && document.hidden) {
362
+ notify();
363
+ return;
364
+ }
365
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(notify);
366
+ else queueMicrotask(notify);
367
+ }
368
+
369
+ private renderComponent(component = this.slot.current(), renderedCode = this.currentBuffer, renderSerial?: number, sourceCode = renderedCode, hookSignature = getGenUIHookSignature(sourceCode)) {
370
+ if (!this.root || !component) return;
371
+ this.renderRound += 1;
372
+ const renderedComponent = component;
373
+ // streamingPartialFrame only drives the chart render context below (chart keys remount per partial frame). Preserve
374
+ // stays on throughout streaming: each partial frame can have a different hook count, so the hook-signature diff in
375
+ // the preserve branch remounts the stable wrapper whenever the layout changes, which is what would otherwise reuse
376
+ // the wrong hook cells and violate the Rules of Hooks (React #310).
377
+ const streamingPartialFrame = this.updateMode === "push" && !this.finished;
378
+ const preserve = this.preserveStateOnUpdate;
379
+ const renderedHookSignature = hookSignature;
380
+ const lastGood = this.slot.lastGood();
381
+ if (preserve && streamingPartialFrame && lastGood && lastGood !== renderedComponent && !this.canRenderComponent(renderedComponent, streamingPartialFrame)) {
382
+ this.slot.restoreLastGood();
383
+ return;
384
+ }
385
+ if (preserve) {
386
+ // Preserving state across hook layout changes reuses the wrong hook cells; remount only that stable wrapper epoch.
387
+ // lastHookSignature is null only before the first committed frame; "" is a real hookless signature, so a hookless→hooked
388
+ // transition (the common JSX-first-then-useState stream shape) must still bump the epoch and remount (React #310).
389
+ if (this.lastHookSignature !== null && this.lastHookSignature !== renderedHookSignature) this.preserveBoundaryEpoch += 1;
390
+ this.lastHookSignature = renderedHookSignature;
391
+ }
392
+ const renderContext = this.buildStreamingRenderContext(streamingPartialFrame);
393
+ const onRenderError = (error: Error) => {
394
+ this.callbacks.onError?.(error, "render");
395
+ if (!preserve) return;
396
+ if (this.slot.lastGood() === renderedComponent || !this.restoreLastGood()) this.errorEpoch += 1;
397
+ };
398
+ this.root.render(
399
+ createElement(
400
+ RuntimeErrorBoundary,
401
+ { key: preserve ? getPreserveBoundaryKey(this.preserveBoundaryEpoch) : `boundary:${this.renderRound}`, resetKey: this.errorEpoch, onError: onRenderError },
402
+ createElement(GenUIRenderContext.Provider, { value: renderContext }, createElement(preserve ? this.slot.Component : (renderedComponent as ComponentType<Record<string, unknown>>), { key: "generated-component" })),
403
+ createElement(RenderNotify, {
404
+ key: `render-notify:${this.renderRound}`,
405
+ onCommit: () => {
406
+ if (this.slot.current() === renderedComponent) {
407
+ this.slot.markRendered(renderedComponent);
408
+ if (preserve) this.lastGoodHookSignature = renderedHookSignature;
409
+ }
410
+ },
411
+ onReady: () => {
412
+ this.callbacks.onRendered?.(renderedComponent, renderedCode, renderSerial);
413
+ },
414
+ }),
415
+ ),
416
+ );
417
+ }
418
+ }
package/src/state.ts ADDED
@@ -0,0 +1,46 @@
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 = {
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
+ };
14
+ const isClassComponent = (component: unknown) => typeof component === "function" && Boolean((component as { prototype?: { isReactComponent?: unknown } }).prototype?.isReactComponent);
15
+
16
+ export function createGeneratedComponentSlot(): GeneratedComponentSlot {
17
+ const currentRef = { current: null as GeneratedComponent | null };
18
+ const lastGoodRef = { current: null as GeneratedComponent | null };
19
+ const Component = (props: Record<string, unknown>) => {
20
+ const Current = currentRef.current;
21
+ if (!Current) return null;
22
+ // Function components are invoked inside this stable wrapper so React keeps their hooks on the wrapper fiber.
23
+ if (typeof Current === "function" && !isClassComponent(Current)) return (Current as (props: Record<string, unknown>) => ReactNode | Promise<ReactNode>)(props);
24
+ return createElement(Current as never, props);
25
+ };
26
+ return {
27
+ Component,
28
+ current: () => currentRef.current,
29
+ lastGood: () => lastGoodRef.current,
30
+ setCurrent(component) {
31
+ currentRef.current = component;
32
+ },
33
+ markRendered(component = currentRef.current) {
34
+ if (component) lastGoodRef.current = component;
35
+ },
36
+ restoreLastGood() {
37
+ if (!lastGoodRef.current) return false;
38
+ currentRef.current = lastGoodRef.current;
39
+ return true;
40
+ },
41
+ clear() {
42
+ currentRef.current = null;
43
+ lastGoodRef.current = null;
44
+ },
45
+ };
46
+ }
package/index.js DELETED
@@ -1 +0,0 @@
1
- // Placeholder