partial-react 0.0.2 → 0.0.4

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
@@ -36,6 +36,12 @@ import { mergeFallbackImports, prepareRendererImportMap } from "partial-react/im
36
36
  import { useGenUIRenderContext } from "partial-react/render-context";
37
37
  ```
38
38
 
39
+ ## Bundlers
40
+
41
+ The loader's `import()` specifiers are computed at runtime — a blob URL for the generated module, import-map targets for its dependencies — so no bundler may resolve them at build time. They carry `@vite-ignore`, `webpackIgnore: true`, and `turbopackIgnore: true` together, which covers Vite, webpack/Rspack, and Turbopack (Next.js); if you re-emit this source through a tool that strips comments, preserve those three.
42
+
43
+ Turbopack builds also need [#1690](https://github.com/MindLab-Research/macaron-genui-demo/issues/1690): `compiler.ts` loads the tsx wasm through Vite's `?url` query, which Turbopack rejects. Until that lands, Next.js consumers still need to patch that import.
44
+
39
45
  ## State and Streaming
40
46
 
41
47
  - Keep `preserveStateOnUpdate: true` for normal previews. It preserves useful React state and DOM continuity across regenerated code.
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.2",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "@esm.sh/tsx": "^1.5.3",
24
- "partial-tsx": "0.0.2"
24
+ "partial-tsx": "0.0.3"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^19.0.0",
package/src/compiler.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import initTsx, { transform as transformTsx } from "@esm.sh/tsx";
2
- import tsxWasmUrl from "@esm.sh/tsx/pkg/tsx_bg.wasm?url";
3
2
  import { normalizeGeneratedTsx } from "partial-tsx";
4
- import type { RendererImportMap } from "./importMap";
3
+ import { rewriteImportMetaResolveSpecifiers, type RendererImportMap } from "./importMap";
5
4
 
6
5
  export type CompileOptions = { importMap?: RendererImportMap; partial?: boolean; previousCode?: string; filename?: string; lang?: "ts" | "tsx" | "js" | "jsx"; jsxImportSource?: string };
7
6
  export type CompileResult = { code: string; source: string; changed: boolean };
@@ -19,9 +18,10 @@ const initCompiler = () =>
19
18
  try {
20
19
  if (typeof window === "undefined") return initTsx(await loadNodeWasm());
21
20
  } catch {
22
- // Environments without a filesystem (e.g. web workers) fall back to Vite's asset URL below.
21
+ // Environments without a filesystem (e.g. web workers) fall back to the bundled asset below.
23
22
  }
24
- return initTsx(tsxWasmUrl);
23
+ // No argument: wasm-bindgen resolves `new URL("tsx_bg.wasm", import.meta.url)`, which every bundler understands — a Vite-only `?url` import here broke the rest. #1690
24
+ return initTsx();
25
25
  })().catch((error) => {
26
26
  initPromise = null;
27
27
  throw error;
@@ -32,7 +32,8 @@ export function createTsxCompiler(): TsxCompiler {
32
32
  async compile(code, options = {}) {
33
33
  await initCompiler();
34
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" });
35
+ const resolvedSource = options.importMap?.imports ? rewriteImportMetaResolveSpecifiers(source, options.importMap.imports) : source;
36
+ const result = transformTsx({ filename: options.filename ?? "_.tsx", code: resolvedSource, lang: options.lang, target: "es2022", importMap: options.importMap, jsxImportSource: options.jsxImportSource ?? "react" });
36
37
  const compiled = new TextDecoder().decode(result.code);
37
38
  return { code: compiled, source, changed: compiled !== options.previousCode };
38
39
  },
package/src/importMap.ts CHANGED
@@ -2,10 +2,11 @@ export type RendererImportMap = { imports?: Record<string, string>; scopes?: Rec
2
2
 
3
3
  // A provider contributes import-map entries. `base` providers always run; `fallback` providers only run when earlier
4
4
  // providers left some required specifier unresolved, and only see (and should answer for) those `missing` specifiers.
5
- export type ImportMapProviderContext = { code: string; missing: string[] };
5
+ export type ImportMapProviderContext = { code: string; missing: string[]; imports?: Readonly<Record<string, string>> };
6
6
  export type ImportMapProvider = { kind?: "base" | "fallback"; prefetch?: () => Promise<unknown> | void; resolve: (context: ImportMapProviderContext) => RendererImportMap | Promise<RendererImportMap> };
7
7
 
8
- const ESM_SH_VERSION_OVERRIDES = { "@number-flow/react": "0.6.0" } as const;
8
+ // Packages whose identity must be shared with a dependent's own bundled copy — see `toEsmShImportUrl`.
9
+ const SHARED_INSTANCE_PACKAGES = new Set(["three"]);
9
10
  const DEFAULT_ESM_SH_EXTERNALS = ["react", "react-dom", "scheduler"] as const;
10
11
  export const RUNTIME_SINGLETON_SPECIFIERS = ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime", "scheduler"] as const;
11
12
  export type RuntimeSingletonSpecifier = (typeof RUNTIME_SINGLETON_SPECIFIERS)[number];
@@ -18,6 +19,10 @@ const LOCAL_EXPORT_KEYWORDS = ["abstract", "async", "class", "const", "declare",
18
19
 
19
20
  export const isBareModuleSpecifier = (specifier: string) => !specifier.startsWith(".") && !specifier.startsWith("/") && !/^(?:[a-z][a-z\d+.-]*:|#)/i.test(specifier);
20
21
  export const getBarePackageName = (specifier: string) => (specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0] || specifier);
22
+ // A shared-instance package must stay on its canonical esm.sh URL end to end: `toEsmShImportUrl` keeps it unbundled so
23
+ // it resolves to the same `/three@x/es2022/three.mjs` a dependent's bundle imports, and callers must not blob it either
24
+ // — a blob: copy is a distinct module object, which is exactly the duplication the unbundled path exists to avoid.
25
+ export const isSharedInstancePackage = (specifier: string) => SHARED_INSTANCE_PACKAGES.has(getBarePackageName(specifier));
21
26
  // Shared #1178 contract: /src/… and /node_modules/… targets are root-relative disk paths each engine rebases against
22
27
  // its own root (dev: Vite's resolved root; build: cwd). Single-sourced so a new local prefix lands in both engines.
23
28
  export const isLocalRootRelativeTarget = (target: string) => target.startsWith("/src/") || target.startsWith("/node_modules/");
@@ -187,8 +192,9 @@ const readFromSpecifier = (code: string, index: number) => {
187
192
  continue;
188
193
  }
189
194
  if (isKeywordAt(code, index, "from")) {
190
- const literal = readStringLiteral(code, skipWhitespaceAndComments(code, index + "from".length));
191
- if (literal) return { specifier: literal.value, end: literal.end, terminated: literal.terminated };
195
+ const start = skipWhitespaceAndComments(code, index + "from".length);
196
+ const literal = readStringLiteral(code, start);
197
+ if (literal) return { specifier: literal.value, start, end: literal.end, terminated: literal.terminated };
192
198
  index += "from".length;
193
199
  continue;
194
200
  }
@@ -205,7 +211,99 @@ const readExportFromSpecifier = (code: string, index: number) => {
205
211
  return readFromSpecifier(code, next);
206
212
  };
207
213
 
208
- const collectModuleSpecifiers = (code: string, specifiers: Set<string>, includeSpecifier: (specifier: string) => boolean, start = 0, end = code.length) => {
214
+ // `.meta.resolve(` after the `import` keyword, returning the string-literal argument. Null means this `import.` is
215
+ // something else (`import.meta.url`, `import.meta.env`) and the caller should just skip past the dot.
216
+ const readImportMetaResolveSpecifier = (code: string, dotIndex: number) => {
217
+ let next = skipWhitespaceAndComments(code, dotIndex + 1);
218
+ if (!isKeywordAt(code, next, "meta")) return null;
219
+ next = skipWhitespaceAndComments(code, next + "meta".length);
220
+ if (code[next] !== ".") return null;
221
+ next = skipWhitespaceAndComments(code, next + 1);
222
+ if (!isKeywordAt(code, next, "resolve")) return null;
223
+ next = skipWhitespaceAndComments(code, next + "resolve".length);
224
+ if (code[next] !== "(") return null;
225
+ const start = skipWhitespaceAndComments(code, next + 1);
226
+ const literal = readStringLiteral(code, start);
227
+ return literal ? { ...literal, start } : { value: "", start, end: next, terminated: false };
228
+ };
229
+
230
+ const readIdentifierWord = (code: string, index: number) => {
231
+ let end = index;
232
+ while (end < code.length && IDENTIFIER_CHAR_PATTERN.test(code[end])) end += 1;
233
+ return end > index ? { name: code.slice(index, end), end } : null;
234
+ };
235
+
236
+ // Parses one static import clause right after the `import` keyword into the export names the module must provide
237
+ // ("default" for default imports; namespace and side-effect imports demand none). `notLinked` marks a type-only import,
238
+ // which survives specifier collection but is erased before the module is ever instantiated. Returns null on clauses it does not understand
239
+ // (streaming-truncated ones) so callers can fall back to the lenient readFromSpecifier.
240
+ const readImportBindings = (code: string, index: number): { specifier: string; names: string[]; start: number; end: number; terminated: boolean; notLinked?: boolean } | null => {
241
+ const names: string[] = [];
242
+ index = skipWhitespaceAndComments(code, index);
243
+ const sideEffect = readStringLiteral(code, index);
244
+ if (sideEffect) return sideEffect.value ? { specifier: sideEffect.value, names, start: index, end: sideEffect.end, terminated: sideEffect.terminated } : null;
245
+ if (isKeywordAt(code, index, "type")) {
246
+ const after = skipWhitespaceAndComments(code, index + "type".length);
247
+ // `import type …` is erased at runtime — but `import type from "x"` binds a default named `type`.
248
+ if (!isKeywordAt(code, after, "from")) {
249
+ const from = readFromSpecifier(code, after);
250
+ return from ? { ...from, names, notLinked: true } : null;
251
+ }
252
+ }
253
+ if (code[index] !== "{" && code[index] !== "*") {
254
+ const afterDefault = readIdentifierWord(code, index)?.end;
255
+ if (afterDefault === undefined) return null;
256
+ names.push("default");
257
+ index = skipWhitespaceAndComments(code, afterDefault);
258
+ if (code[index] === ",") index = skipWhitespaceAndComments(code, index + 1);
259
+ }
260
+ if (code[index] === "*") {
261
+ index = skipWhitespaceAndComments(code, index + 1);
262
+ if (!isKeywordAt(code, index, "as")) return null;
263
+ const afterNamespace = readIdentifierWord(code, skipWhitespaceAndComments(code, index + "as".length))?.end;
264
+ if (afterNamespace === undefined) return null;
265
+ index = skipWhitespaceAndComments(code, afterNamespace);
266
+ } else if (code[index] === "{") {
267
+ index = skipWhitespaceAndComments(code, index + 1);
268
+ while (index < code.length && code[index] !== "}") {
269
+ let typeOnly = false;
270
+ if (isKeywordAt(code, index, "type")) {
271
+ const after = skipWhitespaceAndComments(code, index + "type".length);
272
+ // `{ type Foo }` is erased; a lone `{ type }` / `{ type as t }` demands an export literally named `type`.
273
+ if (code[after] !== "," && code[after] !== "}" && !isKeywordAt(code, after, "as")) {
274
+ typeOnly = true;
275
+ index = after;
276
+ }
277
+ }
278
+ const literal = readStringLiteral(code, index);
279
+ const imported = literal ? { name: literal.value, end: literal.end } : readIdentifierWord(code, index);
280
+ if (!imported) return null;
281
+ index = skipWhitespaceAndComments(code, imported.end);
282
+ if (isKeywordAt(code, index, "as")) {
283
+ const local = readIdentifierWord(code, skipWhitespaceAndComments(code, index + "as".length));
284
+ if (!local) return null;
285
+ index = skipWhitespaceAndComments(code, local.end);
286
+ }
287
+ if (!typeOnly) names.push(imported.name);
288
+ if (code[index] === ",") index = skipWhitespaceAndComments(code, index + 1);
289
+ else if (code[index] !== "}") return null;
290
+ }
291
+ if (code[index] !== "}") return null;
292
+ index = skipWhitespaceAndComments(code, index + 1);
293
+ }
294
+ if (!isKeywordAt(code, index, "from")) return null;
295
+ const start = skipWhitespaceAndComments(code, index + "from".length);
296
+ const literal = readStringLiteral(code, start);
297
+ return literal?.value ? { specifier: literal.value, names, start, end: literal.end, terminated: literal.terminated } : null;
298
+ };
299
+
300
+ // Walks module-level import/export statements and reports each specifier with the export names bound from it
301
+ // (empty for namespace, side-effect, re-export and dynamic imports). `onSpecifier` is the single output channel —
302
+ // callers project it into whatever shape they need. `site` separates the module graph from `import.meta.resolve()`,
303
+ // which is a runtime asset lookup rather than an edge.
304
+ type ResolutionSite = "import" | "meta-resolve";
305
+ type SpecifierRange = { start: number; end: number };
306
+ const collectModuleSpecifiers = (code: string, onSpecifier: (specifier: string, names: string[], notLinked: boolean | undefined, site: ResolutionSite, range?: SpecifierRange) => void, start = 0, end = code.length) => {
209
307
  for (let index = start; index < end; index += 1) {
210
308
  const char = code[index];
211
309
  if (char === "/" && code[index + 1] === "/") {
@@ -225,45 +323,110 @@ const collectModuleSpecifiers = (code: string, specifiers: Set<string>, includeS
225
323
  continue;
226
324
  }
227
325
  if (char === "`") {
228
- index = skipTemplateLiteral(code, index, (expressionStart, expressionEnd) => collectModuleSpecifiers(code, specifiers, includeSpecifier, expressionStart, expressionEnd));
326
+ index = skipTemplateLiteral(code, index, (expressionStart, expressionEnd) => collectModuleSpecifiers(code, onSpecifier, expressionStart, expressionEnd));
229
327
  continue;
230
328
  }
