partial-tsx 0.0.3 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/partial.ts +143 -42
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "partial-tsx",
3
3
  "description": "Utilities for completing prefix-growing TSX.",
4
- "version": "0.0.3",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
package/src/partial.ts CHANGED
@@ -6,6 +6,10 @@ export type PartialTsxOptions = { mode?: PartialTsxMode };
6
6
  type BlockKind = "function" | "control" | "arrow" | "class" | "method" | "block" | "other";
7
7
  type CompletionFrame = { type: "block"; closer: string; statement: boolean; open?: number; close?: number; jsxExpression?: boolean; statementStart?: number; kind?: BlockKind } | { type: "jsx"; tag: string; open?: number };
8
8
 
9
+ // Single source of truth for the JSX-text fast path in the four lexer loops below: only `<` (child tag) and `{` (child
10
+ // expression) leave text mode, so none of them reads `(`/`[`/quotes/`//` in ordinary text as unfinished JavaScript.
11
+ const isJsxTextBoundary = (char: string) => char === "<" || char === "{";
12
+
9
13
  const stripMarkdownFence = (code: string) => {
10
14
  let next = code.trim();
11
15
  next = next.replace(/^```(?:[^\r\n]*)?(?:\r?\n|$)/i, "");
@@ -78,7 +82,7 @@ const maskIgnoredTextForExportScan = (code: string, regexLiteralStarts?: Readonl
78
82
  const stack: CompletionFrame[] = [];
79
83
  for (let index = 0; index < code.length; index += 1) {
80
84
  const char = code[index];
81
- if (isInsideJsxText(stack) && char !== "<" && char !== "{") {
85
+ if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
82
86
  if (chars[index] !== "\n") chars[index] = " ";
83
87
  continue;
84
88
  }
@@ -157,14 +161,28 @@ const findWrappedComponentCallOpen = (code: string, index: number) => {
157
161
  return code[index] === "(" ? index : -1;
158
162
  };
159
163
  const isWrappedImplementationFunction = (code: string, index: number) => /\b(?:memo|forwardRef)\s*(?:<[\s\S]*>)?\s*\($/.test(code.slice(0, index).trimEnd());
164
+ const isAmbientDeclaration = (code: string, index: number) => /\bdeclare\s+(?:abstract\s+)?$/.test(code.slice(0, index));
165
+ // A class/function expression's own name is bound only inside its body, so the enclosing binding it is assigned to is the
166
+ // exportable one. Without a binding (argument, object value, `return`) nothing is exportable and the candidate is dropped.
167
+ // A lone HOC call (`withTheme(class Inner …)`) is skipped so arbitrary wrappers resolve like the `memo`/`forwardRef` loop below.
168
+ const findExpressionBindingName = (code: string, index: number) => /\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$.]*\s*(?:<[^<>()]*>)?\s*\(\s*)?$/.exec(code.slice(0, index))?.[1] ?? null;
169
+ const isExpressionPosition = (code: string, index: number) => !/(?:^|[;}]|(?<!\$)\{|\n)\s*(?:(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?)$/.test(code.slice(0, index));
160
170
 
161
171
  const findLastExportableComponent = (code: string) => {
162
172
  const candidates: Array<{ name: string; index: number }> = [];
163
173
  for (const match of code.matchAll(/\bfunction\s+([A-Z][\w$]*)\s*\(/g)) {
164
- if (!isWrappedImplementationFunction(code, match.index ?? 0)) candidates.push({ name: match[1], index: match.index ?? 0 });
174
+ const index = match.index ?? 0;
175
+ if (isWrappedImplementationFunction(code, index) || isAmbientDeclaration(code, index)) continue;
176
+ const name = isExpressionPosition(code, index) ? findExpressionBindingName(code, index) : match[1];
177
+ if (name) candidates.push({ name, index });
165
178
  }
166
- for (const match of code.matchAll(/\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:(?:React\.)?(?:Pure)?Component|[A-Za-z_$][\w$]*\.Component)\b/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
167
- for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*=>/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
179
+ for (const match of code.matchAll(/\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:[A-Za-z_$][\w$]*\.)?(?:Pure)?Component\b/g)) {
180
+ const index = match.index ?? 0;
181
+ if (isAmbientDeclaration(code, index)) continue;
182
+ const name = isExpressionPosition(code, index) ? findExpressionBindingName(code, index) : match[1];
183
+ if (name) candidates.push({ name, index });
184
+ }
185
+ for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*(?::[^=;\n]+)?=>/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
168
186
  for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$]*\.)?(?:memo|forwardRef)\s*/g)) {
169
187
  if (findWrappedComponentCallOpen(code, (match.index ?? 0) + match[0].length) !== -1) candidates.push({ name: match[1], index: match.index ?? 0 });
170
188
  }
@@ -174,6 +192,37 @@ const findLastExportableComponent = (code: string) => {
174
192
  // so the scan does not miss an existing default and append a second one (`Duplicate export of 'default'`).
175
193
  const hasDefaultExportDeclaration = (code: string) => /(?:^|[\n;}])\s*export\s+default\s+(?:async\s+)?(?:function|class|[A-Za-z_$]|\(|<)/m.test(code) || /(?:^|[\n;}])\s*export\s*\{[^}]*\b(?:as\s+default|default\s*(?:,|}))[^}]*\}/m.test(code);
176
194
 
195
+ const maskAmbientBlocks = (scanned: string) => {
196
+ const matches = [...scanned.matchAll(/\bdeclare\s+(?:namespace|module|global)\b[^{;]*\{/g)];
197
+ if (!matches.length) return scanned;
198
+ const chars = scanned.split("");
199
+ // Ambient blocks can contain component-shaped declarations, but none survive into the runtime module.
200
+ for (const match of matches) {
201
+ let depth = 1;
202
+ let end = match.index + match[0].length;
203
+ while (end < scanned.length && depth) {
204
+ if (scanned[end] === "{") depth += 1;
205
+ else if (scanned[end] === "}") depth -= 1;
206
+ end += 1;
207
+ }
208
+ maskExportScanRange(chars, match.index, end);
209
+ }
210
+ return chars.join("");
211
+ };
212
+ // Reuse the normalizer's lexical scan: component declarations inside comments or strings are not preview entry points.
213
+ export function hasReactComponent(code: string) {
214
+ const scanned = maskAmbientBlocks(maskIgnoredTextForExportScan(code));
215
+ if (findLastExportableComponent(scanned)) return true;
216
+ for (const match of scanned.matchAll(/(?:^|[\n;}])\s*export\s+default\s+(?:async\s+)?/g)) {
217
+ const start = match.index + match[0].length;
218
+ const expression = scanned.slice(start);
219
+ if (/^(?:function\b|class\s+(?:[A-Za-z_$][\w$]*\s+)?extends\s+(?:[A-Za-z_$][\w$]*\.)?(?:Pure)?Component\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=;\n]+)?=>)/.test(expression)) return true;
220
+ const wrapper = /^(?:[A-Za-z_$][\w$]*\.)?(?:memo|forwardRef)\b/.exec(expression);
221
+ if (wrapper && findWrappedComponentCallOpen(scanned, start + wrapper[0].length) !== -1) return true;
222
+ }
223
+ return false;
224
+ }
225
+
177
226
  // React hooks the model commonly forgets to import. Stay conservative: anything outside this set (custom hooks, react-dom hooks like useFormStatus) is left alone.
178
227
  const REACT_HOOK_NAMES = new Set([
179
228
  "useState",
@@ -272,8 +321,9 @@ const isLikelyJsxTagStart = (code: string, index: number) => {
272
321
  return previousChar !== ">";
273
322
  };
274
323
 
275
- const isLikelyStatementBlock = (code: string, index: number) => {
276
- if (code[index] !== "{") return false;
324
+ const isLikelyStatementBlock = (code: string, index: number, stack: CompletionFrame[]) => {
325
+ // A JSX child expression stays an expression even when the preceding prose looks like a JavaScript block header.
326
+ if (code[index] !== "{" || isInsideJsxText(stack)) return false;
277
327
  const before = code.slice(0, index).trimEnd();
278
328
  return /\bfunction(?:\s+[\w$]+)?\s*\([^)]*\)$/.test(before) || /\b(?:if|for|while|switch|with)\s*\([^)]*\)$/.test(before) || /\bcatch(?:\s*\([^)]*\))?$/.test(before) || /(?:^|[^\w$])(?:else|try|finally|do)$/.test(before) || before.endsWith("=>");
279
329
  };
@@ -475,25 +525,26 @@ const collectCompletionStackBefore = (code: string, end: number) => {
475
525
  const stack: CompletionFrame[] = [];
476
526
  for (let index = 0; index < end; index += 1) {
477
527
  const char = code[index];
478
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "/") {
528
+ if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) continue;
529
+ if (char === "/" && code[index + 1] === "/") {
479
530
  const newlineIndex = code.indexOf("\n", index + 2);
480
531
  index = (newlineIndex === -1 ? end : Math.min(newlineIndex, end)) - 1;
481
532
  continue;
482
533
  }
483
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "*") {
534
+ if (char === "/" && code[index + 1] === "*") {
484
535
  const closeIndex = code.indexOf("*/", index + 2);
485
536
  index = (closeIndex === -1 ? end : Math.min(closeIndex + 2, end)) - 1;
486
537
  continue;
487
538
  }
488
- if (!isInsideJsxText(stack) && isLikelyRegexLiteralStart(code, index)) {
539
+ if (isLikelyRegexLiteralStart(code, index)) {
489
540
  index = Math.min(findRegexLiteralEnd(code, index), end) - 1;
490
541
  continue;
491
542
  }
492
- if (!isInsideJsxText(stack) && (char === '"' || char === "'")) {
543
+ if (char === '"' || char === "'") {
493
544
  index = Math.min(findStringLiteralEnd(code, index), end) - 1;
494
545
  continue;
495
546
  }
496
- if (!isInsideJsxText(stack) && char === "`") {
547
+ if (char === "`") {
497
548
  index = Math.min(findTemplateLiteralEnd(code, index), end) - 1;
498
549
  continue;
499
550
  }
@@ -504,7 +555,7 @@ const collectCompletionStackBefore = (code: string, end: number) => {
504
555
  index = closeIndex;
505
556
  continue;
506
557
  }
507
- if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index) });
558
+ if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: false });
508
559
  else if (BLOCK_CLOSERS.has(char)) {
509
560
  const frame = stack.at(-1);
510
561
  if (frame?.type === "block" && frame.closer === char) stack.pop();
@@ -629,11 +680,13 @@ const hasUnclosedTypeArgument = (body: string) => {
629
680
  return true;
630
681
  };
631
682
  // Locate the last `const`/`let`/`var` whose declaration runs to EOF without a depth-0 `;` terminator, scanning the
632
- // literal-masked text so a `;`/`{`/`<` sitting inside a string or comment (`const s = "a; const x = useState<T"`) is never
683
+ // JSX/literal-masked text so prose declarations and a `;`/`{`/`<` inside a string or comment (`const s = "a; const x = useState<T"`) are never
633
684
  // mistaken for a statement boundary. A declaration can begin at the buffer start or right after `{`, `}`, `;`, or `=>`; we keep
634
685
  // the latest one that isn't closed by a depth-0 `;` or by a `)`/`]`/`}` that pops out of its enclosing block.
635
686
  const locateTrailingDeclaration = (value: string) => {
636
- const masked = maskLiteralSpans(value);
687
+ // Masking cannot create a keyword, so skip the JSX scan on keyword-free streaming prefixes.
688
+ if (!/\b(?:const|let|var)\b/.test(value)) return null;
689
+ const masked = maskIgnoredTextForExportScan(value);
637
690
  const re = /(?:^|[{};\n]|=>)\s*(const(?!\s+enum\b)|let|var)\b/g;
638
691
  let match: RegExpExecArray | null;
639
692
  let start = -1;
@@ -732,18 +785,25 @@ const isCompleteDoWhileStatement = (value: string, scan = scanStreamingSource(va
732
785
  return false;
733
786
  };
734
787
  const dropTrailingBareLoopHeader = (value: string) => {
735
- const masked = maskLiteralSpans(value).trimEnd();
788
+ // Masking only ever blanks spans, so it can neither synthesize a `)` nor the letters of the keyword: a value missing
789
+ // either can never yield a droppable header, and skipping the mask here saves it on most calls of a stream. This does
790
+ // NOT license hoisting the `endsWith(")")` check below — blanking trailing trivia changes *which* `)` is last, so
791
+ // `while (a) // note` masks to a droppable header the raw value does not end with.
792
+ if (!value.includes(")") || !/\b(?:for|while)\b/.test(value)) return value;
793
+ // Mask JSX too, not just literals: prose like `<p>run for (i` ends in a synthesized `)` closer whose matching `(` sits in
794
+ // committed text, and `maskLiteralSpans` would let the preceding word read as a real `for`/`while` keyword — rolling the
795
+ // already-rendered JSX back to `<p>run`.
796
+ const masked = maskIgnoredTextForExportScan(value).trimEnd();
736
797
  if (!masked.endsWith(")")) return value;
737
798
  const index = findMatchingOpener(masked, "(", ")");
738
799
  if (index <= 0) return value;
739
800
  const head = masked.slice(0, index).trimEnd();
740
- const match = /\b(for|while)$/.exec(head);
801
+ const match = /\b(for(?:\s+await)?|while)$/.exec(head);
741
802
  if (!match) return value;
742
803
  const before = head.slice(0, match.index);
743
- // A reserved word used as a member name or identifier suffix is a reference, not a loop: `obj.for(x)`, `arr?.while(p)`,
744
- // `a$for(1)` must survive intact. `\b` treats `.` and `$` as boundaries, so guard both — `$` is the only identifier-
745
- // continuation char that can precede the keyword (letters/digits/`_` are word chars `\b` would not break on).
746
- if (/[.$]$/.test(before.trimEnd())) return value;
804
+ // `\b` only understands ASCII word characters: member names and Unicode/escaped identifier suffixes are not loops.
805
+ // Keep whitespace for the identifier check so a separate keyword after an identifier still gets dropped.
806
+ if (/[.#]$/.test(before.trimEnd()) || /[$\p{ID_Continue}\u200c\u200d]$|\\u\{[\da-f]+\}$/iu.test(before)) return value;
747
807
  // `do { ... } while (cond)` is a complete statement that needs no body — keep it. Tell it apart from a plain `while` that
748
808
  // merely follows another block (`while(a){...} while(b`) by parsing the source and checking this trailing `while` closes a `do`.
749
809
  if (match[1] === "while" && isCompleteDoWhileStatement(value)) return value;
@@ -757,7 +817,9 @@ const dropTrailingBareLoopHeader = (value: string) => {
757
817
  // inner frames whose openers were inside the rolled-back span.
758
818
  const completeBlockFrame = (output: string, frame: Extract<CompletionFrame, { type: "block" }>) => {
759
819
  if (frame.statement) return { text: `${output}${frame.closer}`, dropped: false };
760
- const completed = completeIncompleteExpressionPrefix(output, true);
820
+ // Clean JSX child expressions before `}` hides their loop header; other frames can contain valid type methods `for(...)`.
821
+ // Drop the header before completing its prefix: it can expose an operator or be hidden by a synthesized ternary branch.
822
+ const completed = completeIncompleteExpressionPrefix(frame.jsxExpression ? dropTrailingBareLoopHeader(output) : output, true);
761
823
  const openerGone = frame.open !== undefined ? completed.length <= frame.open : completed.length < output.length;
762
824
  if (openerGone) return { text: completed, dropped: true };
763
825
  return { text: `${completed}${frame.closer}`, dropped: false };
@@ -779,12 +841,16 @@ const completeStatementPrefixFrame = (output: string, frame: CompletionFrame): {
779
841
  // Close frames from the stack top down. A frame whose own opening bracket was rolled back by a declaration drop must not emit a
780
842
  // closer — `completeBlockFrame` detects that and reports `open: number` for the bracket position it would have closed, so once
781
843
  // a drop happens we know which later frames lost their opener (those whose recorded bracket offset is past the drop point).
782
- const closeStackFrames = (output: string, frames: CompletionFrame[]) => {
844
+ const closeStackFrames = (output: string, frames: CompletionFrame[], beforeCloser?: (text: string, closer: string) => string) => {
783
845
  let text = output;
784
846
  let dropPoint = Infinity;
785
847
  for (const frame of frames) {
786
848
  if (frame.type === "block" && frame.open !== undefined && frame.open >= dropPoint) continue;
787
849
  const result = completeStatementPrefixFrame(text, frame);
850
+ if (beforeCloser && !result.dropped) {
851
+ const closer = closeFrame(frame);
852
+ result.text = beforeCloser(result.text.slice(0, -closer.length), closer);
853
+ }
788
854
  if (result.dropped) dropPoint = Math.min(dropPoint, result.text.length);
789
855
  text = result.text;
790
856
  }
@@ -1008,7 +1074,7 @@ const isAsiBoundary = (before: string, after: string, leftEndsWithRegexLiteral =
1008
1074
  const endsWithContinuingPunctuator = /[=+\-*/%&|^~?!<>.:,([]$/.test(left) && !endsWithPostfixUpdate(left) && !(left.endsWith("/") && leftEndsWithRegexLiteral) && !(left.endsWith(">") && endsWithCompleteJsxTag(left));
1009
1075
  if (!left || !right || endsWithContinuingPunctuator || right.startsWith(".") || LINE_CONTINUING_KEYWORD_OPERATOR.test(left)) return false;
1010
1076
  if (FUNCTION_HEADER.test(left) || (/(?:^|\n)\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/.test(left) && !hasTopLevelClassBody(left))) return false;
1011
- return /^(?:const|let|var|return|throw|if|else|for|while|switch|try|catch|finally|do|function|class|import|export)\b|^[A-Za-z_$][\w$]*(?:\s*(?:[=(]|$))/.test(right);
1077
+ return /^(?:const|let|var|return|throw|if|else|for|while|switch|try|catch|finally|do|function|class|import|export|type)\b|^[A-Za-z_$][\w$]*(?:\s*(?:[=(]|$))/.test(right);
1012
1078
  };
1013
1079
 
1014
1080
  const isClosedQuotedLiteral = (code: string, start: number, end: number, quote: string) => end > start + 1 && code[end - 1] === quote && (code.slice(start, end - 1).match(/\\+$/)?.[0].length ?? 0) % 2 === 0;
@@ -1087,9 +1153,22 @@ const scanStreamingSource = (code: string): StreamingScan => {
1087
1153
  unclosedLiteralStatementStart = isTrailingClosedRegexLiteral(code, index, lastRegexLiteralStart) || (Boolean(statement.trim()) && hasSafeExpressionTail(statement)) ? index : statementStart;
1088
1154
  };
1089
1155
  while (index < code.length) {
1090
- const insideJsxText = stack.at(-1)?.type === "jsx";
1156
+ const insideJsxText = isInsideJsxText(stack);
1091
1157
  const char = code[index];
1092
- if (!insideJsxText && char === "/" && code[index + 1] === "/") {
1158
+ // A label such as "shape (E, r" is committed JSX text, not an unfinished JS call that can roll back the whole return.
1159
+ // Text advances `lastSignificantEnd` but must NOT reach the `;`/`\n` boundary gates below: committing a statement at a
1160
+ // `;` in prose would move the rollback cut into the label. Kept parallel to the shared tail rather than falling through
1161
+ // to it for that reason. `statementRegexBoundary` is only ever set by a statement `}` pop, which leaves a block frame on
1162
+ // top, so it is already false here — mirroring the tail costs one line and keeps the two blocks diffable.
1163
+ if (insideJsxText && !isJsxTextBoundary(char)) {
1164
+ if (!/\s/.test(char)) {
1165
+ statementRegexBoundary = false;
1166
+ lastSignificantEnd = index + 1;
1167
+ }
1168
+ index += 1;
1169
+ continue;
1170
+ }
1171
+ if (char === "/" && code[index + 1] === "/") {
1093
1172
  const newlineIndex = code.indexOf("\n", index + 2);
1094
1173
  if (newlineIndex === -1) {
1095
1174
  markDisposableComment(); // the comment itself is disposable; preserve only a complete statement before it
@@ -1101,7 +1180,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1101
1180
  index = newlineIndex + 1;
1102
1181
  continue;
1103
1182
  }
1104
- if (!insideJsxText && char === "/" && code[index + 1] === "*") {
1183
+ if (char === "/" && code[index + 1] === "*") {
1105
1184
  const closeIndex = code.indexOf("*/", index + 2);
1106
1185
  if (closeIndex === -1) {
1107
1186
  markDisposableComment();
@@ -1110,7 +1189,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1110
1189
  index = closeIndex + 2;
1111
1190
  continue;
1112
1191
  }
1113
- if (!insideJsxText && ((statementRegexBoundary && char === "/") || isLikelyRegexLiteralStart(code, index))) {
1192
+ if ((statementRegexBoundary && char === "/") || isLikelyRegexLiteralStart(code, index)) {
1114
1193
  regexLiteralStarts.add(index);
1115
1194
  lastRegexLiteralStart = index;
1116
1195
  statementRegexBoundary = false;
@@ -1123,7 +1202,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1123
1202
  index = end;
1124
1203
  continue;
1125
1204
  }
1126
- if (!insideJsxText && (char === '"' || char === "'")) {
1205
+ if (char === '"' || char === "'") {
1127
1206
  statementRegexBoundary = false;
1128
1207
  const end = findStringLiteralEnd(code, index);
1129
1208
  if (!isClosedQuotedLiteral(code, index, end, char)) {
@@ -1134,7 +1213,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1134
1213
  index = end;
1135
1214
  continue;
1136
1215
  }
1137
- if (!insideJsxText && char === "`") {
1216
+ if (char === "`") {
1138
1217
  statementRegexBoundary = false;
1139
1218
  const literal = scanTemplateLiteralEnd(code, index);
1140
1219
  if (!literal.closed) {
@@ -1193,7 +1272,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1193
1272
  const scope = scopes.at(-1);
1194
1273
  if (scope) scope.statementStart = index + 1;
1195
1274
  }
1196
- if (char === "\n" && !insideJsxText && canCommitBoundary(stack)) {
1275
+ if (char === "\n" && canCommitBoundary(stack)) {
1197
1276
  const scope = scopes.at(-1);
1198
1277
  const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
1199
1278
  if (scope && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(index + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = index + 1;
@@ -1328,7 +1407,7 @@ const isTypedParameterList = (initializer: string) => {
1328
1407
  };
1329
1408
 
1330
1409
  const hasUnsafeStreamingTypeArgument = (code: string) => {
1331
- const masked = maskLiteralSpans(code);
1410
+ const masked = maskIgnoredTextForExportScan(code);
1332
1411
  if (/(?:^|=)\s*<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)[^>]*$/.test(neutralizeAngleOperators(masked))) return true;
1333
1412
  if (!hasUnclosedTypeArgument(masked)) return false;
1334
1413
  const lastOpen = masked.lastIndexOf("<");
@@ -1345,7 +1424,12 @@ const hasSafeExpressionTail = (tail: string) => {
1345
1424
  if (hasIncompleteControlTail(trimmed) || dropTrailingIncompleteExpressionOperator(trimmed) !== trimmed) return false;
1346
1425
  if (completeTrailingIncompleteTernary(trimmed) !== trimmed) return false;
1347
1426
  if (dropTrailingIncompleteFunctionDeclaration(trimmed) !== trimmed) return false;
1348
- if (hasUnsafeStreamingTypeArgument(trimmed)) return false;
1427
+ // Strip JSX prefix before checking for unclosed type arguments: `<div>text</div>\ntype Foo` should only examine `type Foo`.
1428
+ // `maskIgnoredTextForExportScan` blanks JSX content but preserves `<`/`>` delimiters, which `hasUnclosedTypeArgument` would
1429
+ // misread as type-argument brackets. Find the last complete JSX tag and check only the tail after it.
1430
+ const lastJsxTagEnd = trimmed.lastIndexOf(">");
1431
+ const tailAfterJsx = lastJsxTagEnd >= 0 && endsWithCompleteJsxTag(trimmed.slice(0, lastJsxTagEnd + 1)) ? trimmed.slice(lastJsxTagEnd + 1).trimStart() : trimmed;
1432
+ if (tailAfterJsx && hasUnsafeStreamingTypeArgument(tailAfterJsx)) return false;
1349
1433
  if (/^import\b/.test(trimmed) && !/^import\s+(?:["'][\s\S]*["']|[\s\S]+\s+from\s+["'][\s\S]*["'])\s*;?$/.test(trimmed)) return false;
1350
1434
  if (/^export\s+(?:d(?:e(?:f(?:a(?:u(?:l(?:t)?)?)?)?)?)?)?\s*$/.test(trimmed) || /^export\s+default\s+[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
1351
1435
  if (/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(trimmed)) return false;
@@ -1550,6 +1634,12 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1550
1634
  regexLiteralStarts = committed.regexLiteralStarts;
1551
1635
  }
1552
1636
  let output = "";
1637
+ let pendingTrivia = "";
1638
+ const finish = (text: string, suffix = "") => {
1639
+ const trivia = pendingTrivia;
1640
+ pendingTrivia = "";
1641
+ return `${text}${trivia}${suffix}`;
1642
+ };
1553
1643
  const stack: CompletionFrame[] = [];
1554
1644
  let index = 0;
1555
1645
  const push = (value: string) => {
@@ -1557,14 +1647,23 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1557
1647
  };
1558
1648
  while (index < code.length) {
1559
1649
  const char = code[index];
1560
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "/") {
1650
+ // Keep completion and committed-prefix scanning in the same lexical mode; never append ')' or ']' to plain text.
1651
+ if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
1652
+ push(char);
1653
+ index += 1;
1654
+ continue;
1655
+ }
1656
+ if (char === "/" && code[index + 1] === "/") {
1561
1657
  const newlineIndex = code.indexOf("\n", index + 2);
1562
1658
  const end = newlineIndex === -1 ? code.length : newlineIndex;
1563
- push(code.slice(index, end));
1659
+ // A newline alone is not enough: expression cleanup can strip an operator from the comment and eat that newline.
1660
+ // Hold EOF trivia apart until the first surviving frame is complete, then restore it before its synthetic closer.
1661
+ if (newlineIndex === -1 && stack.length) pendingTrivia = `${code.slice(index, end)}\n`;
1662
+ else push(code.slice(index, end));
1564
1663
  index = end;
1565
1664
  continue;
1566
1665
  }
1567
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "*") {
1666
+ if (char === "/" && code[index + 1] === "*") {
1568
1667
  const closeIndex = code.indexOf("*/", index + 2);
1569
1668
  if (closeIndex === -1) {
1570
1669
  push(`${code.slice(index)}*/`);
@@ -1575,15 +1674,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1575
1674
  index = closeIndex + 2;
1576
1675
  continue;
1577
1676
  }
1578
- if (!isInsideJsxText(stack) && (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index))) {
1677
+ if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
1579
1678
  index = consumeRegexLiteral(code, index, push);
1580
1679
  continue;
1581
1680
  }
1582
- if (!isInsideJsxText(stack) && (char === '"' || char === "'")) {
1681
+ if (char === '"' || char === "'") {
1583
1682
  index = consumeStringLiteral(code, index, push);
1584
1683
  continue;
1585
1684
  }
1586
- if (!isInsideJsxText(stack) && char === "`") {
1685
+ if (char === "`") {
1587
1686
  index = consumeTemplateLiteral(code, index, push);
1588
1687
  continue;
1589
1688
  }
@@ -1617,7 +1716,7 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1617
1716
  continue;
1618
1717
  }
1619
1718
  if (char in BLOCK_FOLLOWERS) {
1620
- stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index), open: output.length });
1719
+ stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index, stack), open: output.length, jsxExpression: char === "{" && isInsideJsxText(stack) });
1621
1720
  push(char);
1622
1721
  index += 1;
1623
1722
  continue;
@@ -1634,14 +1733,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1634
1733
  }
1635
1734
  const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
1636
1735
  if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
1736
+ const beforeCloser = pendingTrivia ? finish : undefined;
1637
1737
  const closingFrames = stack.toReversed();
1638
1738
  const closers = closingFrames.map(closeFrame).join("");
1639
1739
  if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
1640
1740
  const statementIndex = closingFrames.findIndex((frame) => frame.type === "block" && frame.statement);
1641
- if (statementIndex === -1) return closeStackFrames(output, closingFrames);
1642
- const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex));
1741
+ if (statementIndex === -1) return finish(closeStackFrames(output, closingFrames, beforeCloser));
1742
+ const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex), beforeCloser);
1643
1743
  const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
1644
- return `${completedExpression}${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`;
1744
+ return finish(completedExpression, `${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`);
1645
1745
  }
1646
1746
 
1647
1747
  export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
@@ -1650,6 +1750,7 @@ export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions =
1650
1750
  // The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
1651
1751
  // false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
1652
1752
  if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
1753
+ exportScanCode = maskAmbientBlocks(exportScanCode);
1653
1754
  if (!hasDefaultExportDeclaration(exportScanCode)) {
1654
1755
  const componentName = findLastExportableComponent(exportScanCode);
1655
1756
  if (componentName) next += `\nexport default ${componentName};`;