fix(browse): chained IIFE + second statement no longer misclassified as one expression

isSingleParenOrIifeExpression accepted any tail after the initial group's
close as long as trailing chars looked chain-ish, so
`(async()=>{await 1})().then(x=>x); console.log('done')` classified as a
single expression and the expression wrapper emitted a SyntaxError. The
tail is now consumed as a strict member/call/index/optional-chain walk to
END of input via a shared string/escape-aware findBalancedClose scanner;
anything else (';', operators) demotes to the block wrapper. Negative +
positive tests added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-01 16:07:22 +00:00
co-authored by Claude Fable 5
parent 159b48e813
commit ce3f3848a7
2 changed files with 74 additions and 46 deletions
+52 -46
View File
@@ -29,23 +29,17 @@ export function hasAwait(code: string): boolean {
}
/**
* Detect whether code is a single top-level expression (such as an IIFE or parenthesized expression),
* even if it contains internal statements, semicolons, or multiple lines (#2727).
* Find the index of the bracket that closes the group opened at `start`
* (which must be `(` or `[`), skipping string literals and escapes.
* Returns -1 when unbalanced. Shared by the single-expression scanner below.
*/
export function isSingleParenOrIifeExpression(code: string): boolean {
const trimmed = code.trim().replace(/;+\s*$/, '');
let src = trimmed;
if (src.startsWith('await ') || src.startsWith('await\t') || src.startsWith('await\n')) {
src = src.slice(5).trim();
}
if (!src.startsWith('(')) return false;
function findBalancedClose(src: string, start: number): number {
const open = src[start];
const close = open === '(' ? ')' : ']';
let depth = 0;
let inString: string | null = null;
let escape = false;
let mainCloseIndex = -1;
for (let i = 0; i < src.length; i++) {
for (let i = start; i < src.length; i++) {
const char = src[i];
if (escape) {
escape = false;
@@ -63,49 +57,61 @@ export function isSingleParenOrIifeExpression(code: string): boolean {
inString = char;
continue;
}
if (char === '(') {
if (char === open) {
depth++;
} else if (char === ')') {
} else if (char === close) {
depth--;
if (depth === 0) {
mainCloseIndex = i;
break;
}
if (depth === 0) return i;
}
}
return -1;
}
/**
* Detect whether code is a single top-level expression (such as an IIFE or parenthesized expression),
* even if it contains internal statements, semicolons, or multiple lines (#2727).
*
* The tail after the initial `(...)` group must be a CONTINUOUS member/call/
* index chain consumed to end-of-input. Accepting any tail that merely starts
* with `(` or `.` classified `(iife)().then(x=>x); stmt` as a single
* expression, and the expression wrapper then emitted a SyntaxError.
*/
export function isSingleParenOrIifeExpression(code: string): boolean {
const trimmed = code.trim().replace(/;+\s*$/, '');
let src = trimmed;
if (src.startsWith('await ') || src.startsWith('await\t') || src.startsWith('await\n')) {
src = src.slice(5).trim();
}
if (!src.startsWith('(')) return false;
const mainCloseIndex = findBalancedClose(src, 0);
if (mainCloseIndex === -1) return false;
const rest = src.slice(mainCloseIndex + 1).trim();
if (rest === '') return true;
if (rest.startsWith('(')) {
let callDepth = 0;
let callCloseIndex = -1;
let inStr: string | null = null;
let esc = false;
for (let i = 0; i < rest.length; i++) {
const c = rest[i];
if (esc) { esc = false; continue; }
if (c === '\\' && inStr) { esc = true; continue; }
if (inStr) { if (c === inStr) inStr = null; continue; }
if (c === '"' || c === "'" || c === '`') { inStr = c; continue; }
if (c === '(') callDepth++;
else if (c === ')') {
callDepth--;
if (callDepth === 0) {
callCloseIndex = i;
break;
}
}
// Consume the ENTIRE tail as a chain of `.member`, `(...)`, `[...]`, or
// optional-chaining segments. Anything else (a `;`, a second statement,
// an operator) means this is not a single expression.
let i = mainCloseIndex + 1;
while (i < src.length) {
const ch = src[i];
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
i++;
continue;
}
if (callCloseIndex !== -1) {
const afterCall = rest.slice(callCloseIndex + 1).trim();
if (afterCall === '' || afterCall.startsWith('.')) return true;
if (ch === '(' || ch === '[') {
const close = findBalancedClose(src, i);
if (close === -1) return false;
i = close + 1;
continue;
}
if (ch === '.' || (ch === '?' && src[i + 1] === '.')) {
i += ch === '.' ? 1 : 2;
// Member name (or the `(`/`[` of `?.()` / `?.[]`, handled next loop).
while (i < src.length && /[\w$]/.test(src[i])) i++;
continue;
}
return false;
}
return false;
return true;
}
/** Detect whether code needs a block wrapper {…} vs expression wrapper (…) inside an async IIFE. */
+22
View File
@@ -6,6 +6,28 @@ import {
wrapForEvaluate,
} from '../src/read-commands';
// Regression: a chained IIFE followed by a SECOND statement was classified as
// a single expression, and the expression wrapper emitted a SyntaxError. The
// tail after the initial group must be a continuous member/call/index chain
// to END of input.
describe('chained IIFE followed by a second statement', () => {
it('is NOT a single expression and wraps to syntactically valid code', () => {
const code = "(async()=>{await 1})().then(x=>x); console.log('done')";
expect(isSingleParenOrIifeExpression(code)).toBe(false);
const wrapped = wrapForEvaluate(code);
expect(() => new Function('return ' + wrapped)).not.toThrow();
});
it('still accepts a pure chained IIFE (with optional chaining and index access)', () => {
expect(isSingleParenOrIifeExpression('(async()=>{await 1})().then(x=>x)')).toBe(true);
expect(isSingleParenOrIifeExpression('(getObj())?.items[0].run()')).toBe(true);
});
it('rejects an operator tail', () => {
expect(isSingleParenOrIifeExpression('(a)() + 1')).toBe(false);
});
});
describe('browse js / eval wrapping (#2727)', () => {
it('detects presence of await keyword', () => {
expect(hasAwait('await Promise.resolve(1)')).toBe(true);