231
329
  if (isKeywordAt(code, index, "import")) {
232
330
  const afterImport = skipWhitespaceAndComments(code, index + "import".length);
233
331
  if (code[afterImport] === ".") {
332
+ // Keep runtime asset lookups visible to callers that scan all specifiers, while separating them from actual
333
+ // module-graph edges. The renderer no longer projects these into a browser-native import map.
334
+ const literal = readImportMetaResolveSpecifier(code, afterImport);
335
+ if (literal) {
336
+ // notLinked: nothing is instantiated here, so probing it would fetch what the module never requested.
337
+ if (literal.terminated && literal.value) onSpecifier(literal.value, [], true, "meta-resolve", { start: literal.start, end: literal.end });
338
+ index = literal.end;
339
+ continue;
340
+ }
234
341
  index = afterImport;
235
342
  continue;
236
343
  }
237
344
  if (code[afterImport] === "(") {
238
- const literal = readStringLiteral(code, skipWhitespaceAndComments(code, afterImport + 1));
239
- if (literal?.terminated && literal.value && includeSpecifier(literal.value)) specifiers.add(literal.value);
345
+ const literalStart = skipWhitespaceAndComments(code, afterImport + 1);
346
+ const literal = readStringLiteral(code, literalStart);
347
+ // A dynamic target is not linked at instantiation, so probing it would issue a real request.
348
+ if (literal?.terminated && literal.value) onSpecifier(literal.value, [], true, "import", { start: literalStart, end: literal.end });
240
349
  index = literal?.end ?? afterImport;
241
350
  continue;
242
351
  }
243
- const sideEffect = readStringLiteral(code, afterImport);
244
- const from = sideEffect ? { specifier: sideEffect.value, end: sideEffect.end, terminated: sideEffect.terminated } : readFromSpecifier(code, afterImport);
245
- if (from?.terminated && from.specifier && includeSpecifier(from.specifier)) specifiers.add(from.specifier);
352
+ const bindings = readImportBindings(code, afterImport);
353
+ const from = bindings ?? readFromSpecifier(code, afterImport);
354
+ if (from?.terminated && from.specifier) onSpecifier(from.specifier, bindings?.names ?? [], bindings?.notLinked, "import", { start: from.start, end: from.end });
246
355
  index = from?.end ?? afterImport;
247
356
  continue;
248
357
  }
