partial-tsx 0.0.3 → 0.0.5

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 +161 -48
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.5",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
package/src/partial.ts CHANGED
@@ -4,7 +4,11 @@ const BLOCK_CLOSERS = new Set(Object.values(BLOCK_FOLLOWERS));
4
4
  export type PartialTsxMode = "streaming" | "final";
5
5
  export type PartialTsxOptions = { mode?: PartialTsxMode };
6
6
  type BlockKind = "function" | "control" | "arrow" | "class" | "method" | "block" | "other";
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 };
7
+ type CompletionFrame = { type: "block"; closer: string; statement: boolean; open?: number; close?: number; headerEnd?: number; jsxExpression?: boolean; statementStart?: number; kind?: BlockKind } | { type: "jsx"; tag: string; open?: number };
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 === "{";
8
12
 
9
13
  const stripMarkdownFence = (code: string) => {
10
14
  let next = code.trim();
@@ -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,9 +321,10 @@ 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;
277
- const before = code.slice(0, index).trimEnd();
324
+ const isLikelyStatementBlock = (code: string, index: number, headerEnd: 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;
327
+ const before = code.slice(0, headerEnd).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
  };
280
330
 
@@ -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) {
@@ -1161,10 +1240,13 @@ const scanStreamingSource = (code: string): StreamingScan => {
1161
1240
  statementRegexBoundary = false;
1162
1241
  const inheritedStart = currentStatementStart();
1163
1242
  const jsxExpression = char === "{" && insideJsxText;
1164
- const kind = jsxExpression ? undefined : getBlockKind(code, index, inheritedStart);
1243
+ // Both classification and later JSX-shell checks must stop before trailing comments. Reuse lexical provenance
1244
+ // instead of rescanning the prefix or stripping comment-like text inside literals (#1814).
1245
+ const headerEnd = lastSignificantEnd;
1246
+ const kind = jsxExpression ? undefined : getBlockKind(code, headerEnd, inheritedStart);
1165
1247
  const statement = !jsxExpression && kind !== "other";
1166
- const statementStart = statement ? findKeywordStart(code, index, inheritedStart, kind ?? "other") : inheritedStart;
1167
- stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement, open: index, jsxExpression, statementStart, kind });
1248
+ const statementStart = statement ? findKeywordStart(code, headerEnd, inheritedStart, kind ?? "other") : inheritedStart;
1249
+ stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement, open: index, headerEnd, jsxExpression, statementStart, kind });
1168
1250
  if (statement && char === "{") scopes.push({ statementStart: index + 1 });
1169
1251
  lastSignificantEnd = index + 1;
1170
1252
  index += 1;
@@ -1193,7 +1275,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1193
1275
  const scope = scopes.at(-1);
1194
1276
  if (scope) scope.statementStart = index + 1;
1195
1277
  }
