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 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.7",
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.4"
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 { normalizeGeneratedTsx } from "partial-tsx";
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 ? normalizeGeneratedTsx(code, { mode: "streaming" }) : code;
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; retryDelayMs?: 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. Failures remain retryable, while a
847
- // 2xx means esm.sh has finished resolving/building before import() sees the URL.
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
- // 3 attempts at 250ms exponential backoff spans ~750ms of retrying, which covers a typical esm.sh cold build
853
- // without stalling first paint. There is no ack to wait on — esm.sh answers 5xx while the build is still running.
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 5xx while a cold build is still running, and 429 when npm rate-limits it — both transient,
865
- // same classification the probes below use. Any other 4xx is a decided answer (unknown package, rejected
866
- // specifier); retrying burns the full backoff for the same result.
867
- if (response.status < 500 && response.status !== 429) break;
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
  }
@@ -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: () => {