partial-react 0.0.7 → 0.0.9
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 +1 -0
- package/package.json +2 -2
- package/src/compiler.ts +5 -5
- package/src/importMap.ts +17 -11
- package/src/remoteModule.ts +2 -1
- package/src/runtime.ts +43 -4
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ Turbopack builds also need [#1690](https://github.com/MindLab-Research/macaron-g
|
|
|
52
52
|
## Corner Cases
|
|
53
53
|
|
|
54
54
|
- `render(code)` replaces the current buffer with a complete TSX module.
|
|
55
|
+
- While using `pushCode`, the last inferred entry that committed non-empty React output stays selected while a later component is still unclosed, so a half-streamed helper cannot blank the preview. Once that component closes, the last declared one takes over as usual. Explicit default exports always take precedence, empty output never becomes the displayed entry, and any `clear()` or source replacement resets it. Entry state belongs to the renderer, so a compiler can still be shared between previews.
|
|
55
56
|
- `clear({ preserveVisualState: true })` clears pending code but leaves the last visual tree in place.
|
|
56
57
|
- Missing default exports fail at compile/import time.
|
|
57
58
|
- Render failures call `onError` and, with state preservation on, try to restore the last good component.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partial-react",
|
|
3
3
|
"description": "A state-preserving renderer for prefix-growing streaming React/TSX.",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@esm.sh/tsx": "^1.5.3",
|
|
27
|
-
"partial-tsx": "0.0.
|
|
27
|
+
"partial-tsx": "~0.0.6"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/compiler.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import initTsx, { transform as transformTsx } from "@esm.sh/tsx";
|
|
2
|
-
import {
|
|
2
|
+
import { normalizeGeneratedTsxWithMetadata } from "partial-tsx";
|
|
3
3
|
import { rewriteImportMetaResolveSpecifiers, type RendererImportMap } from "./importMap";
|
|
4
4
|
|
|
5
|
-
export type CompileOptions = { importMap?: RendererImportMap; partial?: boolean; previousCode?: string; filename?: string; lang?: "ts" | "tsx" | "js" | "jsx"; jsxImportSource?: string };
|
|
6
|
-
export type CompileResult = { code: string; source: string; changed: boolean };
|
|
5
|
+
export type CompileOptions = { importMap?: RendererImportMap; partial?: boolean; previousCode?: string; filename?: string; lang?: "ts" | "tsx" | "js" | "jsx"; jsxImportSource?: string; synthesizeDefaultExport?: boolean; preferredComponent?: string };
|
|
6
|
+
export type CompileResult = { code: string; source: string; changed: boolean; inferredComponent?: string };
|
|
7
7
|
export type TsxCompiler = { compile: (code: string, options?: CompileOptions) => Promise<CompileResult> };
|
|
8
8
|
|
|
9
9
|
let initPromise: Promise<unknown> | null = null;
|
|
@@ -25,11 +25,11 @@ export function createTsxCompiler(): TsxCompiler {
|
|
|
25
25
|
return {
|
|
26
26
|
async compile(code, options = {}) {
|
|
27
27
|
await initCompiler();
|
|
28
|
-
const source = options.partial ?
|
|
28
|
+
const { source, inferredComponent } = options.partial ? normalizeGeneratedTsxWithMetadata(code, { mode: "streaming", synthesizeDefaultExport: options.synthesizeDefaultExport, preferredComponent: options.preferredComponent }) : { source: code };
|
|
29
29
|
const resolvedSource = options.importMap?.imports ? rewriteImportMetaResolveSpecifiers(source, options.importMap.imports) : source;
|
|
30
30
|
const result = transformTsx({ filename: options.filename ?? "_.tsx", code: resolvedSource, lang: options.lang, target: "es2022", importMap: options.importMap, jsxImportSource: options.jsxImportSource ?? "react" });
|
|
31
31
|
const compiled = new TextDecoder().decode(result.code);
|
|
32
|
-
return { code: compiled, source, changed: compiled !== options.previousCode };
|
|
32
|
+
return { code: compiled, source, changed: compiled !== options.previousCode, inferredComponent };
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
35
|
}
|
package/src/importMap.ts
CHANGED
|
@@ -838,33 +838,39 @@ export function toEsmShImportUrl(specifier: string, external: readonly string[]
|
|
|
838
838
|
}
|
|
839
839
|
|
|
840
840
|
type EsmShPreloadFetch = (url: string) => Promise<Pick<Response, "ok" | "status">>;
|
|
841
|
-
type EsmShPreloadOptions = { fetch?: EsmShPreloadFetch; attempts?: number
|
|
841
|
+
type EsmShPreloadOptions = { fetch?: EsmShPreloadFetch; attempts?: number };
|
|
842
842
|
const esmShPreloadCache = new Map<string, Promise<void>>();
|
|
843
843
|
|
|
844
844
|
// A browser module loader memoizes a rejected URL for the page lifetime, so retrying the generated blob cannot recover
|
|
845
845
|
// from a transient esm.sh cold-build failure. Warm the entry URL with ordinary fetch first — a version range still
|
|
846
|
-
// leaves it a mutable entry esm.sh has to resolve and build, not a pinned artifact.
|
|
847
|
-
//
|
|
846
|
+
// leaves it a mutable entry esm.sh has to resolve and build, not a pinned artifact. A 2xx means esm.sh has finished
|
|
847
|
+
// resolving/building before import() sees the URL; transient failures get bounded response-driven attempts below.
|
|
848
848
|
export function preloadEsmShImportUrl(url: string, options?: EsmShPreloadOptions): Promise<void> {
|
|
849
849
|
let preload = esmShPreloadCache.get(url);
|
|
850
850
|
if (!preload) {
|
|
851
851
|
const fetcher = options?.fetch ?? fetch;
|
|
852
|
-
//
|
|
853
|
-
//
|
|
852
|
+
// Three total attempts cover the initial request plus two response-driven retries, each sent only after the
|
|
853
|
+
// previous fetch settles. Answers that stop early are evicted below, so an independent preload caller can start a
|
|
854
|
+
// fresh request instead of this one sending an immediate retry burst.
|
|
854
855
|
const attempts = options?.attempts ?? 3;
|
|
855
|
-
const retryDelayMs = options?.retryDelayMs ?? 250;
|
|
856
856
|
preload = (async () => {
|
|
857
857
|
let lastFailure: unknown;
|
|
858
|
+
let retriedColdBuild = false;
|
|
858
859
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
859
|
-
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** (attempt - 1)));
|
|
860
860
|
try {
|
|
861
861
|
const response = await fetcher(url);
|
|
862
862
|
if (response.ok) return;
|
|
863
863
|
lastFailure = new Error(`HTTP ${response.status}`);
|
|
864
|
-
// esm.sh answers
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
|
|
864
|
+
// esm.sh answers 408 after its cold-build wait, so the retry re-joins the same queued build; it gets at most
|
|
865
|
+
// one follow-up within the shared budget, since repeating it can hold a render for another full build window.
|
|
866
|
+
// 5xx responses and network errors use the bounded budget, but expose no client-side ready event, so the loop
|
|
867
|
+
// does not pretend to wait out a still-running build. 429 has no completion signal we can await, and any
|
|
868
|
+
// other 4xx is decided, so those answers stop this request.
|
|
869
|
+
if (response.status === 408 && !retriedColdBuild) {
|
|
870
|
+
retriedColdBuild = true;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (response.status < 500) break;
|
|
868
874
|
} catch (error) {
|
|
869
875
|
lastFailure = error;
|
|
870
876
|
}
|
package/src/remoteModule.ts
CHANGED
|
@@ -90,6 +90,7 @@ export function createEsmShModuleResolver(options?: EsmShModuleResolverOptions):
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export function transformedEsmShFallback(options?: {
|
|
93
|
+
external?: readonly string[];
|
|
93
94
|
dev?: boolean;
|
|
94
95
|
dependencyRanges?: EsmShDependencyRanges;
|
|
95
96
|
baseUrl?: string;
|
|
@@ -102,7 +103,7 @@ export function transformedEsmShFallback(options?: {
|
|
|
102
103
|
const resolveEsmSh = options?.resolveEsmSh ?? createEsmShModuleResolver({ compiler: options?.compiler, fetch: options?.fetch, preload: options?.preload, baseUrl: options?.baseUrl });
|
|
103
104
|
// Decorate the plain fallback rather than restate it: the specifier filter, the local-package preference and the
|
|
104
105
|
// esm.sh URL shape all stay single-sourced, and this provider only decides what to do with each resolved target.
|
|
105
|
-
const base = esmShFallback({ dev: options?.dev, dependencyRanges: options?.dependencyRanges, hasLocalPackage: options?.hasLocalPackage });
|
|
106
|
+
const base = esmShFallback({ external: options?.external, dev: options?.dev, dependencyRanges: options?.dependencyRanges, hasLocalPackage: options?.hasLocalPackage });
|
|
106
107
|
// A failed compile evicts its cache entry so a later render can retry, which means every stream chunk re-attempts and
|
|
107
108
|
// would re-warn — hundreds of identical lines burying the first, actually diagnostic one. Warn once per target.
|
|
108
109
|
const warned = new Set<string>();
|
package/src/runtime.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type GenUIRendererClearOptions = { preserveVisualState?: boolean };
|
|
|
16
16
|
export type GenUIRendererFlushMode = "microtask" | "immediate";
|
|
17
17
|
// `filename` is the base @esm.sh/tsx resolves relative specifiers against. Hosts that render a file from disk pass its
|
|
18
18
|
// path so `./data` stays `./data`; without one the default `_.tsx` turns it into the unresolvable `_.tsx/data` (#1601).
|
|
19
|
-
export type GenUIRendererOptions = { importmap?: RendererImportMap; callbacks?: GenUIRendererCallbacks; compiler?: TsxCompiler; preserveStateOnUpdate?: boolean; flushMode?: GenUIRendererFlushMode; filename?: string };
|
|
19
|
+
export type GenUIRendererOptions = { importmap?: RendererImportMap; callbacks?: GenUIRendererCallbacks; compiler?: TsxCompiler; preserveStateOnUpdate?: boolean; flushMode?: GenUIRendererFlushMode; filename?: string; synthesizeDefaultExport?: boolean };
|
|
20
20
|
export type GenUIRendererUpdateMode = "push" | "render";
|
|
21
21
|
|
|
22
22
|
const attachedRenderer = Symbol.for("@macaron/genui-runtime:attached-renderer");
|
|
@@ -27,6 +27,13 @@ let rendererScopeSerial = 0;
|
|
|
27
27
|
const createRendererScope = () => `renderer:${(rendererScopeSerial += 1).toString(36)}`;
|
|
28
28
|
const compiledComponentCache = new Map<string, GeneratedComponent>();
|
|
29
29
|
const getPreserveBoundaryKey = (epoch: number) => (epoch === 0 ? "boundary" : `boundary:${epoch}`);
|
|
30
|
+
// Any element or non-blank text counts. Deliberately not a layout check: hosts may hide their first paint for measurement.
|
|
31
|
+
const hasCommittedContent = (target: HTMLElement) => {
|
|
32
|
+
for (let node = target.firstChild; node; node = node.nextSibling) {
|
|
33
|
+
if (node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && /\S/.test(node.nodeValue ?? ""))) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
30
37
|
const rememberCompiledComponent = (code: string, component: GeneratedComponent) => {
|
|
31
38
|
if (compiledComponentCache.has(code)) compiledComponentCache.delete(code);
|
|
32
39
|
compiledComponentCache.set(code, component);
|
|
@@ -90,6 +97,9 @@ export class GenUIRenderer {
|
|
|
90
97
|
private flushMode: GenUIRendererFlushMode;
|
|
91
98
|
private importmap?: RendererImportMap;
|
|
92
99
|
private filename?: string;
|
|
100
|
+
private synthesizeDefaultExport?: boolean;
|
|
101
|
+
private inferredComponent?: string;
|
|
102
|
+
private entryEpoch = 0;
|
|
93
103
|
private lastCompiledCode = "";
|
|
94
104
|
private lastGoodHookSignature = "";
|
|
95
105
|
private lastHookSignature: string | null = null;
|
|
@@ -109,6 +119,7 @@ export class GenUIRenderer {
|
|
|
109
119
|
this.flushMode = options.flushMode ?? "microtask";
|
|
110
120
|
this.importmap = options.importmap;
|
|
111
121
|
this.filename = options.filename;
|
|
122
|
+
this.synthesizeDefaultExport = options.synthesizeDefaultExport;
|
|
112
123
|
this.preserveStateOnUpdate = options.preserveStateOnUpdate ?? true;
|
|
113
124
|
this.rendererScope = createRendererScope();
|
|
114
125
|
this.slot = createGeneratedComponentSlot();
|
|
@@ -137,6 +148,7 @@ export class GenUIRenderer {
|
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
detach() {
|
|
151
|
+
this.resetEntry();
|
|
140
152
|
// Bump compileSerial so any in-flight compile resolves into a no-op instead of adopting onto a dead renderer.
|
|
141
153
|
// Drop the queued follow-up too: it would start a fresh chain (self-consistent serial) that the bump can't invalidate.
|
|
142
154
|
this.compileSerial += 1;
|
|
@@ -167,6 +179,7 @@ export class GenUIRenderer {
|
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
render(code: string, serial?: number) {
|
|
182
|
+
if (!code.startsWith(this.currentBuffer)) this.resetEntry();
|
|
170
183
|
this.finished = true;
|
|
171
184
|
this.currentBuffer = code;
|
|
172
185
|
this.updateMode = "render";
|
|
@@ -181,6 +194,7 @@ export class GenUIRenderer {
|
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
finish(code?: string, serial?: number) {
|
|
197
|
+
if (code !== undefined && !code.startsWith(this.currentBuffer)) this.resetEntry();
|
|
184
198
|
this.finished = true;
|
|
185
199
|
if (code !== undefined) this.currentBuffer = code;
|
|
186
200
|
this.scheduleCompile(false, serial);
|
|
@@ -229,6 +243,7 @@ export class GenUIRenderer {
|
|
|
229
243
|
}
|
|
230
244
|
|
|
231
245
|
clear(options: GenUIRendererClearOptions = {}) {
|
|
246
|
+
this.resetEntry();
|
|
232
247
|
this.compileSerial += 1;
|
|
233
248
|
this.currentBuffer = "";
|
|
234
249
|
// `clear` terminates the current stream; the next `pushCode`/`render` selects partial vs full mode again.
|
|
@@ -250,6 +265,11 @@ export class GenUIRenderer {
|
|
|
250
265
|
return this;
|
|
251
266
|
}
|
|
252
267
|
|
|
268
|
+
private resetEntry() {
|
|
269
|
+
this.inferredComponent = undefined;
|
|
270
|
+
this.entryEpoch += 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
253
273
|
private scheduleCompile(partial: boolean, serial?: number) {
|
|
254
274
|
if (this.flushMode === "immediate") {
|
|
255
275
|
this.compileScheduled = false;
|
|
@@ -298,10 +318,12 @@ export class GenUIRenderer {
|
|
|
298
318
|
|
|
299
319
|
private async compileOnce(partial: boolean, renderSerial?: number) {
|
|
300
320
|
const serial = (this.compileSerial += 1);
|
|
321
|
+
const entryEpoch = this.entryEpoch;
|
|
322
|
+
const preferredComponent = this.inferredComponent;
|
|
301
323
|
const code = this.currentBuffer;
|
|
302
324
|
let result: CompileResult;
|
|
303
325
|
try {
|
|
304
|
-
result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode, filename: this.filename });
|
|
326
|
+
result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode, filename: this.filename, synthesizeDefaultExport: this.synthesizeDefaultExport, preferredComponent });
|
|
305
327
|
} catch (error) {
|
|
306
328
|
// A queued follow-up means this frame is already superseded; its error is stale noise (pre-single-flight, the
|
|
307
329
|
// newer compile's serial bump suppressed it the same way).
|
|
@@ -310,6 +332,13 @@ export class GenUIRenderer {
|
|
|
310
332
|
return;
|
|
311
333
|
}
|
|
312
334
|
if (serial !== this.compileSerial) return;
|
|
335
|
+
// A preceding React commit can establish the entry while this asynchronous compile/import is still in flight. Within one
|
|
336
|
+
// epoch the only writer is that commit, which always stores a name, so a moved latch is necessarily non-empty.
|
|
337
|
+
const entryChanged = () => entryEpoch === this.entryEpoch && preferredComponent !== this.inferredComponent && !!result.inferredComponent && result.inferredComponent !== this.inferredComponent;
|
|
338
|
+
if (entryChanged()) {
|
|
339
|
+
this.compilePending ??= { partial, serial: renderSerial };
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
313
342
|
if (!result.changed) {
|
|
314
343
|
const component = this.slot.current();
|
|
315
344
|
// The final full compile can equal the last partial frame; re-render once with finished=true so the chart render
|
|
@@ -323,16 +352,22 @@ export class GenUIRenderer {
|
|
|
323
352
|
}
|
|
324
353
|
try {
|
|
325
354
|
const { component, moduleUrl } = await this.importCompiledComponent(result.code);
|
|
355
|
+
// Superseded frames just drop; a moved entry instead re-compiles against the new preference.
|
|
326
356
|
if (serial !== this.compileSerial) {
|
|
327
357
|
if (moduleUrl) URL.revokeObjectURL(moduleUrl);
|
|
328
358
|
return;
|
|
329
359
|
}
|
|
360
|
+
if (entryChanged()) {
|
|
361
|
+
if (moduleUrl) URL.revokeObjectURL(moduleUrl);
|
|
362
|
+
this.compilePending ??= { partial, serial: renderSerial };
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
330
365
|
this.lastCompiledCode = result.code;
|
|
331
366
|
if (moduleUrl) this.adoptModuleUrl(moduleUrl);
|
|
332
367
|
else this.revokeModuleUrl();
|
|
333
368
|
this.callbacks.onReady?.(component, moduleUrl, code);
|
|
334
369
|
this.slot.setCurrent(component);
|
|
335
|
-
this.renderComponent(component, code, renderSerial, result.source);
|
|
370
|
+
this.renderComponent(component, code, renderSerial, result.source, undefined, result.inferredComponent ? { component: result.inferredComponent, epoch: entryEpoch } : undefined);
|
|
336
371
|
} catch (error) {
|
|
337
372
|
if (serial !== this.compileSerial || this.compilePending) return;
|
|
338
373
|
this.callbacks.onError?.(normalizeError(error), "compile");
|
|
@@ -389,7 +424,7 @@ export class GenUIRenderer {
|
|
|
389
424
|
else queueMicrotask(notify);
|
|
390
425
|
}
|
|
391
426
|
|
|
392
|
-
private renderComponent(component = this.slot.current(), renderedCode = this.currentBuffer, renderSerial?: number, sourceCode = renderedCode, hookSignature = getGenUIHookSignature(sourceCode)) {
|
|
427
|
+
private renderComponent(component = this.slot.current(), renderedCode = this.currentBuffer, renderSerial?: number, sourceCode = renderedCode, hookSignature = getGenUIHookSignature(sourceCode), entry?: { component: string; epoch: number }) {
|
|
393
428
|
if (!this.root || !component) return;
|
|
394
429
|
this.renderRound += 1;
|
|
395
430
|
const renderedComponent = component;
|
|
@@ -413,7 +448,9 @@ export class GenUIRenderer {
|
|
|
413
448
|
this.lastHookSignature = renderedHookSignature;
|
|
414
449
|
}
|
|
415
450
|
const renderContext = this.buildStreamingRenderContext(streamingPartialFrame);
|
|
451
|
+
let failed = false;
|
|
416
452
|
const onRenderError = (error: Error) => {
|
|
453
|
+
failed = true;
|
|
417
454
|
this.callbacks.onError?.(error, "render");
|
|
418
455
|
if (!preserve) return;
|
|
419
456
|
if (this.slot.lastGood() === renderedComponent || !this.restoreLastGood()) this.errorEpoch += 1;
|
|
@@ -430,6 +467,8 @@ export class GenUIRenderer {
|
|
|
430
467
|
if (this.slot.current() === renderedComponent) {
|
|
431
468
|
this.slot.markRendered(renderedComponent);
|
|
432
469
|
if (preserve) this.lastGoodHookSignature = renderedHookSignature;
|
|
470
|
+
// Empty helpers are legal React output but cannot choose the stream's entry.
|
|
471
|
+
if (!failed && entry && entry.component !== this.inferredComponent && entry.epoch === this.entryEpoch && this.target && hasCommittedContent(this.target)) this.inferredComponent = entry.component;
|
|
433
472
|
}
|
|
434
473
|
},
|
|
435
474
|
onReady: () => {
|