1196
- if (char === "\n" && !insideJsxText && canCommitBoundary(stack)) {
1278
+ if (char === "\n" && canCommitBoundary(stack)) {
1197
1279
  const scope = scopes.at(-1);
1198
1280
  const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
1199
1281
  if (scope && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(index + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = index + 1;
@@ -1207,7 +1289,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1207
1289
  return { stack, closedBlocks, scopes, regexLiteralStarts, lastSignificantEnd, unclosedLiteralStart, unclosedLiteralStatementStart, incompleteJsxTagStart };
1208
1290
  };
1209
1291
 
1210
- const getFrameHeader = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => code.slice(frame.statementStart ?? 0, frame.open ?? 0).trim();
1292
+ const getFrameHeader = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => code.slice(frame.statementStart ?? 0, frame.headerEnd ?? frame.open ?? 0).trim();
1211
1293
  const isComponentFunctionFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(FUNCTION_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
1212
1294
  const isFunctionDeclarationFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => FUNCTION_HEADER.test(getFrameHeader(code, frame));
1213
1295
  const isComponentClassFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(CLASS_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
@@ -1328,7 +1410,7 @@ const isTypedParameterList = (initializer: string) => {
1328
1410
  };
1329
1411
 
1330
1412
  const hasUnsafeStreamingTypeArgument = (code: string) => {
1331
- const masked = maskLiteralSpans(code);
1413
+ const masked = maskIgnoredTextForExportScan(code);
1332
1414
  if (/(?:^|=)\s*<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)[^>]*$/.test(neutralizeAngleOperators(masked))) return true;
1333
1415
  if (!hasUnclosedTypeArgument(masked)) return false;
1334
1416
  const lastOpen = masked.lastIndexOf("<");
@@ -1345,7 +1427,12 @@ const hasSafeExpressionTail = (tail: string) => {
1345
1427
  if (hasIncompleteControlTail(trimmed) || dropTrailingIncompleteExpressionOperator(trimmed) !== trimmed) return false;
1346
1428
  if (completeTrailingIncompleteTernary(trimmed) !== trimmed) return false;
1347
1429
  if (dropTrailingIncompleteFunctionDeclaration(trimmed) !== trimmed) return false;
1348
- if (hasUnsafeStreamingTypeArgument(trimmed)) return false;
1430
+ // Strip JSX prefix before checking for unclosed type arguments: `<div>text</div>\ntype Foo` should only examine `type Foo`.
1431
+ // `maskIgnoredTextForExportScan` blanks JSX content but preserves `<`/`>` delimiters, which `hasUnclosedTypeArgument` would
1432
+ // misread as type-argument brackets. Find the last complete JSX tag and check only the tail after it.
1433
+ const lastJsxTagEnd = trimmed.lastIndexOf(">");
1434
+ const tailAfterJsx = lastJsxTagEnd >= 0 && endsWithCompleteJsxTag(trimmed.slice(0, lastJsxTagEnd + 1)) ? trimmed.slice(lastJsxTagEnd + 1).trimStart() : trimmed;
1435
+ if (tailAfterJsx && hasUnsafeStreamingTypeArgument(tailAfterJsx)) return false;
1349
1436
  if (/^import\b/.test(trimmed) && !/^import\s+(?:["'][\s\S]*["']|[\s\S]+\s+from\s+["'][\s\S]*["'])\s*;?$/.test(trimmed)) return false;
1350
1437
  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
1438
  if (/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(trimmed)) return false;
@@ -1550,21 +1637,38 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1550
1637
  regexLiteralStarts = committed.regexLiteralStarts;
1551
1638
  }
1552
1639
  let output = "";
1640
+ let pendingTrivia = "";
1641
+ const finish = (text: string, suffix = "") => {
1642
+ const trivia = pendingTrivia;
1643
+ pendingTrivia = "";
1644
+ return `${text}${trivia}${suffix}`;
1645
+ };
1553
1646
  const stack: CompletionFrame[] = [];
1554
1647
  let index = 0;
1648
+ let lastSignificantEnd = 0;
1555
1649
  const push = (value: string) => {
1556
1650
  output += value;
1557
1651
  };
1558
1652
  while (index < code.length) {
1559
1653
  const char = code[index];
1560
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "/") {
1654
+ // Keep completion and committed-prefix scanning in the same lexical mode; never append ')' or ']' to plain text.
1655
+ if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
1656
+ push(char);
1657
+ if (!/\s/.test(char)) lastSignificantEnd = index + 1;
1658
+ index += 1;
1659
+ continue;
1660
+ }
1661
+ if (char === "/" && code[index + 1] === "/") {
1561
1662
  const newlineIndex = code.indexOf("\n", index + 2);
1562
1663
  const end = newlineIndex === -1 ? code.length : newlineIndex;
1563
- push(code.slice(index, end));
1664
+ // A newline alone is not enough: expression cleanup can strip an operator from the comment and eat that newline.
1665
+ // Hold EOF trivia apart until the first surviving frame is complete, then restore it before its synthetic closer.
1666
+ if (newlineIndex === -1 && stack.length) pendingTrivia = `${code.slice(index, end)}\n`;
1667
+ else push(code.slice(index, end));
1564
1668
  index = end;
1565
1669
  continue;
1566
1670
  }
1567
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "*") {
1671
+ if (char === "/" && code[index + 1] === "*") {
1568
1672
  const closeIndex = code.indexOf("*/", index + 2);
1569
1673
  if (closeIndex === -1) {
1570
1674
  push(`${code.slice(index)}*/`);
@@ -1575,16 +1679,22 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1575
1679
  index = closeIndex + 2;
1576
1680
  continue;
1577
1681
  }
1578
- if (!isInsideJsxText(stack) && (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index))) {
1682
+ // Match scanStreamingSource's header boundary without losing comments from the completed output.
1683
+ const headerEnd = lastSignificantEnd;
1684
+ if (!/\s/.test(char)) lastSignificantEnd = index + 1;
1685
+ if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
1579
1686
  index = consumeRegexLiteral(code, index, push);
1687
+ lastSignificantEnd = index;
1580
1688
  continue;
1581
1689
  }
1582
- if (!isInsideJsxText(stack) && (char === '"' || char === "'")) {
1690
+ if (char === '"' || char === "'") {
1583
1691
  index = consumeStringLiteral(code, index, push);
1692
+ lastSignificantEnd = index;
1584
1693
  continue;
1585
1694
  }
1586
- if (!isInsideJsxText(stack) && char === "`") {
1695
+ if (char === "`") {
1587
1696
  index = consumeTemplateLiteral(code, index, push);
1697
+ lastSignificantEnd = index;
1588
1698
  continue;
1589
1699
  }
1590
1700
  if (char === "<" && isInsideJsxText(stack) && !shouldStartJsxTag(code, index, stack) && index < code.length - 1) {
@@ -1613,11 +1723,12 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1613
1723
  const tagName = match?.[1] ?? (tag === "<>" || tag === "</>" ? "" : null);
1614
1724
  if (!(tagName && VOID_TAGS.has(tagName) && tag.startsWith("</"))) push(tagName && VOID_TAGS.has(tagName) && !tag.endsWith("/>") ? tag.replace(/\s*\/?\s*>$/, " />") : tag);
1615
1725
  index = closeIndex + 1;
1726
+ lastSignificantEnd = index;
1616
1727
  updateJsxStackForTag(stack, tag);
1617
1728
  continue;
1618
1729
  }
1619
1730
  if (char in BLOCK_FOLLOWERS) {
1620
- stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index), open: output.length });
1731
+ stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index, headerEnd, stack), open: output.length, jsxExpression: char === "{" && isInsideJsxText(stack) });
1621
1732
  push(char);
1622
1733
  index += 1;
1623
1734
  continue;
@@ -1634,14 +1745,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1634
1745
  }
1635
1746
  const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
1636
1747
  if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
1748
+ const beforeCloser = pendingTrivia ? finish : undefined;
1637
1749
  const closingFrames = stack.toReversed();
1638
1750
  const closers = closingFrames.map(closeFrame).join("");
1639
1751
  if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
1640
1752
  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));
1753
+ if (statementIndex === -1) return finish(closeStackFrames(output, closingFrames, beforeCloser));
1754
+ const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex), beforeCloser);
1643
1755
  const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
1644
- return `${completedExpression}${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`;
1756
+ return finish(completedExpression, `${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`);
1645
1757
  }
1646
1758
 
1647
1759
  export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
@@ -1650,6 +1762,7 @@ export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions =
1650
1762
  // The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
1651
1763
  // false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
1652
1764
  if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
1765
+ exportScanCode = maskAmbientBlocks(exportScanCode);
1653
1766
  if (!hasDefaultExportDeclaration(exportScanCode)) {
1654
1767
  const componentName = findLastExportableComponent(exportScanCode);
1655
1768
  if (componentName) next += `\nexport default ${componentName};`;