partial-react 0.0.5 → 0.0.7

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/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.5",
4
+ "version": "0.0.7",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -15,13 +15,16 @@
15
15
  "./remote-module": "./src/remoteModule.ts",
16
16
  "./render-context": "./src/renderContext.ts"
17
17
  },
18
+ "browser": {
19
+ "./src/nodeWasm.ts": false
20
+ },
18
21
  "files": [
19
22
  "src/**/*",
20
23
  "!src/**/*.test.*"
21
24
  ],
22
25
  "dependencies": {
23
26
  "@esm.sh/tsx": "^1.5.3",
24
- "partial-tsx": "0.0.3"
27
+ "partial-tsx": "0.0.4"
25
28
  },
26
29
  "peerDependencies": {
27
30
  "react": "^18.3.0 || ^19.0.0",
package/src/compiler.ts CHANGED
@@ -7,16 +7,10 @@ export type CompileResult = { code: string; source: string; changed: boolean };
7
7
  export type TsxCompiler = { compile: (code: string, options?: CompileOptions) => Promise<CompileResult> };
8
8
 
9
9
  let initPromise: Promise<unknown> | null = null;
10
- const loadNodeWasm = async () => {
11
- // `import.meta.resolve` yields a `file://` URL under both Bun and Node/Vite SSR, so `new URL` is safe — don't "fix" it with pathToFileURL.
12
- const wasmUrl = new URL(import.meta.resolve("@esm.sh/tsx/pkg/tsx_bg.wasm"));
13
- if (typeof Bun !== "undefined") return Bun.file(wasmUrl).arrayBuffer();
14
- return (await import("node:fs/promises")).readFile(wasmUrl);
15
- };
16
10
  const initCompiler = () =>
17
11
  (initPromise ??= (async () => {
18
12
  try {
19
- if (typeof window === "undefined") return initTsx(await loadNodeWasm());
13
+ if (typeof window === "undefined") return initTsx(await (await import("./nodeWasm")).loadNodeWasm());
20
14
  } catch {
21
15
  // Environments without a filesystem (e.g. web workers) fall back to the bundled asset below.
22
16
  }
package/src/importMap.ts CHANGED
@@ -13,9 +13,30 @@ export type RuntimeSingletonSpecifier = (typeof RUNTIME_SINGLETON_SPECIFIERS)[nu
13
13
  export const isRuntimeSingletonSpecifier = (specifier: string): specifier is RuntimeSingletonSpecifier => RUNTIME_SINGLETON_SPECIFIERS.includes(specifier as RuntimeSingletonSpecifier);
14
14
  // A trailing-slash import-map key also owns matching singleton subpaths.
15
15
  export const isRuntimeSingletonImportMapKey = (specifier: string) => isRuntimeSingletonSpecifier(specifier) || (specifier.endsWith("/") && RUNTIME_SINGLETON_SPECIFIERS.some((runtimeSpecifier) => runtimeSpecifier.startsWith(specifier)));
16
- const IDENTIFIER_CHAR_PATTERN = /[\w$]/;
17
- const REGEX_PREFIX_WORDS = new Set(["return", "throw", "case", "delete", "typeof", "void", "new", "in", "of", "await", "yield"]);
18
- const LOCAL_EXPORT_KEYWORDS = ["abstract", "async", "class", "const", "declare", "default", "enum", "function", "interface", "let", "namespace", "var"];
16
+ const IDENTIFIER_START_PATTERN = /[$_\p{ID_Start}]/u;
17
+ const IDENTIFIER_PART_PATTERN = /[$_\u200C\u200D\p{ID_Continue}]/u;
18
+ // Composed from the two above so widening either also widens JSX tag names; `.`/`:`/`-` extend the part set for member,
19
+ // namespaced and dashed tags. The empty alternative matches a fragment (`<>`).
20
+ const JSX_TAG_START_PATTERN = new RegExp(`<(/\\s*)?(${IDENTIFIER_START_PATTERN.source}(?:${IDENTIFIER_PART_PATTERN.source}|[.:-])*|(?=>))(?=[\\s/><])`, "uy");
21
+ const STRING_ESCAPE_PATTERN = /\\(?:x([\da-fA-F]{2})|u\{([\da-fA-F]+)\}|u([\da-fA-F]{4}))/y;
22
+ // Every escape the pattern above doesn't cover denotes the bare character (`\/` → `/`, `\\` → `\`) except these: the
23
+ // named control escapes, and a `\`-LineTerminator line continuation, which denotes nothing. Callers match this value
24
+ // against import-map keys, so returning the source spelling instead yields a third string that is neither the source
25
+ // nor what the runtime resolves — it matches no key, and `rewriteImportSpecifiers` leaves the specifier unrewritten.
26
+ // The escapes that are a syntax error in a module (`\1`-`\9`, `\0` before a digit, a malformed `\x`/`\u`) get no case:
27
+ // such a module never loads, so whatever they decode to names no reachable key. U+2028/U+2029 are spelled as escapes
28
+ // because raw they are invisible.
29
+ const STRING_ESCAPE_VALUES: Record<string, string> = { b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "0": "\0", "\n": "", "\r": "", "\u2028": "", "\u2029": "" };
30
+ // An escaped identifier character names one code point, so an astral name is spelled `\u{10400}`, never as the two
31
+ // escaped surrogate halves a string literal would pair — no pairing branch belongs here. A lone half is no ID_Start, so
32
+ // the scan stops and the clause falls back to the specifier alone, costing nothing: V8 and tsgo both reject that spelling.
33
+ const IDENTIFIER_ESCAPE_PATTERN = /\\u(?:\{([\da-fA-F]+)\}|([\da-fA-F]{4}))/y;
34
+ // `$` `_` `A-Z` `a-z` as code points (`0-9` joins them for continues, below): an ASCII fast path past
35
+ // IDENTIFIER_START_PATTERN/IDENTIFIER_PART_PATTERN, which answer the same but cost a regex exec per character. Widen
36
+ // alongside them.
37
+ const isAsciiIdentifierStart = (value: number) => value === 36 || value === 95 || (value >= 65 && value <= 90) || (value >= 97 && value <= 122);
38
+ const isIdentifierCodePoint = (value: number, first: boolean) => (value <= 0x7f ? isAsciiIdentifierStart(value) || (!first && value >= 48 && value <= 57) : (first ? IDENTIFIER_START_PATTERN : IDENTIFIER_PART_PATTERN).test(String.fromCodePoint(value)));
39
+ const EXPRESSION_PREFIX_WORDS = new Set(["return", "throw", "case", "delete", "typeof", "void", "new", "in", "of", "await", "yield", "default", "else", "do"]);
19
40
 
20
41
  export const isBareModuleSpecifier = (specifier: string) => !specifier.startsWith(".") && !specifier.startsWith("/") && !/^(?:[a-z][a-z\d+.-]*:|#)/i.test(specifier);
21
42
  export const getBarePackageName = (specifier: string) => (specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0] || specifier);
@@ -28,7 +49,7 @@ export const isSharedInstancePackage = (specifier: string) => SHARED_INSTANCE_PA
28
49
  export const isLocalRootRelativeTarget = (target: string) => target.startsWith("/src/") || target.startsWith("/node_modules/");
29
50
  const canUseEsmShFallback = (specifier: string) => isBareModuleSpecifier(specifier) && !specifier.startsWith("@/");
30
51
 
31
- const isKeywordAt = (code: string, index: number, keyword: string) => code.startsWith(keyword, index) && !IDENTIFIER_CHAR_PATTERN.test(code[index - 1] ?? "") && !IDENTIFIER_CHAR_PATTERN.test(code[index + keyword.length] ?? "");
52
+ const isKeywordAt = (code: string, index: number, keyword: string) => code.startsWith(keyword, index) && !IDENTIFIER_PART_PATTERN.test(code[index - 1] ?? "") && !IDENTIFIER_PART_PATTERN.test(code[index + keyword.length] ?? "");
32
53
 
33
54
  const skipLineComment = (code: string, index: number) => {
34
55
  const newlineIndex = code.indexOf("\n", index + 2);
@@ -40,18 +61,34 @@ const skipBlockComment = (code: string, index: number) => {
40
61
  return closeIndex === -1 ? code.length : closeIndex + 2;
41
62
  };
42
63
 
64
+ // Callers must have verified `/` at `index`; the second char picks the terminator.
65
+ const skipComment = (code: string, index: number) => (code[index + 1] === "/" ? skipLineComment(code, index) : skipBlockComment(code, index));
66
+
67
+ const skipScannedComment = (code: string, index: number, commentStarts: Map<number, number>) => {
68
+ const end = skipComment(code, index);
69
+ commentStarts.set(end, index);
70
+ return end;
71
+ };
72
+
73
+ // Only rewind over comments the forward scan observed: searching backwards for `/*` can land inside comment text.
74
+ const previousCodeIndex = (code: string, end: number, commentStarts: ReadonlyMap<number, number>) => {
75
+ while (end > 0) {
76
+ const commentStart = commentStarts.get(end);
77
+ if (commentStart !== undefined) end = commentStart;
78
+ else if (/\s/.test(code[end - 1])) end -= 1;
79
+ else break;
80
+ }
81
+ return end - 1;
82
+ };
83
+
43
84
  const skipWhitespaceAndComments = (code: string, index: number) => {
44
85
  while (index < code.length) {
45
86
  if (/\s/.test(code[index])) {
46
87
  index += 1;
47
88
  continue;
48
89
  }
49
- if (code[index] === "/" && code[index + 1] === "/") {
50
- index = skipLineComment(code, index);
51
- continue;
52
- }
53
- if (code[index] === "/" && code[index + 1] === "*") {
54
- index = skipBlockComment(code, index);
90
+ if (code[index] === "/" && (code[index + 1] === "/" || code[index + 1] === "*")) {
91
+ index = skipComment(code, index);
55
92
  continue;
56
93
  }
57
94
  break;
@@ -67,8 +104,27 @@ const readStringLiteral = (code: string, index: number) => {
67
104
  while (index < code.length) {
68
105
  const char = code[index];
69
106
  if (char === "\\") {
70
- value += code.slice(index, index + 2);
71
- index += 2;
107
+ // Mirrors STRING_ESCAPE_PATTERN's alternation heads: skipping exec on the common escapes (`\n`, `\"`, `\\`) is
108
+ // ~1.6x faster on escape-dense literals. Widen this when widening the pattern.
109
+ const next = code[index + 1];
110
+ let escape: RegExpExecArray | null = null;
111
+ if (next === "x" || next === "u") {
112
+ STRING_ESCAPE_PATTERN.lastIndex = index;
113
+ escape = STRING_ESCAPE_PATTERN.exec(code);
114
+ }
115
+ if (escape) {
116
+ const point = Number.parseInt(escape[1] ?? escape[2] ?? escape[3], 16);
117
+ if (point <= 0x10ffff) {
118
+ value += String.fromCodePoint(point);
119
+ index += escape[0].length;
120
+ continue;
121
+ }
122
+ }
123
+ // A lone trailing backslash has no next character to stand for, and `?? next` would append `undefined` verbatim.
124
+ if (next === undefined) break;
125
+ value += STRING_ESCAPE_VALUES[next] ?? next;
126
+ // `\` + CRLF is one line continuation, not a continuation followed by a stray `\n`.
127
+ index += next === "\r" && code[index + 2] === "\n" ? 3 : 2;
72
128
  continue;
73
129
  }
74
130
  if (char === quote) return { value, end: index + 1, terminated: true };
@@ -80,7 +136,9 @@ const readStringLiteral = (code: string, index: number) => {
80
136
  return { value, end: code.length, terminated: false };
81
137
  };
82
138
 
83
- const skipTemplateLiteral = (code: string, index: number, onExpression?: (start: number, end: number) => void) => {
139
+ type JsxScanCache = Map<number, { end: number; expressions: [number, number][] }>;
140
+
141
+ const skipTemplateLiteral = (code: string, index: number, jsxScanCache: JsxScanCache, onExpression?: (start: number, end: number) => void) => {
84
142
  index += 1;
85
143
  while (index < code.length) {
86
144
  if (code[index] === "\\") {
@@ -90,7 +148,7 @@ const skipTemplateLiteral = (code: string, index: number, onExpression?: (start:
90
148
  if (code[index] === "`") return index + 1;
91
149
  if (code[index] === "$" && code[index + 1] === "{") {
92
150
  const expressionStart = index + 2;
93
- const expressionEnd = findBalancedBlockEnd(code, index + 1, "{", "}");
151
+ const expressionEnd = findBalancedBlockEnd(code, index + 1, "{", "}", jsxScanCache);
94
152
  onExpression?.(expressionStart, Math.max(expressionStart, expressionEnd - 1));
95
153
  index = expressionEnd;
96
154
  continue;
@@ -100,19 +158,25 @@ const skipTemplateLiteral = (code: string, index: number, onExpression?: (start:
100
158
  return code.length;
101
159
  };
102
160
 
103
- const findBalancedBlockEnd = (code: string, index: number, opener: string, closer: string) => {
161
+ const findBalancedBlockEnd = (code: string, index: number, opener: string, closer: string, jsxScanCache: JsxScanCache) => {
104
162
  let depth = 0;
163
+ const commentStarts = new Map<number, number>();
164
+ const openParens: number[] = [];
165
+ let closedParen: number | undefined;
105
166
  while (index < code.length) {
106
167
  const char = code[index];
107
- if (char === "/" && code[index + 1] === "/") {
108
- index = skipLineComment(code, index);
168
+ if (char === "/" && (code[index + 1] === "/" || code[index + 1] === "*")) {
169
+ index = skipScannedComment(code, index, commentStarts);
109
170
  continue;
110
171
  }
111
- if (char === "/" && code[index + 1] === "*") {
112
- index = skipBlockComment(code, index);
113
- continue;
172
+ if (char === "<" && opener !== "<" && isLikelyJsxStart(code, index, closedParen, commentStarts)) {
173
+ const jsxEnd = skipJsxElement(code, index, jsxScanCache);
174
+ if (jsxEnd > index) {
175
+ index = jsxEnd;
176
+ continue;
177
+ }
114
178
  }
115
- if (isLikelyRegexLiteralStart(code, index)) {
179
+ if (char === "/" && isLikelyRegexLiteralStart(code, index, commentStarts)) {
116
180
  index = skipRegexLiteral(code, index);
117
181
  continue;
118
182
  }
@@ -121,9 +185,11 @@ const findBalancedBlockEnd = (code: string, index: number, opener: string, close
121
185
  continue;
122
186
  }
123
187
  if (char === "`") {
124
- index = skipTemplateLiteral(code, index);
188
+ index = skipTemplateLiteral(code, index, jsxScanCache);
125
189
  continue;
126
190
  }
191
+ if (char === "(") openParens.push(index);
192
+ else if (char === ")") closedParen = openParens.pop();
127
193
  if (char === opener) depth += 1;
128
194
  if (char === closer) {
129
195
  depth -= 1;
@@ -134,17 +200,19 @@ const findBalancedBlockEnd = (code: string, index: number, opener: string, close
134
200
  return code.length;
135
201
  };
136
202
 
137
- const isLikelyRegexLiteralStart = (code: string, index: number) => {
203
+ const isLikelyRegexLiteralStart = (code: string, index: number, commentStarts: ReadonlyMap<number, number>) => {
138
204
  if (code[index] !== "/" || code[index + 1] === "/" || code[index + 1] === "*") return false;
139
- let previous = index - 1;
140
- while (previous >= 0 && /\s/.test(code[previous])) previous -= 1;
205
+ const previous = previousCodeIndex(code, index, commentStarts);
141
206
  if (previous < 0) return true;
142
207
  const previousChar = code[previous];
208
+ // An adjacent `</` opens a JSX closing tag, not a regex after `<` — a real `a < /re/` always has whitespace. Consuming
209
+ // the tag as a regex swallows every import after it.
210
+ if (previous === index - 1 && previousChar === "<") return false;
143
211
  if ("([{=,:;!&|?+-*~^<>".includes(previousChar)) return true;
144
212
  if (!/[A-Za-z_$]/.test(previousChar)) return false;
145
- let word = "";
146
- for (let cursor = previous; cursor >= 0 && /[A-Za-z0-9_$]/.test(code[cursor]); cursor -= 1) word = code[cursor] + word;
147
- return REGEX_PREFIX_WORDS.has(word);
213
+ const { word, start } = wordEndingAt(code, previous + 1, commentStarts);
214
+ // Keywords also name properties: `object.default / 2` is division, including optional chains and intervening comments.
215
+ return EXPRESSION_PREFIX_WORDS.has(word) && ![".", "#"].includes(code[previousCodeIndex(code, start, commentStarts)]);
148
216
  };
149
217
 
150
218
  const skipRegexLiteral = (code: string, index: number) => {
@@ -167,19 +235,143 @@ const skipRegexLiteral = (code: string, index: number) => {
167
235
  return code.length;
168
236
  };
169
237
 
170
- const readFromSpecifier = (code: string, index: number) => {
238
+ const STATEMENT_CONDITION_WORDS = new Set(["if", "while", "for", "with", "switch", "catch"]);
239
+
240
+ // What separates a keyword from the token after it can be a comment, not just whitespace (`if /* c */ (`), so rewind
241
+ // over the comment spans the forward scan recorded: stopping on the `*/` yields no word at all, losing the keyword.
242
+ const wordEndingAt = (code: string, end: number, commentStarts: ReadonlyMap<number, number>) => {
243
+ end = previousCodeIndex(code, end, commentStarts) + 1;
244
+ let start = end;
245
+ // Consume whole code points and braced escapes so an identifier prefix cannot leave a keyword-looking suffix behind.
246
+ while (start > 0) {
247
+ if (code[start - 1] === "}") {
248
+ let cursor = start - 2;
249
+ while (cursor >= 0 && /[\da-fA-F]/.test(code[cursor])) cursor -= 1;
250
+ if (code.slice(cursor - 2, cursor + 1) === "\\u{") {
251
+ start = cursor - 2;
252
+ continue;
253
+ }
254
+ }
255
+ const previous = start - (code.charCodeAt(start - 1) >= 0xdc00 && code.charCodeAt(start - 1) <= 0xdfff ? 2 : 1);
256
+ if (!IDENTIFIER_PART_PATTERN.test(code.slice(previous, start))) break;
257
+ start = previous;
258
+ }
259
+ return { word: code.slice(start, end), start };
260
+ };
261
+
262
+ // Both JavaScript scan loops pair parens while skipping literals, comments and JSX. Rewinding from `)` can start
263
+ // inside one of those constructs and lose later imports; using the observed opener also avoids rescanning conditions.
264
+ const isStatementConditionOpen = (code: string, open: number | undefined, commentStarts: ReadonlyMap<number, number>) => {
265
+ if (open === undefined) return false;
266
+ let preceding = wordEndingAt(code, open, commentStarts);
267
+ // `for await (` is the only production putting a word between the controlling keyword and its `(`, so one step is enough.
268
+ if (preceding.word === "await") preceding = wordEndingAt(code, preceding.start, commentStarts);
269
+ return STATEMENT_CONDITION_WORDS.has(preceding.word);
270
+ };
271
+
272
+ const isLikelyJsxStart = (code: string, index: number, closedParen: number | undefined, commentStarts: ReadonlyMap<number, number>) => {
273
+ let previous = index - 1;
274
+ while (previous >= 0 && /\s/.test(code[previous])) {
275
+ // Deliberately optimistic, and the widest edge of this heuristic: a preceding newline admits every multi-line
276
+ // non-JSX `<` too (a split generic call, a comparison chain, a wrapped type annotation), each paying an element
277
+ // scan that fails. Results stay right because `skipJsxElement` returns `start` on failure, and affordable only
278
+ // because `jsxScanCache` memoizes that failure — narrowing this branch would break ordinary formatted JSX.
279
+ if (code[previous] === "\n") return true;
280
+ previous -= 1;
281
+ }
282
+ // `call()<p>` is a comparison-like expression, not JSX. `jsxScanCache` cannot absorb this the way it does the newline
283
+ // branch — each `)<` sits at a distinct start — so deleting the guard takes `a=f()<div a={f()};` quadratic (n=500..4000:
284
+ // 13.7 -> 817.7 ms, versus 1.4 -> 11.2 ms). It also changes results: TS reads `<` after a call as relational, so the
285
+ // `import` in `f()<div>{import("x")}</div>` is live code. `)` only — `]` would regress `xs[0]<div>` to inert JSX text.
286
+ // Keep statement-position JSX (`if(ready)<p>`) working by recognizing the controlling keyword before the paren.
287
+ if (previous === index - 1 && code[previous] === ")" && !isStatementConditionOpen(code, closedParen, commentStarts)) return false;
288
+ // Exclude type arguments (`Promise<T>`, `call<T>()`) before attempting a potentially long element scan.
289
+ if (previous < 0 || !IDENTIFIER_PART_PATTERN.test(code[previous])) return true;
290
+ return EXPRESSION_PREFIX_WORDS.has(wordEndingAt(code, previous + 1, commentStarts).word);
291
+ };
292
+
293
+ // JSX text is not JavaScript: quotes and import-looking prose are inert, but attribute/child expressions still contain
294
+ // real imports. Only commit a complete element; otherwise a TS angle-bracket assertion could consume the rest of a file.
295
+ const skipJsxElement = (code: string, start: number, jsxScanCache: JsxScanCache, onExpression?: (start: number, end: number) => void): number => {
296
+ const cached = jsxScanCache.get(start);
297
+ if (cached) {
298
+ for (const [expressionStart, expressionEnd] of cached.expressions) onExpression?.(expressionStart, expressionEnd);
299
+ return cached.end;
300
+ }
301
+ let index = start;
302
+ const tags: { name: string; start: number }[] = [];
303
+ const expressions: [number, number][] = [];
304
+ const fail = () => {
305
+ // Every still-open ancestor would fail on the same suffix. Cache all of them, not just the outer start, so a
306
+ // streamed stack of unclosed elements does not rescan the tail once per nesting level. The cache is per input.
307
+ jsxScanCache.set(start, { end: start, expressions: [] });
308
+ for (const tag of tags) jsxScanCache.set(tag.start, { end: tag.start, expressions: [] });
309
+ return start;
310
+ };
311
+ const skipExpression = (at: number) => {
312
+ const end = findBalancedBlockEnd(code, at, "{", "}", jsxScanCache);
313
+ expressions.push([at + 1, Math.max(at + 1, end - 1)]);
314
+ return end;
315
+ };
171
316
  while (index < code.length) {
172
- const char = code[index];
173
- if (char === ";") return null;
174
- if (char === "/" && code[index + 1] === "/") {
175
- index = skipLineComment(code, index);
317
+ if (code[index] === "{" && tags.length) {
318
+ index = skipExpression(index);
176
319
  continue;
177
320
  }
178
- if (char === "/" && code[index + 1] === "*") {
179
- index = skipBlockComment(code, index);
321
+ if (code[index] !== "<") {
322
+ index += 1;
180
323
  continue;
181
324
  }
182
- if (isLikelyRegexLiteralStart(code, index)) {
325
+ JSX_TAG_START_PATTERN.lastIndex = index;
326
+ const tag = JSX_TAG_START_PATTERN.exec(code);
327
+ if (!tag) {
328
+ if (!tags.length) return fail();
329
+ index += 1;
330
+ continue;
331
+ }
332
+ const closing = !!tag[1];
333
+ if (closing && tags.at(-1)?.name !== tag[2]) return fail();
334
+ const tagStart = index;
335
+ index += tag[0].length;
336
+ // Generic arrows use the same expression position as JSX, but `extends`/`=` start type parameters, not attributes.
337
+ const afterName = skipWhitespaceAndComments(code, index);
338
+ if (!tags.length && (code[afterName] === "=" || isKeywordAt(code, afterName, "extends"))) return fail();
339
+ while (index < code.length && code[index] !== ">") {
340
+ const char = code[index];
341
+ if (char === '"' || char === "'") {
342
+ // Backslashes do not escape quotes in JSX attributes.
343
+ const quoteEnd = code.indexOf(char, index + 1);
344
+ if (quoteEnd === -1) return fail();
345
+ index = quoteEnd + 1;
346
+ } else if (char === "{") index = skipExpression(index);
347
+ else if (char === "<") index = findBalancedBlockEnd(code, index, "<", ">", jsxScanCache);
348
+ else if (char === "/" && code[index + 1] === "/") index = skipLineComment(code, index);
349
+ else if (char === "/" && code[index + 1] === "*") index = skipBlockComment(code, index);
350
+ else index += 1;
351
+ }
352
+ if (index === code.length) return fail();
353
+ if (closing) tags.pop();
354
+ else if (code[index - 1] !== "/") tags.push({ name: tag[2], start: tagStart });
355
+ index += 1;
356
+ if (!tags.length) {
357
+ jsxScanCache.set(start, { end: index, expressions });
358
+ for (const [expressionStart, expressionEnd] of expressions) onExpression?.(expressionStart, expressionEnd);
359
+ return index;
360
+ }
361
+ }
362
+ return fail();
363
+ };
364
+
365
+ const readFromSpecifier = (code: string, index: number, jsxScanCache: JsxScanCache) => {
366
+ const commentStarts = new Map<number, number>();
367
+ while (index < code.length) {
368
+ const char = code[index];
369
+ if (char === ";") return null;
370
+ if (char === "/" && (code[index + 1] === "/" || code[index + 1] === "*")) {
371
+ index = skipScannedComment(code, index, commentStarts);
372
+ continue;
373
+ }
374
+ if (char === "/" && isLikelyRegexLiteralStart(code, index, commentStarts)) {
183
375
  index = skipRegexLiteral(code, index);
184
376
  continue;
185
377
  }
@@ -188,10 +380,10 @@ const readFromSpecifier = (code: string, index: number) => {
188
380
  continue;
189
381
  }
190
382
  if (char === "`") {
191
- index = skipTemplateLiteral(code, index);
383
+ index = skipTemplateLiteral(code, index, jsxScanCache);
192
384
  continue;
193
385
  }
194
- if (isKeywordAt(code, index, "from")) {
386
+ if (char === "f" && isKeywordAt(code, index, "from")) {
195
387
  const start = skipWhitespaceAndComments(code, index + "from".length);
196
388
  const literal = readStringLiteral(code, start);
197
389
  if (literal) return { specifier: literal.value, start, end: literal.end, terminated: literal.terminated };
@@ -203,12 +395,24 @@ const readFromSpecifier = (code: string, index: number) => {
203
395
  return null;
204
396
  };
205
397
 
206
- const readExportFromSpecifier = (code: string, index: number) => {
398
+ const readExportFromSpecifier = (code: string, index: number, jsxScanCache: JsxScanCache) => {
207
399
  let next = skipWhitespaceAndComments(code, index);
208
- if (isKeywordAt(code, next, "type")) next = skipWhitespaceAndComments(code, next + "type".length);
209
- if (code[next] === "*" || code[next] === "{") return readFromSpecifier(code, next);
210
- for (const keyword of LOCAL_EXPORT_KEYWORDS) if (isKeywordAt(code, next, keyword)) return null;
211
- return readFromSpecifier(code, next);
400
+ // A clause-level `type` erases the whole re-export, unlike an inline `export { type A, B } from` which still links for
401
+ // `B`. Reported as `notLinked` so probing does not fetch what the compiled module never imports.
402
+ const typeOnly = isKeywordAt(code, next, "type");
403
+ if (typeOnly) next = skipWhitespaceAndComments(code, next + "type".length);
404
+ // Only `{` and `*` can start a re-export. Enumerating local declaration keywords misses decorators and lets
405
+ // semicolonless declarations consume a later import's `from`.
406
+ if (code[next] === "{") {
407
+ // A local `export { a }` may have no semicolon either, and the lenient scan below would then read the NEXT
408
+ // statement's `from` — reporting that import as a nameless re-export and losing every name it binds. The clause's
409
+ // own `from` can only sit right after the closing brace, so require it there instead of hunting forward.
410
+ const afterClause = skipWhitespaceAndComments(code, findBalancedBlockEnd(code, next, "{", "}", jsxScanCache));
411
+ if (!isKeywordAt(code, afterClause, "from")) return null;
412
+ next = afterClause;
413
+ } else if (code[next] !== "*") return null;
414
+ const from = readFromSpecifier(code, next, jsxScanCache);
415
+ return from && { ...from, notLinked: typeOnly || undefined };
212
416
  };
213
417
 
214
418
  // `.meta.resolve(` after the `import` keyword, returning the string-literal argument. Null means this `import.` is
@@ -224,36 +428,114 @@ const readImportMetaResolveSpecifier = (code: string, dotIndex: number) => {
224
428
  if (code[next] !== "(") return null;
225
429
  const start = skipWhitespaceAndComments(code, next + 1);
226
430
  const literal = readStringLiteral(code, start);
227
- return literal ? { ...literal, start } : { value: "", start, end: next, terminated: false };
431
+ return literal ? { ...literal, start, openParen: next } : { value: "", start, end: next, terminated: false, openParen: next };
228
432
  };
229
433
 
230
434
  const readIdentifierWord = (code: string, index: number) => {
231
435
  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;
436
+ let escapedName: string | undefined;
437
+ while (end < code.length) {
438
+ let escape: RegExpExecArray | null = null;
439
+ if (code[end] === "\\") {
440
+ IDENTIFIER_ESCAPE_PATTERN.lastIndex = end;
441
+ escape = IDENTIFIER_ESCAPE_PATTERN.exec(code);
442
+ }
443
+ const value = escape ? Number.parseInt(escape[1] ?? escape[2], 16) : code.codePointAt(end)!;
444
+ if (value > 0x10ffff || !isIdentifierCodePoint(value, end === index)) break;
445
+ const characterLength = value > 0xffff ? 2 : 1;
446
+ if (escape) escapedName = `${escapedName ?? code.slice(index, end)}${String.fromCodePoint(value)}`;
447
+ else if (escapedName !== undefined) escapedName += code.slice(end, end + characterLength);
448
+ end += escape?.[0].length ?? characterLength;
449
+ }
450
+ return end > index ? { name: escapedName ?? code.slice(index, end), end } : null;
451
+ };
452
+
453
+ const readImportTypeModifierEnd = (code: string, index: number) => {
454
+ if (!isKeywordAt(code, index, "type")) return undefined;
455
+ const after = skipWhitespaceAndComments(code, index + "type".length);
456
+ // `import type from "x"` / `import type, { X } from "x"` bind a default named `type`.
457
+ return isKeywordAt(code, after, "from") || code[after] === "," ? undefined : after;
458
+ };
459
+
460
+ // A JSON/CSS module is only importable with its `with { type: … }` clause, so a probe that re-imports the specifier
461
+ // without it fails on a dependency that actually linked, reporting a working import as broken (#1824). Read the clause
462
+ // so probeModuleImports can replay it verbatim.
463
+ //
464
+ // `end` lets the caller resume past the clause: rescanned as statements, a reserved-word key (`with { import: "y" }`)
465
+ // matches as a keyword and swallows the next import's `from`. `partial` separates a clause that is present but
466
+ // unreadable — a stream frame cut inside it — from no clause at all; probing the former bare reproduces exactly the
467
+ // false positive above, so those specifiers are withheld until the clause completes.
468
+ type ImportAttributesClause = { attributes?: Record<string, string>; end: number; partial?: boolean };
469
+ const readImportAttributes = (code: string, index: number, jsxScanCache: JsxScanCache): ImportAttributesClause | undefined => {
470
+ const next = skipWhitespaceAndComments(code, index);
471
+ if (!isKeywordAt(code, next, "with")) return;
472
+ const braceStart = skipWhitespaceAndComments(code, next + "with".length);
473
+ // `with` streamed but not yet its `{`: the clause is coming, so this frame cannot be probed.
474
+ if (code[braceStart] !== "{") return { end: braceStart, partial: true };
475
+ const braceEnd = findBalancedBlockEnd(code, braceStart, "{", "}", jsxScanCache);
476
+ // findBalancedBlockEnd returns `code.length` when the brace never closes, i.e. the clause is still streaming.
477
+ // Left undefined until an entry lands, so an empty clause (`with {}`) reads as no attributes — what the engine links with too.
478
+ // Null-prototype once allocated: keys come from generated code, so a `__proto__` key must land as an own property
479
+ // instead of hitting the setter, where it would silently vanish and probe the module without the type it asked for.
480
+ let attributes: Record<string, string> | undefined;
481
+ let partial = code[braceEnd - 1] !== "}";
482
+ let cursor = braceStart + 1;
483
+ while (cursor < braceEnd - 1) {
484
+ cursor = skipWhitespaceAndComments(code, cursor);
485
+ if (cursor >= braceEnd - 1) break;
486
+ // AssertEntry keys are an IdentifierName or a string literal, so `type`, `"type"` and `'type'` are all the same key.
487
+ const quotedKey = readStringLiteral(code, cursor);
488
+ const key = quotedKey ?? readIdentifierWord(code, cursor);
489
+ if (!key || (quotedKey && !quotedKey.terminated)) {
490
+ partial = true;
491
+ break;
492
+ }
493
+ // Both readers decode escapes, so `"type"` and `type` reach the probe as `type`, the key the engine linked with.
494
+ const keyName = "name" in key ? key.name : key.value;
495
+ cursor = skipWhitespaceAndComments(code, key.end);
496
+ if (code[cursor] !== ":") {
497
+ partial = true;
498
+ break;
499
+ }
500
+ cursor = skipWhitespaceAndComments(code, cursor + 1);
501
+ // Values are string literals only — no identifiers, no numbers — so anything else means the clause is truncated.
502
+ const value = readStringLiteral(code, cursor);
503
+ if (!value?.terminated) {
504
+ partial = true;
505
+ break;
506
+ }
507
+ attributes ??= Object.create(null) as Record<string, string>;
508
+ attributes[keyName] = value.value;
509
+ cursor = skipWhitespaceAndComments(code, value.end);
510
+ // The loop head skips whitespace and comments, so stepping over the comma alone is enough.
511
+ if (code[cursor] === ",") cursor += 1;
512
+ // Two entries with no comma between them is not a clause the engine links, and not a frame that becomes one.
513
+ else if (code[cursor] !== "}") {
514
+ partial = true;
515
+ break;
516
+ }
517
+ }
518
+ return { attributes, end: braceEnd, partial: partial || undefined };
234
519
  };
235
520
 
236
521
  // 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 => {
522
+ // ("default" for default imports; namespace and side-effect imports demand none). `names` holds only the runtime-linked
523
+ // ones and `allNames` adds the erased type positions, which ambient consumers still need to declare; `notLinked` marks a
524
+ // wholly erased clause. Returns null on clauses it does not understand (streaming-truncated ones) so callers can fall
525
+ // back to the lenient readFromSpecifier.
526
+ const readImportBindings = (code: string, index: number): { specifier: string; names: string[]; allNames: string[]; start: number; end: number; terminated: boolean; notLinked?: boolean } | null => {
241
527
  const names: string[] = [];
528
+ let typeOnlyNames: string[] | undefined;
242
529
  index = skipWhitespaceAndComments(code, index);
243
530
  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
- }
531
+ if (sideEffect) return sideEffect.value ? { specifier: sideEffect.value, names, allNames: names, start: index, end: sideEffect.end, terminated: sideEffect.terminated } : null;
532
+ const afterType = readImportTypeModifierEnd(code, index);
533
+ const typeOnlyImport = afterType !== undefined;
534
+ index = afterType ?? index;
253
535
  if (code[index] !== "{" && code[index] !== "*") {
254
536
  const afterDefault = readIdentifierWord(code, index)?.end;
255
537
  if (afterDefault === undefined) return null;
256
- names.push("default");
538
+ (typeOnlyImport ? (typeOnlyNames ??= []) : names).push("default");
257
539
  index = skipWhitespaceAndComments(code, afterDefault);
258
540
  if (code[index] === ",") index = skipWhitespaceAndComments(code, index + 1);
259
541
  }
@@ -284,7 +566,7 @@ const readImportBindings = (code: string, index: number): { specifier: string; n
284
566
  if (!local) return null;
285
567
  index = skipWhitespaceAndComments(code, local.end);
286
568
  }
287
- if (!typeOnly) names.push(imported.name);
569
+ (typeOnlyImport || typeOnly ? (typeOnlyNames ??= []) : names).push(imported.name);
288
570
  if (code[index] === ",") index = skipWhitespaceAndComments(code, index + 1);
289
571
  else if (code[index] !== "}") return null;
290
572
  }
@@ -294,39 +576,60 @@ const readImportBindings = (code: string, index: number): { specifier: string; n
294
576
  if (!isKeywordAt(code, index, "from")) return null;
295
577
  const start = skipWhitespaceAndComments(code, index + "from".length);
296
578
  const literal = readStringLiteral(code, start);
297
- return literal?.value ? { specifier: literal.value, names, start, end: literal.end, terminated: literal.terminated } : null;
579
+ return literal?.value ? { specifier: literal.value, names, allNames: typeOnlyNames ? [...names, ...typeOnlyNames] : names, start, end: literal.end, terminated: literal.terminated, ...(typeOnlyImport ? { notLinked: true } : {}) } : null;
298
580
  };
299
581
 
300
- // Walks module-level import/export statements and reports each specifier with the export names bound from it
582
+ // Walks module-level import/export statements and reports each specifier with its runtime names plus all source names
301
583
  // (empty for namespace, side-effect, re-export and dynamic imports). `onSpecifier` is the single output channel —
302
584
  // callers project it into whatever shape they need. `site` separates the module graph from `import.meta.resolve()`,
303
585
  // which is a runtime asset lookup rather than an edge.
304
- type ResolutionSite = "import" | "meta-resolve";
586
+ export type ResolutionSite = "import" | "meta-resolve";
305
587
  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) => {
307
- for (let index = start; index < end; index += 1) {
588
+ // `attributesPartial` marks a specifier whose `with { … }` clause is still streaming. The specifier itself is complete, so
589
+ // import-map building and specifier rewriting must still see it; only probing has to withhold, since replaying a clause that
590
+ // has not fully arrived would link a form the module never requested.
591
+ const collectModuleSpecifiers = (
592
+ code: string,
593
+ onSpecifier: (specifier: string, names: string[], notLinked: boolean | undefined, site: ResolutionSite, range?: SpecifierRange, allNames?: string[], attributes?: Record<string, string>, attributesPartial?: boolean) => void,
594
+ start = 0,
595
+ end = code.length,
596
+ jsxScanCache: JsxScanCache = new Map(),
597
+ ) => {
598
+ const scanExpression = (expressionStart: number, expressionEnd: number) => collectModuleSpecifiers(code, onSpecifier, expressionStart, expressionEnd, jsxScanCache);
599
+ const openParens: number[] = [];
600
+ const commentStarts = new Map<number, number>();
601
+ let closedParen: number | undefined;
602
+ // Each branch below already lands on the next unscanned character, so consuming branches `continue` past the tail
603
+ // increment. Adding one on top of them skipped a token abutting the construct before it (`/*! banner */import"react"`).
604
+ let index = start;
605
+ while (index < end) {
606
+ // Each branch below gates its predicate on `char` first: those calls each re-test the same character and bail, so
607
+ // paying them on all ~1.7M characters instead of the <1% that can match cost 1.7x of the whole scan.
308
608
  const char = code[index];
309
- if (char === "/" && code[index + 1] === "/") {
310
- index = skipLineComment(code, index);
311
- continue;
609
+ if (char === "<" && isLikelyJsxStart(code, index, closedParen, commentStarts)) {
610
+ const jsxEnd = skipJsxElement(code, index, jsxScanCache, scanExpression);
611
+ if (jsxEnd > index) {
612
+ index = jsxEnd;
613
+ continue;
614
+ }
312
615
  }
313
- if (char === "/" && code[index + 1] === "*") {
314
- index = skipBlockComment(code, index);
616
+ if (char === "/" && (code[index + 1] === "/" || code[index + 1] === "*")) {
617
+ index = skipScannedComment(code, index, commentStarts);
315
618
  continue;
316
619
  }
317
- if (isLikelyRegexLiteralStart(code, index)) {
620
+ if (char === "/" && isLikelyRegexLiteralStart(code, index, commentStarts)) {
318
621
  index = skipRegexLiteral(code, index);
319
622
  continue;
320
623
  }
321
624
  if (char === '"' || char === "'") {
322
- index = readStringLiteral(code, index)?.end ?? index;
625
+ index = readStringLiteral(code, index)?.end ?? index + 1;
323
626
  continue;
324
627
  }
325
628
  if (char === "`") {
326
- index = skipTemplateLiteral(code, index, (expressionStart, expressionEnd) => collectModuleSpecifiers(code, onSpecifier, expressionStart, expressionEnd));
629
+ index = skipTemplateLiteral(code, index, jsxScanCache, scanExpression);
327
630
  continue;
328
631
  }
329
- if (isKeywordAt(code, index, "import")) {
632
+ if (char === "i" && isKeywordAt(code, index, "import")) {
330
633
  const afterImport = skipWhitespaceAndComments(code, index + "import".length);
331
634
  if (code[afterImport] === ".") {
332
635
  // Keep runtime asset lookups visible to callers that scan all specifiers, while separating them from actual
@@ -335,6 +638,8 @@ const collectModuleSpecifiers = (code: string, onSpecifier: (specifier: string,
335
638
  if (literal) {
336
639
  // notLinked: nothing is instantiated here, so probing it would fetch what the module never requested.
337
640
  if (literal.terminated && literal.value) onSpecifier(literal.value, [], true, "meta-resolve", { start: literal.start, end: literal.end });
641
+ // The fast path skips `(` but leaves `)` to the loop; keep an enclosing condition's parens balanced.
642
+ if (literal.end > literal.openParen) openParens.push(literal.openParen);
338
643
  index = literal.end;
339
644
  continue;
340
645
  }
@@ -346,23 +651,63 @@ const collectModuleSpecifiers = (code: string, onSpecifier: (specifier: string,
346
651
  const literal = readStringLiteral(code, literalStart);
347
652
  // A dynamic target is not linked at instantiation, so probing it would issue a real request.
348
653
  if (literal?.terminated && literal.value) onSpecifier(literal.value, [], true, "import", { start: literalStart, end: literal.end });
654
+ if (literal) openParens.push(afterImport);
349
655
  index = literal?.end ?? afterImport;
350
656
  continue;
351
657
  }
352
658
  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 });
355
- index = from?.end ?? afterImport;
659
+ const from = bindings ?? readFromSpecifier(code, afterImport, jsxScanCache);
660
+ // A truncated clause can lose its bindings, but must not turn an erased import into a runtime fetch.
661
+ const notLinked = bindings ? bindings.notLinked : readImportTypeModifierEnd(code, afterImport) !== undefined;
662
+ // Fire the callback even during streaming: rewriteImportSpecifiers needs the range to replace the bare specifier with
663
+ // the resolved target. `clause.partial` tells the probe path to withhold instead — see readImportAttributes.
664
+ if (from?.terminated && from.specifier) {
665
+ const clause = readImportAttributes(code, from.end, jsxScanCache);
666
+ onSpecifier(from.specifier, bindings?.names ?? [], notLinked, "import", { start: from.start, end: from.end }, bindings?.allNames, clause?.attributes, clause?.partial);
667
+ // Only a complete clause is safe to resume past: a partial one's `end` is where the scan gave up, which for an
668
+ // unclosed brace is EOF and for an unterminated value sits inside that string — resuming there would swallow every
669
+ // import after it. Fall back to the specifier's own end, which the pre-attributes scanner used.
670
+ index = clause && !clause.partial ? clause.end : from.end;
671
+ } else {
672
+ index = from?.end ?? afterImport;
673
+ }
356
674
  continue;
357
675
  }
358
- if (isKeywordAt(code, index, "export")) {
359
- const from = readExportFromSpecifier(code, index + "export".length);
360
- if (from?.terminated && from.specifier) onSpecifier(from.specifier, [], undefined, "import", { start: from.start, end: from.end });
361
- index = from?.end ?? index;
676
+ if (char === "e" && isKeywordAt(code, index, "export")) {
677
+ const from = readExportFromSpecifier(code, index + "export".length, jsxScanCache);
678
+ // Same split as the import branch: rewriting sees every specifier, the probe path withholds partial clauses.
679
+ if (from?.terminated && from.specifier) {
680
+ const clause = readImportAttributes(code, from.end, jsxScanCache);
681
+ onSpecifier(from.specifier, [], from.notLinked, "import", { start: from.start, end: from.end }, undefined, clause?.attributes, clause?.partial);
682
+ // A partial clause's `end` can sit inside its own unterminated string, where resuming would re-lex the rest of the
683
+ // file as that string and lose every import after it; leave the specifier's own end to advance instead.
684
+ index = clause && !clause.partial ? clause.end : from.end;
685
+ } else {
686
+ // Null means a local `export` that consumed nothing, so step one char rather than rescanning the same `e`.
687
+ index = from?.end ?? index + 1;
688
+ }
689
+ continue;
362
690
  }
691
+ if (char === "(") openParens.push(index);
692
+ else if (char === ")") closedParen = openParens.pop();
693
+ index += 1;
363
694
  }
364
695
  };
365
696
 
697
+ // Accumulates per-specifier names, deduped across repeated imports of the same module. `select` picks which projection
698
+ // of the clause to merge, or returns undefined to drop the specifier entirely.
699
+ const collectNamesBySpecifier = (code: string, select: (names: string[], allNames: string[], notLinked: boolean | undefined, site: ResolutionSite) => string[] | undefined) => {
700
+ const bindings = new Map<string, Set<string>>();
701
+ collectModuleSpecifiers(code, (specifier, names, notLinked, site, _range, allNames) => {
702
+ const selected = select(names, allNames ?? names, notLinked, site);
703
+ if (!selected) return;
704
+ let collected = bindings.get(specifier);
705
+ if (!collected) bindings.set(specifier, (collected = new Set()));
706
+ for (const name of selected) collected.add(name);
707
+ });
708
+ return new Map([...bindings].map(([specifier, names]) => [specifier, [...names]] as const));
709
+ };
710
+
366
711
  const extractSpecifiers = (code: string, keep: (specifier: string, site: ResolutionSite) => boolean) => {
367
712
  const specifiers = new Set<string>();
368
713
  collectModuleSpecifiers(code, (specifier, _names, _notLinked, site) => {
@@ -371,6 +716,24 @@ const extractSpecifiers = (code: string, keep: (specifier: string, site: Resolut
371
716
  return specifiers;
372
717
  };
373
718
 
719
+ // One walk, one Set per kind, for a caller that needs several projections of the same buffer: two `extract*` calls walk
720
+ // it twice, and this scan is not cheap — JSX tag recognition, per-character code-point classification and escape
721
+ // decoding all run per character. `classify` returns the kind a specifier belongs to, or undefined to drop it, and is
722
+ // asked once per occurrence rather than once per specifier — so nothing here makes the kinds exclusive. `site` may gate
723
+ // a kind down to undefined, but a `classify` that answers *different* kinds at different sites buckets one specifier
724
+ // twice, and unioning the buckets then repeats it (`Set`s dedupe within a kind, not across). Buckets are keyed for
725
+ // every `K` the caller asks for, empty ones included, so a destructuring caller never reads undefined.
726
+ // `NoInfer` so `kinds` is the only inference source: otherwise a `classify` answering with an unlisted kind widens `K`
727
+ // instead of being rejected, and the unlisted key has no bucket to `add` to.
728
+ export const extractSpecifiersByKind = <const K extends string>(code: string, kinds: readonly K[], classify: (specifier: string, site: ResolutionSite) => NoInfer<K> | undefined) => {
729
+ const buckets = Object.fromEntries(kinds.map((kind) => [kind, new Set<string>()])) as Record<K, Set<string>>;
730
+ collectModuleSpecifiers(code, (specifier, _names, _notLinked, site) => {
731
+ const kind = classify(specifier, site);
732
+ if (kind !== undefined) buckets[kind].add(specifier);
733
+ });
734
+ return buckets;
735
+ };
736
+
374
737
  export const extractBareModuleSpecifiers = (code: string) => extractSpecifiers(code, isBareModuleSpecifier);
375
738
 
376
739
  // Specifiers reached through an `import`/`export` statement only — the actual module graph. Excludes
@@ -418,17 +781,30 @@ export const rewriteImportMetaResolveSpecifiers = (code: string, imports: Readon
418
781
  export const rewriteImportSpecifiers = (code: string, imports: Readonly<Record<string, string>>) => rewriteSpecifiers(code, imports, "import");
419
782
 
420
783
  // 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.
784
+ // namespace, side-effect and re-export imports demand no specific names) and the `with { … }` attributes it was imported
785
+ // under. Type-only and dynamic imports are excluded: neither is linked at instantiation, so probing them would fetch
786
+ // something the failed module never requested. importProbe runs this over *compiled* code, where the import-map rewrite
787
+ // already resolved bare specifiers.
424
788
  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])]);
789
+ const bindings = new Map<string, { names: Set<string>; attributes?: Record<string, string> }>();
790
+ collectModuleSpecifiers(code, (specifier, names, notLinked, _site, _range, _allNames, attributes, attributesPartial) => {
791
+ if (notLinked) return;
792
+ // This is the probe path, so a clause that has not fully arrived must not be replayed — see readImportAttributes.
793
+ if (attributesPartial) return;
794
+ let entry = bindings.get(specifier);
795
+ // One probe stands in for every import of a specifier, so it must replay the FIRST clause seen — including no clause
796
+ // at all. Letting a later `with { … }` fill in for a bare import probes a form the module never used, turning a
797
+ // genuine link failure into a silent pass.
798
+ if (!entry) bindings.set(specifier, (entry = { names: new Set(), attributes }));
799
+ for (const name of names) entry.names.add(name);
428
800
  });
429
- return bindings;
801
+ return new Map([...bindings].map(([specifier, { names, attributes }]) => [specifier, { names: [...names], attributes }] as const));
430
802
  }
431
803
 
804
+ // Standalone facade declarations also need names imported only for type positions; unlike runtime probing, those imports
805
+ // are erased from the module graph but still need ambient members.
806
+ export const extractImportedExportNames = (code: string) => collectNamesBySpecifier(code, (_names, allNames, _notLinked, site) => (site === "import" ? allNames : undefined));
807
+
432
808
  export function hasImportMapEntry(imports: Record<string, string> | Set<string> | Map<string, string>, specifier: string) {
433
809
  const keys = imports instanceof Set ? imports : imports instanceof Map ? new Set(imports.keys()) : new Set(Object.keys(imports));
434
810
  if (keys.has(specifier)) return true;
@@ -633,7 +1009,18 @@ export function packageImportMap(packageName: string, options?: { resolve?: (nam
633
1009
  if (!importMap) throw new Error(`${packageName} has no package.json#genui.importMap — add it to declare the GenUI importmap contract`);
634
1010
  const packageDir = dirname(manifestPath);
635
1011
  const rebase = (target: string) => (target.startsWith(".") ? resolvePath(packageDir, target) : target);
636
- return { imports: Object.fromEntries(Object.entries(importMap.imports ?? {}).map(([specifier, target]) => [specifier, rebase(target)])), styles: importMap.styles?.map(rebase) };
1012
+ const rebaseEntries = (entries: Record<string, string>) => Object.fromEntries(Object.entries(entries).map(([specifier, target]) => [specifier, rebase(target)]));
1013
+ const rebasedScopes =
1014
+ importMap.scopes &&
1015
+ Object.fromEntries(
1016
+ Object.entries(importMap.scopes).map(([scope, entries]) => {
1017
+ // Validate scope keys are valid URL prefixes (absolute path or URL) after rebasing
1018
+ const rebased = rebase(scope);
1019
+ if (!rebased.startsWith("/") && !/^https?:\/\//i.test(rebased)) throw new Error(`Invalid import map scope key "${scope}" (rebased to "${rebased}"): must be an absolute path or URL`);
1020
+ return [rebased, rebaseEntries(entries)];
1021
+ }),
1022
+ );
1023
+ return { imports: rebaseEntries(importMap.imports ?? {}), scopes: rebasedScopes, styles: importMap.styles?.map(rebase) };
637
1024
  },
638
1025
  };
639
1026
  }
@@ -37,14 +37,16 @@ const reverseResolveSpecifier = (entries: [string, string][], target: string) =>
37
37
  // targets back to the bare specifiers the model wrote.
38
38
  export async function probeModuleImports(code: string, imports?: Record<string, string>): Promise<GenUIImportDiagnostic[]> {
39
39
  const importEntries = Object.entries(imports ?? {});
40
- const probes = [...extractImportBindings(code)].map(async ([target, names]): Promise<GenUIImportDiagnostic | null> => {
40
+ const probes = [...extractImportBindings(code)].map(async ([target, { names, attributes }]): Promise<GenUIImportDiagnostic | null> => {
41
41
  const specifier = reverseResolveSpecifier(importEntries, target);
42
42
  let module: Record<string, unknown>;
43
43
  try {
44
44
  // `target` is import-map-resolved at runtime, so it needs the same three-bundler ignore stack as the blob import
45
45
  // in runtime.ts — otherwise every probe throws `Cannot find module 'unknown'` and reports each dependency as
46
46
  // broken, burying the one that actually is. #1688
47
- module = (await import(/* @vite-ignore */ /* webpackIgnore: true */ /* turbopackIgnore: true */ target)) as Record<string, unknown>;
47
+ // The original `with { type: … }` clause must be replayed: a JSON/CSS module re-imported without it always
48
+ // fails, which would report a dependency that actually linked as the broken one. #1818
49
+ module = (await import(/* @vite-ignore */ /* webpackIgnore: true */ /* turbopackIgnore: true */ target, attributes ? { with: attributes } : undefined)) as Record<string, unknown>;
48
50
  } catch (error) {
49
51
  return { specifier, target, detail: `failed to load: ${error instanceof Error ? error.message : String(error)}` };
50
52
  }
@@ -0,0 +1,11 @@
1
+ // Kept out of `compiler.ts` so browser bundles never see `import.meta.resolve`: it is a syntax-level construct, so a
2
+ // bundler emitting CJS produces a file that throws on evaluation no matter which branch would run. #1717
3
+ // Typed off `globalThis` instead of the `Bun` global to avoid forcing `@types/bun` on consumers (we ship raw `.ts`).
4
+ const bun = (globalThis as { Bun?: { file(path: URL): { arrayBuffer(): Promise<ArrayBuffer> } } }).Bun;
5
+
6
+ export const loadNodeWasm = async () => {
7
+ // `import.meta.resolve` yields a `file://` URL under both Bun and Node/Vite SSR, so `new URL` is safe — don't "fix" it with pathToFileURL.
8
+ const wasmUrl = new URL(import.meta.resolve("@esm.sh/tsx/pkg/tsx_bg.wasm"));
9
+ if (bun) return bun.file(wasmUrl).arrayBuffer();
10
+ return (await import("node:fs/promises")).readFile(wasmUrl);
11
+ };