249
358
  if (isKeywordAt(code, index, "export")) {
250
359
  const from = readExportFromSpecifier(code, index + "export".length);
251
- if (from?.terminated && from.specifier && includeSpecifier(from.specifier)) specifiers.add(from.specifier);
360
+ if (from?.terminated && from.specifier) onSpecifier(from.specifier, [], undefined, "import", { start: from.start, end: from.end });
252
361
  index = from?.end ?? index;
253
362
  }
254
363
  }
255
364
  };
256
365
 
257
- export function extractBareModuleSpecifiers(code: string) {
366
+ const extractSpecifiers = (code: string, keep: (specifier: string, site: ResolutionSite) => boolean) => {
258
367
  const specifiers = new Set<string>();
259
- collectModuleSpecifiers(code, specifiers, isBareModuleSpecifier);
368
+ collectModuleSpecifiers(code, (specifier, _names, _notLinked, site) => {
369
+ if (keep(specifier, site)) specifiers.add(specifier);
370
+ });
260
371
  return specifiers;
372
+ };
373
+
374
+ export const extractBareModuleSpecifiers = (code: string) => extractSpecifiers(code, isBareModuleSpecifier);
375
+
376
+ // Specifiers reached through an `import`/`export` statement only — the actual module graph. Excludes
377
+ // `import.meta.resolve()` targets, which are runtime asset lookups (WASM binaries, worker scripts) rather than graph
378
+ // edges, so a consumer walking imports (the UnoCSS source tracer) doesn't try to parse a multi-MB binary as source.
379
+ export const extractImportSpecifiers = (code: string) => extractSpecifiers(code, (_specifier, site) => site === "import");
380
+
381
+ export const extractImportMetaResolveSpecifiers = (code: string) => extractSpecifiers(code, (_specifier, site) => site === "meta-resolve");
382
+
383
+ export const extractModuleSpecifiers = (code: string) => extractSpecifiers(code, () => true);
384
+
385
+ export function resolveImportMapSpecifier(imports: Readonly<Record<string, string>>, specifier: string) {
386
+ if (imports[specifier]) return imports[specifier];
387
+ let bestPrefix = "";
388
+ for (const prefix of Object.keys(imports)) if (prefix.endsWith("/") && specifier.startsWith(prefix) && prefix.length > bestPrefix.length) bestPrefix = prefix;
389
+ return bestPrefix ? `${imports[bestPrefix]}${specifier.slice(bestPrefix.length)}` : undefined;
261
390
  }
262
391
 
263
- export function extractModuleSpecifiers(code: string) {
264
- const specifiers = new Set<string>();
265
- collectModuleSpecifiers(code, specifiers, () => true);
266
- return specifiers;
392
+ // Replace the parsed string-literal specifiers at `keepSite` with their import-map targets. Working from the scanner's
393
+ // ranges — not a text match — is what makes this safe on minified bundles, where a string constant can read exactly
394
+ // like an import clause (`from"in $)}function S$(`).
395
+ const rewriteSpecifiers = (code: string, imports: Readonly<Record<string, string>>, keepSite: ResolutionSite) => {
396
+ // collectModuleSpecifiers scans forward, so ranges arrive in ascending order: emit the gap before each one and skip
397
+ // past it, then join once. Splicing the string per replacement would recopy the whole source each time.
398
+ const segments: string[] = [];
399
+ let copied = 0;
400
+ collectModuleSpecifiers(code, (specifier, _names, _notLinked, site, range) => {
401
+ const target = site === keepSite ? resolveImportMapSpecifier(imports, specifier) : undefined;
402
+ if (!target || !range) return;
403
+ segments.push(code.slice(copied, range.start), JSON.stringify(target));
404
+ copied = range.end;
405
+ });
406
+ if (!segments.length) return code;
407
+ segments.push(code.slice(copied));
408
+ return segments.join("");
409
+ };
410
+
411
+ // @esm.sh/tsx rewrites import declarations but deliberately leaves import.meta.resolve() calls intact. Replace only
412
+ // their parsed string-literal arguments with resolved URLs so blob: modules never need a browser-native import map.
413
+ export const rewriteImportMetaResolveSpecifiers = (code: string, imports: Readonly<Record<string, string>>) => rewriteSpecifiers(code, imports, "meta-resolve");
414
+
415
+ // The counterpart for code no compiler pass ever sees: host facade bundles are built with the runtime singletons
416
+ // externalized (that externalization is what keeps React a singleton), so their bare imports would otherwise survive to
417
+ // the browser and need a native import map. Rewriting them to the emitted chunk URLs is what lets that map go away.
418
+ export const rewriteImportSpecifiers = (code: string, imports: Readonly<Record<string, string>>) => rewriteSpecifiers(code, imports, "import");
419
+
420
+ // Maps each specifier linked when the module is instantiated to the export names bound from it ("default" included;
421
+ // namespace, side-effect and re-export imports demand no specific names). Type-only and dynamic imports are excluded:
422
+ // neither is linked at instantiation, so probing them would fetch something the failed module never requested.
423
+ // importProbe runs this over *compiled* code, where the import-map rewrite already resolved bare specifiers.
424
+ export function extractImportBindings(code: string) {
425
+ const bindings = new Map<string, string[]>();
426
+ collectModuleSpecifiers(code, (specifier, names, notLinked) => {
427
+ if (!notLinked) bindings.set(specifier, [...new Set([...(bindings.get(specifier) ?? []), ...names])]);
428
+ });
429
+ return bindings;
267
430
  }
268
431
 
269
432
  export function hasImportMapEntry(imports: Record<string, string> | Set<string> | Map<string, string>, specifier: string) {
@@ -273,14 +436,69 @@ export function hasImportMapEntry(imports: Record<string, string> | Set<string>
273
436
  return false;
274
437
  }
275
438
 
276
- export function toEsmShImportUrl(specifier: string, external: readonly string[] = DEFAULT_ESM_SH_EXTERNALS) {
439
+ export type EsmShDependencyRanges = Readonly<Record<string, string>>;
440
+ type EsmShImportUrlOptions = { dev?: boolean; dependencyRanges?: EsmShDependencyRanges };
441
+
442
+ export function toEsmShImportUrl(specifier: string, external: readonly string[] = DEFAULT_ESM_SH_EXTERNALS, options?: EsmShImportUrlOptions) {
277
443
  if (!canUseEsmShFallback(specifier)) throw new Error(`Cannot create an esm.sh fallback for ${specifier}`);
278
444
  const packageName = getBarePackageName(specifier);
279
- const version = ESM_SH_VERSION_OVERRIDES[packageName as keyof typeof ESM_SH_VERSION_OVERRIDES];
280
- const versionedSpecifier = version ? `${packageName}@${version}${specifier.slice(packageName.length)}` : specifier;
281
- const separator = versionedSpecifier.includes("?") ? "&" : "?";
445
+ // Dev builds: production bundles strip the dev-only dispatcher fields (e.g. getOwner) that the dev jsx-runtime
446
+ // calls while an externalized reconciler (react-reconciler inside @react-three/fiber) owns the render pass.
447
+ const devParam = options?.dev ? "&dev" : "";
448
+ // `bundle`/`external` both fork esm.sh's build path, so a bundled `three` is a different module object than the plain
449
+ // `/three@x/es2022/three.mjs` that `@react-three/fiber` imports. Shared-instance packages use that unbundled path so
450
+ // R3F's `instanceof THREE.Layers/Color` checks see the same constructors as user-created Three.js objects. Their
451
+ // identity comes from agreeing with a dependent's own pinned range (R3F asks for `three@>=0.156`), so our side stays
452
+ // unconstrained too and both float onto latest, landing on the same pinned build. The invariant is that agreement,
453
+ // not version-freedom — it would break the day R3F pins an upper bound or we constrain our side.
454
+ if (isSharedInstancePackage(specifier)) return `https://esm.sh/${specifier}${specifier.includes("?") ? "&" : "?"}target=es2022${devParam}`;
455
+ // Keep the repository's declared semver range as the single source of truth. Protocol-based specs describe local,
456
+ // aliased, or non-registry packages that esm.sh cannot resolve and therefore stay version-free here.
457
+ const dependencyRange = options?.dependencyRanges?.[packageName];
458
+ const constrainedSpecifier = dependencyRange && !dependencyRange.includes(":") ? `${packageName}@${encodeURIComponent(dependencyRange)}${specifier.slice(packageName.length)}` : specifier;
459
+ const separator = constrainedSpecifier.includes("?") ? "&" : "?";
282
460
  const externalParam = external.length ? `&external=${external.join(",")}` : "";
283
- return `https://esm.sh/${versionedSpecifier}${separator}bundle&target=es2022${externalParam}`;
461
+ return `https://esm.sh/${constrainedSpecifier}${separator}bundle&target=es2022${devParam}${externalParam}`;
462
+ }
463
+
464
+ type EsmShPreloadFetch = (url: string) => Promise<Pick<Response, "ok" | "status">>;
465
+ type EsmShPreloadOptions = { fetch?: EsmShPreloadFetch; attempts?: number; retryDelayMs?: number };
466
+ const esmShPreloadCache = new Map<string, Promise<void>>();
467
+
468
+ // A browser module loader memoizes a rejected URL for the page lifetime, so retrying the generated blob cannot recover
469
+ // from a transient esm.sh cold-build failure. Warm the entry URL with ordinary fetch first — a version range still
470
+ // leaves it a mutable entry esm.sh has to resolve and build, not a pinned artifact. Failures remain retryable, while a
471
+ // 2xx means esm.sh has finished resolving/building before import() sees the URL.
472
+ export function preloadEsmShImportUrl(url: string, options?: EsmShPreloadOptions): Promise<void> {
473
+ let preload = esmShPreloadCache.get(url);
474
+ if (!preload) {
475
+ const fetcher = options?.fetch ?? fetch;
476
+ // 3 attempts at 250ms exponential backoff spans ~750ms of retrying, which covers a typical esm.sh cold build
477
+ // without stalling first paint. There is no ack to wait on — esm.sh answers 5xx while the build is still running.
478
+ const attempts = options?.attempts ?? 3;
479
+ const retryDelayMs = options?.retryDelayMs ?? 250;
480
+ preload = (async () => {
481
+ let lastFailure: unknown;
482
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
483
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** (attempt - 1)));
484
+ try {
485
+ const response = await fetcher(url);
486
+ if (response.ok) return;
487
+ lastFailure = new Error(`HTTP ${response.status}`);
488
+ // esm.sh answers 5xx while a cold build is still running, and 429 when npm rate-limits it — both transient,
489
+ // same classification the probes below use. Any other 4xx is a decided answer (unknown package, rejected
490
+ // specifier); retrying burns the full backoff for the same result.
491
+ if (response.status < 500 && response.status !== 429) break;
492
+ } catch (error) {
493
+ lastFailure = error;
494
+ }
495
+ }
496
+ throw new Error(`Failed to preload esm.sh module ${url}`, { cause: lastFailure });
497
+ })();
498
+ preload.catch(() => esmShPreloadCache.delete(url));
499
+ esmShPreloadCache.set(url, preload);
500
+ }
501
+ return preload;
284
502
  }
285
503
 
286
504
  export type EsmShProbe = { alive: true } | { alive: false; detail: string };
@@ -310,6 +528,9 @@ export const probeEsmShSpecifier = (specifier: string, fetcher: EsmShProbeFetch
310
528
  probe = (async (): Promise<EsmShProbe | null> => {
311
529
  let url: string;
312
530
  try {
531
+ // Deliberately unranged: this is a package-existence check, and the cache is keyed by bare specifier. A
532
+ // package that exists unranged exists ranged, so the only gap is a declared range with no esm.sh build —
533
+ // probe says alive, render 404s. #1573 tracks threading ranges through if that ever bites.
313
534
  url = toEsmShImportUrl(specifier);
314
535
  } catch {
315
536
  return null;
@@ -419,14 +640,14 @@ export function packageImportMap(packageName: string, options?: { resolve?: (nam
419
640
 
420
641
  // esm.sh fallback only answers for `missing` specifiers, so the resolver can merge its output without re-checking
421
642
  // coverage. In dev, locally installed packages are preferred so peer deps like React stay on the preview's runtime.
422
- export function esmShFallback(options?: { external?: readonly string[]; hasLocalPackage?: (packageName: string) => Promise<boolean> }): ImportMapProvider {
643
+ export function esmShFallback(options?: { external?: readonly string[]; dev?: boolean; dependencyRanges?: EsmShDependencyRanges; hasLocalPackage?: (packageName: string) => Promise<boolean> }): ImportMapProvider {
423
644
  return {
424
645
  kind: "fallback",
425
646
  resolve: async ({ missing }) => {
426
647
  const entries = await Promise.all(
427
648
  missing.filter(canUseEsmShFallback).map(async (specifier) => {
428
649
  const isLocal = await options?.hasLocalPackage?.(getBarePackageName(specifier));
429
- return [specifier, isLocal ? `/node_modules/${specifier}` : toEsmShImportUrl(specifier, options?.external)] as const;
650
+ return [specifier, isLocal ? `/node_modules/${specifier}` : toEsmShImportUrl(specifier, options?.external, { dev: options?.dev, dependencyRanges: options?.dependencyRanges })] as const;
430
651
  }),
431
652
  );
432
653
  return { imports: Object.fromEntries(entries) };
@@ -448,7 +669,7 @@ export function createImportMapResolver(providers: ImportMapProvider[]) {
448
669
  const covered = new Set(Object.keys(imports));
449
670
  const missing = required.filter((specifier) => !hasImportMapEntry(covered, specifier));
450
671
  if (provider.kind === "fallback" && missing.length === 0) continue;
451
- const next = await provider.resolve({ code, missing });
672
+ const next = await provider.resolve({ code, missing, imports });
452
673
  imports = { ...imports, ...next.imports };
453
674
  // scopes is an advanced compat field with no producer today, so a later provider replaces an earlier one's
454
675
  // entries for the same scope key rather than deep-merging them. Revisit if two providers ever both emit scopes.
@@ -460,6 +681,6 @@ export function createImportMapResolver(providers: ImportMapProvider[]) {
460
681
  };
461
682
  }
462
683
 
463
- export async function mergeFallbackImports(imports: Record<string, string>, code: string, options?: { hasLocalPackage?: (packageName: string) => Promise<boolean> }) {
684
+ export async function mergeFallbackImports(imports: Record<string, string>, code: string, options?: { dependencyRanges?: EsmShDependencyRanges; hasLocalPackage?: (packageName: string) => Promise<boolean> }) {
464
685
  return (await createImportMapResolver([literalImportMap({ imports }), esmShFallback(options)]).resolve({ code })).imports;
465
686
  }
@@ -0,0 +1,65 @@
1
+ import { extractImportBindings } from "./importMap";
2
+
3
+ export type GenUIImportDiagnostic = { specifier: string; target: string; detail: string };
4
+
5
+ // Replaces the browser's opaque blob-module failure once the probe has named the imports that actually broke.
6
+ // `message` doubles as the heal signal fed back to the generating model, so it must stay self-contained.
7
+ export class GenUIImportError extends Error {
8
+ readonly diagnostics: GenUIImportDiagnostic[];
9
+ constructor(diagnostics: GenUIImportDiagnostic[], cause: unknown) {
10
+ super(`Unresolvable imports:\n${diagnostics.map(({ specifier, target, detail }) => `- '${specifier}'${target === specifier ? "" : ` (→ ${target})`} ${detail}`).join("\n")}`, { cause });
11
+ this.name = "GenUIImportError";
12
+ this.diagnostics = diagnostics;
13
+ }
14
+ }
15
+
16
+ // Cap the export listing so a barrel module with hundreds of exports can't bloat the heal message; enough for the
17
+ // model to spot the name it meant (e.g. `avataaars` vs `avataars`) and infer the rest. Mirrors MAX_HINT_SUBPATHS.
18
+ const MAX_EXPORT_HINTS = 20;
19
+ const listAvailableExports = (module: Record<string, unknown>) => {
20
+ const keys = Object.keys(module).toSorted();
21
+ if (!keys.length) return "";
22
+ return `; module exports: ${keys.slice(0, MAX_EXPORT_HINTS).join(", ")}${keys.length > MAX_EXPORT_HINTS ? `, … (${keys.length - MAX_EXPORT_HINTS} more)` : ""}`;
23
+ };
24
+
25
+ // Recover the bare specifier the model wrote from a compiled (import-map-rewritten) target. An exact entry wins; a
26
+ // trailing-slash prefix entry (`"lucide-react/"` → `".../lucide-react/"`) maps the whole subpath back, mirroring
27
+ // hasImportMapEntry's prefix semantics — so the heal signal names `lucide-react/icons/home`, not the resolved URL.
28
+ const reverseResolveSpecifier = (entries: [string, string][], target: string) => {
29
+ for (const [specifier, mapped] of entries) if (mapped === target) return specifier;
30
+ for (const [specifier, mapped] of entries) if (specifier.endsWith("/") && target.startsWith(mapped)) return specifier + target.slice(mapped.length);
31
+ return target;
32
+ };
33
+
34
+ // Re-imports each dependency of a failed compiled module one by one to name what the blanket blob error hid. Costs no
35
+ // extra network traffic: a dependency that linked is already in the module map, and a dependency that failed reuses the
36
+ // engine's own rejection rather than re-fetching it to explain. `imports` (the resolved importmap) maps rewritten
37
+ // targets back to the bare specifiers the model wrote.
38
+ export async function probeModuleImports(code: string, imports?: Record<string, string>): Promise<GenUIImportDiagnostic[]> {
39
+ const importEntries = Object.entries(imports ?? {});
40
+ const probes = [...extractImportBindings(code)].map(async ([target, names]): Promise<GenUIImportDiagnostic | null> => {
41
+ const specifier = reverseResolveSpecifier(importEntries, target);
42
+ let module: Record<string, unknown>;
43
+ try {
44
+ // `target` is import-map-resolved at runtime, so it needs the same three-bundler ignore stack as the blob import
45
+ // in runtime.ts — otherwise every probe throws `Cannot find module 'unknown'` and reports each dependency as
46
+ // broken, burying the one that actually is. #1688
47
+ module = (await import(/* @vite-ignore */ /* webpackIgnore: true */ /* turbopackIgnore: true */ target)) as Record<string, unknown>;
48
+ } catch (error) {
49
+ return { specifier, target, detail: `failed to load: ${error instanceof Error ? error.message : String(error)}` };
50
+ }
51
+ const missing = names.filter((name) => !(name in module));
52
+ return missing.length ? { specifier, target, detail: `has no export named ${missing.map((name) => `'${name}'`).join(", ")}${listAvailableExports(module)}` } : null;
53
+ });
54
+ return (await Promise.all(probes)).filter((diagnostic) => diagnostic !== null);
55
+ }
56
+
57
+ // The probe must never replace the real failure with one of its own — fall back to the original error on any surprise.
58
+ export async function enrichModuleImportError(error: unknown, code: string, imports?: Record<string, string>) {
59
+ try {
60
+ const diagnostics = await probeModuleImports(code, imports);
61
+ return diagnostics.length ? new GenUIImportError(diagnostics, error) : error;
62
+ } catch {
63
+ return error;
64
+ }
65
+ }
@@ -1,5 +1,148 @@
1
1
  import { createTsxCompiler, type TsxCompiler } from "./compiler";
2
- import { esmShFallback, extractModuleSpecifiers, isBareModuleSpecifier, isRuntimeSingletonSpecifier, type ImportMapProvider, type RendererImportMap } from "./importMap";
2
+ import {
3
+ esmShFallback,
4
+ extractImportMetaResolveSpecifiers,
5
+ extractImportSpecifiers,
6
+ extractModuleSpecifiers,
7
+ hasImportMapEntry,
8
+ isBareModuleSpecifier,
9
+ isLocalRootRelativeTarget,
10
+ isSharedInstancePackage,
11
+ prepareRendererImportMap,
12
+ resolveImportMapSpecifier,
13
+ type EsmShDependencyRanges,
14
+ type ImportMapProvider,
15
+ type RendererImportMap,
16
+ } from "./importMap";
17
+
18
+ type EsmShModuleResponse = Pick<Response, "ok" | "status" | "statusText" | "text"> & { headers: Pick<Headers, "get"> } & Partial<Pick<Response, "url">>;
19
+ export type EsmShModuleResolver = (url: string, imports: Readonly<Record<string, string>>) => Promise<string>;
20
+ type EsmShModuleResolverOptions = {
21
+ baseUrl?: string;
22
+ compiler?: TsxCompiler;
23
+ fetch?: (url: string) => Promise<EsmShModuleResponse>;
24
+ preload?: (url: string) => Promise<unknown> | unknown;
25
+ createModuleUrl?: (code: string) => string;
26
+ };
27
+
28
+ const importMapCacheKey = (entries: readonly (readonly string[])[]) => JSON.stringify(entries.toSorted(([a], [b]) => a.localeCompare(b)));
29
+
30
+ // esm.sh bundles still contain bare runtime imports when React/scheduler are externalized. Fetch the pinned bundle and
31
+ // run it through the same @esm.sh/tsx compiler as user code, so those imports become concrete host-module URLs before
32
+ // the browser sees the module. This keeps one transform-based resolver and removes the need for a page-level importmap.
33
+ export function createEsmShModuleResolver(options?: EsmShModuleResolverOptions): EsmShModuleResolver {
34
+ const compiler = options?.compiler ?? createTsxCompiler();
35
+ const fetcher = options?.fetch ?? (fetch as (url: string) => Promise<EsmShModuleResponse>);
36
+ const createModuleUrl = options?.createModuleUrl ?? ((code: string) => URL.createObjectURL(new Blob([code], { type: "text/javascript" })));
37
+ const moduleByKey = new Map<string, Promise<string>>();
38
+ const sourceByUrl = new Map<string, Promise<{ sourceUrl: string; source: string; specifiers: string[] }>>();
39
+ // Every specifier in one resolve pass gets the same `imports` object, and normalizing it is O(map) — which grows with
40
+ // each stream chunk. Key on object identity so the pass runs once per chunk instead of once per esm.sh package.
41
+ const normalizedImports = new WeakMap<Readonly<Record<string, string>>, Record<string, string>>();
42
+
43
+ const loadSource = (url: string) => {
44
+ let pending = sourceByUrl.get(url);
45
+ if (!pending) {
46
+ pending = (async () => {
47
+ await options?.preload?.(url);
48
+ const entry = await fetcher(url);
49
+ if (!entry.ok) throw new Error(`Failed to fetch esm.sh module ${url}: ${entry.status} ${entry.statusText}`);
50
+ // esm.sh answers an entry URL with a re-export shim, not the bundle itself, so `x-esm-path` is the only way to
51
+ // reach the source worth compiling. Falling back to the shim would compile four `export *` lines and leave the
52
+ // real bundle's bare `react` import to the browser, which has no import map to resolve it — fail loudly instead.
53
+ const pinnedPath = entry.headers.get("x-esm-path");
54
+ if (!pinnedPath) throw new Error(`esm.sh returned no x-esm-path for ${url}; cannot locate the pinned bundle to compile`);
55
+ const sourceUrl = new URL(pinnedPath, url).href;
56
+ const sourceResponse = await fetcher(sourceUrl);
57
+ if (!sourceResponse.ok) throw new Error(`Failed to fetch esm.sh module ${sourceUrl}: ${sourceResponse.status} ${sourceResponse.statusText}`);
58
+ // Scan the bundle's specifiers once here, not per resolve — these bundles run to hundreds of KB.
59
+ const source = await sourceResponse.text();
60
+ return { sourceUrl, source, specifiers: [...extractModuleSpecifiers(source)] };
61
+ })();
62
+ pending.catch(() => sourceByUrl.delete(url));
63
+ sourceByUrl.set(url, pending);
64
+ }
65
+ return pending;
66
+ };
67
+
68
+ return async (url, imports) => {
69
+ const { sourceUrl, source, specifiers } = await loadSource(url);
70
+ // A blob: module cannot resolve `/src/...` relative to itself. Normalize the host map before compiling so the
71
+ // transform emits absolute page URLs and the resulting module is independent of browser-native import maps.
72
+ let compilerImports = normalizedImports.get(imports);
73
+ if (!compilerImports) normalizedImports.set(imports, (compilerImports = prepareRendererImportMap({ imports }, options?.baseUrl).imports));
74
+ // Key on the entries this bundle actually links, not the whole map: the map grows with every stream chunk, and a
75
+ // whole-map key would recompile the same bundle into a fresh blob: for each unrelated addition — re-running its
76
+ // side effects (`customElements.define` in @number-flow/react throws on the second module object).
77
+ const linked = specifiers.flatMap((specifier) => {
78
+ const target = resolveImportMapSpecifier(compilerImports, specifier);
79
+ return target ? [[specifier, target]] : [];
80
+ });
81
+ const key = `${url}\n${importMapCacheKey(linked)}`;
82
+ let moduleUrl = moduleByKey.get(key);
83
+ if (!moduleUrl) {
84
+ moduleUrl = compiler.compile(source, { filename: sourceUrl, lang: "js", importMap: { imports: compilerImports } }).then(({ code }) => createModuleUrl(code));
85
+ moduleUrl.catch(() => moduleByKey.delete(key));
86
+ moduleByKey.set(key, moduleUrl);
87
+ }
88
+ return moduleUrl;
89
+ };
90
+ }
91
+
92
+ export function transformedEsmShFallback(options?: {
93
+ dev?: boolean;
94
+ dependencyRanges?: EsmShDependencyRanges;
95
+ baseUrl?: string;
96
+ hasLocalPackage?: (packageName: string) => Promise<boolean>;
97
+ resolveEsmSh?: EsmShModuleResolver;
98
+ compiler?: TsxCompiler;
99
+ fetch?: (url: string) => Promise<EsmShModuleResponse>;
100
+ preload?: (url: string) => Promise<unknown> | unknown;
101
+ }): ImportMapProvider {
102
+ const resolveEsmSh = options?.resolveEsmSh ?? createEsmShModuleResolver({ compiler: options?.compiler, fetch: options?.fetch, preload: options?.preload, baseUrl: options?.baseUrl });
103
+ // Decorate the plain fallback rather than restate it: the specifier filter, the local-package preference and the
104
+ // 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
+ // A failed compile evicts its cache entry so a later render can retry, which means every stream chunk re-attempts and
107
+ // would re-warn — hundreds of identical lines burying the first, actually diagnostic one. Warn once per target.
108
+ const warned = new Set<string>();
109
+ return {
110
+ kind: "fallback",
111
+ resolve: async ({ code, missing, imports = {} }) => {
112
+ const metaResolveSpecifiers = extractImportMetaResolveSpecifiers(code);
113
+ const importedSpecifiers = extractImportSpecifiers(code);
114
+ const entries = await Promise.all(
115
+ Object.entries((await base.resolve({ code, missing, imports })).imports ?? {}).map(async ([specifier, target]) => {
116
+ if (isLocalRootRelativeTarget(target)) return [specifier, target] as const;
117
+ // Two independent reasons to use the target as-is rather than compile it to a blob: an asset reached only
118
+ // through import.meta.resolve() is a value, not a JS module; a shared-instance package must keep its
119
+ // canonical esm.sh URL so a dependent's bundle importing that same URL gets the same module object. Either
120
+ // way the URL is only warmed, then the compiler rewrites the reference straight to it — and a failed warm
121
+ // changes nothing about what we return, so it stays outside the compile fallback below.
122
+ const isAssetOnly = metaResolveSpecifiers.has(specifier) && !importedSpecifiers.has(specifier);
123
+ if (isSharedInstancePackage(specifier) || isAssetOnly) {
124
+ await Promise.resolve(options?.preload?.(target)).catch(() => {});
125
+ return [specifier, target] as const;
126
+ }
127
+ try {
128
+ return [specifier, await resolveEsmSh(target, imports)] as const;
129
+ } catch (error) {
130
+ // Dropping the entry would make this specifier permanently `missing`, so every stream chunk re-resolves and
131
+ // re-fetches it, and the heal message loses the `(→ https://esm.sh/…)` part that reverse-resolves from the
132
+ // import map. Fall back to the uncompiled target: the browser still fails on it, but with a diagnosable URL.
133
+ if (!warned.has(target)) {
134
+ warned.add(target);
135
+ console.warn(`Falling back to the uncompiled esm.sh target for ${specifier} (${target})`, error);
136
+ }
137
+
138
+ return [specifier, target] as const;
139
+ }
140
+ }),
141
+ );
142
+ return { imports: Object.fromEntries(entries) };
143
+ },
144
+ };
145
+ }
3
146
 
4
147
  // A remote https module worth compiling is one that ships source the browser can't `import` directly. Already-JS URLs
5
148
  // (esm.sh builds, .mjs) import natively, so they pass through; only these source extensions need a transpile pass.
@@ -19,15 +162,18 @@ const langFromRemoteUrl = (url: string): "ts" | "tsx" | "jsx" => {
19
162
  // Fetches remote https TSX/TS/JSX components, compiles each to a blob: module, and hands back `{ [httpsUrl]: blobUrl }`
20
163
  // so the transform's importMap rewrites the original specifiers. Recurses into a remote module's own remote imports
21
164
  // (a global import graph breaks cycles even across concurrent entry branches; a per-provider cache dedupes DAGs and
22
- // survives across renders). React runtime singletons stay bare and resolve through the page's importmap, exactly like
23
- // the esm.sh fallback path.
24
- export function remoteModuleImportMap(options?: { compiler?: TsxCompiler; fetch?: typeof fetch }): ImportMapProvider {
165
+ // survives across renders). Every bare import is rewritten by @esm.sh/tsx: host-owned packages use the mappings already
166
+ // resolved by earlier providers, and unknown packages use transformed esm.sh bundles.
167
+ export function remoteModuleImportMap(options?: { baseUrl?: string; compiler?: TsxCompiler; fetch?: typeof fetch; dev?: boolean; dependencyRanges?: EsmShDependencyRanges; preloadEsmSh?: (url: string) => Promise<unknown> | unknown; resolveEsmSh?: EsmShModuleResolver }): ImportMapProvider {
25
168
  const compiler = options?.compiler ?? createTsxCompiler();
26
169
  const fetcher = options?.fetch ?? fetch;
27
170
  const blobByUrl = new Map<string, Promise<string>>();
28
- // No `hasLocalPackage`: a remote module's own bare deps deliberately don't bind to the host's local /node_modules —
29
- // third-party remote code resolves through esm.sh in every env, unlike the top-level chain's dev local-package preference.
30
- const esmSh = esmShFallback();
171
+ // No `hasLocalPackage`: a bare dep the host map doesn't already own is never probed against the host's local
172
+ // /node_modules — it goes to esm.sh in every env, unlike the top-level chain's dev local-package preference. (Deps
173
+ // the host map DOES own bind to its targets, so one copy is shared instead of a second esm.sh build.)
174
+ // `dev` must match the top-level chain's: a remote module's esm.sh dep bundles its own reconciler, and a production
175
+ // one mixed with the page's dev jsx-runtime hits the missing-`getOwner` dispatcher.
176
+ const esmSh = transformedEsmShFallback({ dev: options?.dev, dependencyRanges: options?.dependencyRanges, baseUrl: options?.baseUrl, preload: options?.preloadEsmSh, resolveEsmSh: options?.resolveEsmSh, compiler, fetch: fetcher as unknown as (url: string) => Promise<EsmShModuleResponse> });
31
177
 
32
178
  // module → the remote modules it directly imports, recorded synchronously as each edge is discovered. A per-branch
33
179
  // `visited` set can't catch a cycle reached through two separate entry branches (a↔b where the entry imports both) —
@@ -48,15 +194,20 @@ export function remoteModuleImportMap(options?: { compiler?: TsxCompiler; fetch?
48
194
  return false;
49
195
  };
50
196
 
197
+ // The host map is identical for every node in one graph walk, so `key` (its `importMapCacheKey`) is computed once per
198
+ // resolve() and threaded down with it — re-serializing per module would be O(nodes × map size).
199
+ type BaseMap = { imports: Readonly<Record<string, string>>; key: string };
200
+
51
201
  // Resolve a remote module's own imports to a self-contained import map, keyed by ABSOLUTE URL so the transform (given
52
202
  // the module's URL as `filename`) matches them after rebasing relatives: nested remote source → blob, bare npm dep →
53
- // esm.sh. Relative browser-native deps (./x.mjs, ./x.css) need no entry — the transform rebases them to absolute URLs
54
- // against `filename` on its own. Runtime singletons stay bare for the page importmap; esm.sh builds pass through.
55
- // `identityUrl` is how importers reference this module (its request URL — the key for blobByUrl, the cycle graph, and
56
- // eviction). `rebaseUrl` is the post-redirect `response.url`, used ONLY to resolve the module's relative imports. They
57
- // must stay separate: keying edges by `rebaseUrl` would disconnect the graph at every redirect (a redirected node's
58
- // own edges land under its final URL while every importer refers to it by its request URL → missed cross-branch cycle).
59
- const collect = async (code: string, identityUrl: string, rebaseUrl: string): Promise<Record<string, string>> => {
203
+ // transformed esm.sh blobs. Relative browser-native deps (./x.mjs, ./x.css) need no entry — the transform rebases
204
+ // them to absolute URLs against `filename` on its own. Host runtime entries are passed through to the compiler too.
205
+ // `identityUrl` is how importers reference this module (its request URL — the cycle graph's key, and the URL half of
206
+ // the blobByUrl key). `rebaseUrl` is the post-redirect `response.url`, used ONLY to resolve the module's relative
207
+ // imports. They must stay separate: keying edges by `rebaseUrl` would disconnect the graph at every redirect (a
208
+ // redirected node's own edges land under its final URL while every importer refers to it by its request URL → missed
209
+ // cross-branch cycle).
210
+ const collect = async (code: string, identityUrl: string, rebaseUrl: string, base: BaseMap): Promise<Record<string, string>> => {
60
211
  const specifiers = [...extractModuleSpecifiers(code)];
61
212
  // Two passes on purpose. Pass 1 records every edge and checks cycles synchronously (no await) so a concurrent sibling
62
213
  // branch sees the edges — but it starts NO async work, so a mid-loop cyclic throw can't strand an already-launched
@@ -69,7 +220,7 @@ export function remoteModuleImportMap(options?: { compiler?: TsxCompiler; fetch?
69
220
  // source extension (`@acme/ui/Button.tsx`, `pkg/foo.ts`) must stay bare for esm.sh — rebasing it first would turn
70
221
  // it into a bogus `https://…/pkg/foo.ts` remote fetch that 404s before the fallback can map the package.
71
222
  if (isBareModuleSpecifier(specifier)) {
72
- if (!isRuntimeSingletonSpecifier(specifier)) bareMissing.push(specifier);
223
+ if (!hasImportMapEntry(base.imports, specifier)) bareMissing.push(specifier);
73
224
  continue;
74
225
  }
75
226
  const absolute = new URL(specifier, rebaseUrl).href;
@@ -78,12 +229,13 @@ export function remoteModuleImportMap(options?: { compiler?: TsxCompiler; fetch?
78
229
  if (reaches(absolute, identityUrl)) throw new Error(`Cyclic remote module import: ${absolute}`);
79
230
  deps.add(absolute);
80
231
  }
81
- const [remote, bare] = await Promise.all([Promise.all([...deps].map((absolute) => resolveModule(absolute).then((blob) => [absolute, blob] as const))), esmSh.resolve({ code, missing: bareMissing })]);
82
- return { ...bare.imports, ...Object.fromEntries(remote) };
232
+ const [remote, bare] = await Promise.all([Promise.all([...deps].map((absolute) => resolveModule(absolute, base).then((blob) => [absolute, blob] as const))), esmSh.resolve({ code, missing: bareMissing, imports: base.imports })]);
233
+ return { ...base.imports, ...bare.imports, ...Object.fromEntries(remote) };
83
234
  };
84
235
 
85
- const resolveModule = (url: string): Promise<string> => {
86
- let blob = blobByUrl.get(url);
236
+ const resolveModule = (url: string, base: BaseMap): Promise<string> => {
237
+ const cacheKey = `${url}\n${base.key}`;
238
+ let blob = blobByUrl.get(cacheKey);
87
239
  if (!blob) {
88
240
  blob = (async () => {
89
241
  const response = await fetcher(url);
@@ -92,7 +244,7 @@ export function remoteModuleImportMap(options?: { compiler?: TsxCompiler; fetch?
92
244
  // A CDN/raw endpoint may redirect to a versioned/storage URL; rebase the module's relative imports against the
93
245
  // final `response.url` (falling back to the request URL) so `./Leaf.tsx` resolves from the right directory.
94
246
  const finalUrl = response.url || url;
95
- const imports = await collect(source, url, finalUrl);
247
+ const imports = await collect(source, url, finalUrl, base);
96
248
  // filename = the module's own URL so the transform rebases its relative imports against it (matching the
97
249
  // absolute-keyed entries above, and turning relative browser-native imports into absolute URLs natively).
98
250
  const { code } = await compiler.compile(source, { filename: finalUrl, lang: langFromRemoteUrl(url), importMap: { imports } });
@@ -101,10 +253,10 @@ export function remoteModuleImportMap(options?: { compiler?: TsxCompiler; fetch?
101
253
  // A failed fetch/compile must not pin this URL for the provider's lifetime — evict the rejected promise so a
102
254
  // later render retries, mirroring probeEsmShSpecifier's fail-open eviction.
103
255
  blob.catch(() => {
104
- blobByUrl.delete(url);
256
+ blobByUrl.delete(cacheKey);
105
257
  importsOf.delete(url);
106
258
  });
107
- blobByUrl.set(url, blob);
259
+ blobByUrl.set(cacheKey, blob);
108
260
  }
109
261
  return blob;
110
262
  };
@@ -112,8 +264,10 @@ export function remoteModuleImportMap(options?: { compiler?: TsxCompiler; fetch?
112
264
  // The entry code isn't fetched or compiled here — the outer resolver chain compiles it and maps its bare specifiers,
113
265
  // so this provider only surfaces its remote-source imports as blob: entries (keyed by their already-absolute URL).
114
266
  return {
115
- resolve: async ({ code }): Promise<RendererImportMap> => {
116
- const entries = await Promise.all([...extractModuleSpecifiers(code)].flatMap((specifier) => (isRemoteSourceModuleSpecifier(specifier) ? [resolveModule(specifier).then((blob) => [specifier, blob] as const)] : [])));
267
+ resolve: async ({ code, imports = {} }): Promise<RendererImportMap> => {
268
+ const compilerImports = prepareRendererImportMap({ imports }, options?.baseUrl).imports;
269
+ const base: BaseMap = { imports: compilerImports, key: importMapCacheKey(Object.entries(compilerImports)) };
270
+ const entries = await Promise.all([...extractModuleSpecifiers(code)].flatMap((specifier) => (isRemoteSourceModuleSpecifier(specifier) ? [resolveModule(specifier, base).then((blob) => [specifier, blob] as const)] : [])));
117
271
  return { imports: Object.fromEntries(entries) };
118
272
  },
119
273
  };
package/src/runtime.ts CHANGED
@@ -3,6 +3,8 @@ import { createRoot, type Root } from "react-dom/client";
3
3
  import { renderToString } from "react-dom/server";
4
4
  import { createGeneratedComponentSlot, type GeneratedComponent, type GeneratedComponentSlot } from "./state";
5
5
  export type { GeneratedComponent } from "./state";
6
+ import { enrichModuleImportError } from "./importProbe";
7
+ export { GenUIImportError, type GenUIImportDiagnostic } from "./importProbe";
6
8
  import { createTsxCompiler, type TsxCompiler, type CompileResult } from "./compiler";
7
9
  import { GenUIRenderContext, getGenUIRenderContextValue } from "./renderContext";
8
10
  import { getGenUIHookSignature } from "./hookSignature";
@@ -12,7 +14,9 @@ export type GenUIRenderPhase = "transform" | "compile" | "render";
12
14
  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
15
  export type GenUIRendererClearOptions = { preserveVisualState?: boolean };
14
16
  export type GenUIRendererFlushMode = "microtask" | "immediate";
15
- export type GenUIRendererOptions = { importmap?: RendererImportMap; callbacks?: GenUIRendererCallbacks; compiler?: TsxCompiler; preserveStateOnUpdate?: boolean; flushMode?: GenUIRendererFlushMode };
17
+ // `filename` is the base @esm.sh/tsx resolves relative specifiers against. Hosts that render a file from disk pass its
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 };
16
20
  export type GenUIRendererUpdateMode = "push" | "render";
17
21
 
18
22
  const attachedRenderer = Symbol.for("@macaron/genui-runtime:attached-renderer");
@@ -32,22 +36,27 @@ const rememberCompiledComponent = (code: string, component: GeneratedComponent)
32
36
  }
33
37
  };
34
38
 
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
+ type RuntimeErrorBoundaryState = { hasError: boolean; message?: string; lastResetKey?: number };
40
+
41
+ class RuntimeErrorBoundary extends Component<{ resetKey?: number; onError?: (error: Error) => void; children?: ReactNode }, RuntimeErrorBoundaryState> {
42
+ state: RuntimeErrorBoundaryState = { hasError: false };
43
+ // React forwards whatever was thrown, including primitives and null; normalize before reading `.message`.
44
+ static getDerivedStateFromError(error: unknown) {
45
+ return { hasError: true, message: normalizeError(error).message };
39
46
  }
40
- static getDerivedStateFromProps(props: { resetKey?: number }, state: { hasError: boolean; lastResetKey?: number }) {
47
+ static getDerivedStateFromProps(props: { resetKey?: number }, state: RuntimeErrorBoundaryState) {
41
48
  // Streaming errors bump resetKey to clear the caught state in place; recreating the boundary via `key`
42
49
  // 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 };
50
+ if (props.resetKey !== state.lastResetKey) return { hasError: false, message: undefined, lastResetKey: props.resetKey };
44
51
  return null;
45
52
  }
46
- componentDidCatch(error: Error) {
47
- this.props.onError?.(error);
53
+ componentDidCatch(error: unknown) {
54
+ this.props.onError?.(normalizeError(error));
48
55
  }
49
56
  render() {
50
- if (this.state.hasError) return "ERROR";
57
+ // Hosts that pass onError paint their own error UI over this; hosts that don't (few-shots, batch) used to be
58
+ // left with a bare "ERROR" and nothing to diagnose from.
59
+ if (this.state.hasError) return this.state.message ? `ERROR: ${this.state.message}` : "ERROR";
51
60
  return this.props.children;
52
61
  }
53
62
  }
@@ -80,6 +89,7 @@ export class GenUIRenderer {
80
89
  private finished = false;
81
90
  private flushMode: GenUIRendererFlushMode;
82
91
  private importmap?: RendererImportMap;
92
+ private filename?: string;
83
93
  private lastCompiledCode = "";
84
94
  private lastGoodHookSignature = "";
85
95
  private lastHookSignature: string | null = null;
@@ -98,6 +108,7 @@ export class GenUIRenderer {
98
108
  this.compiler = options.compiler ?? createTsxCompiler();
99
109
  this.flushMode = options.flushMode ?? "microtask";
100
110
  this.importmap = options.importmap;
111
+ this.filename = options.filename;
101
112
  this.preserveStateOnUpdate = options.preserveStateOnUpdate ?? true;
102
113
  this.rendererScope = createRendererScope();
103
114
  this.slot = createGeneratedComponentSlot();
@@ -290,7 +301,7 @@ export class GenUIRenderer {
290
301
  const code = this.currentBuffer;
291
302
  let result: CompileResult;
292
303
  try {
293
- result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode });
304
+ result = await this.compiler.compile(code, { importMap: this.importmap, partial: partial && !this.finished, previousCode: this.lastCompiledCode, filename: this.filename });
294
305
  } catch (error) {
295
306
  // A queued follow-up means this frame is already superseded; its error is stale noise (pre-single-flight, the
296
307
  // newer compile's serial bump suppressed it the same way).
@@ -333,7 +344,19 @@ export class GenUIRenderer {
333
344
  if (cached) return { component: cached };
334
345
  const moduleUrl = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
335
346
  try {
336
- const module = (await import(/* @vite-ignore */ moduleUrl)) as { default?: GeneratedComponent };
347
+ let module: { default?: GeneratedComponent };
348
+ try {
349
+ // The specifier is a runtime-built blob URL, so every bundler must leave it alone: @vite-ignore for Vite,
350
+ // webpackIgnore for webpack/Rspack, turbopackIgnore for Turbopack. Dropping any of them makes the host bundler
351
+ // resolve it at build time and the renderer dies with `Cannot find module 'unknown'`. #1688
352
+ module = (await import(/* @vite-ignore */ /* webpackIgnore: true */ /* turbopackIgnore: true */ moduleUrl)) as { default?: GeneratedComponent };
353
+ } catch (error) {
354
+ // The browser reports a failed blob module as one opaque "Failed to fetch dynamically imported module" without
355
+ // naming the entry that broke; re-probe each dependency to raise an actionable, heal-ready error instead. #1371
356
+ throw await enrichModuleImportError(error, code, this.importmap?.imports);
357
+ }
358
+ // A missing default is a transient "stream not finished yet" frame, not a dependency failure: the blob import
359
+ // resolved, so every dependency already linked, and probing would re-scan cached modules to no effect.
337
360
  if (!module.default) throw new Error("No default export found in compiled module.");
338
361
  rememberCompiledComponent(code, module.default);
339
362
  return { component: module.default, moduleUrl };