From 3447e9a1c13e6801ac6c3b9a455f70ed2f43d503 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 19:20:54 -0500 Subject: [PATCH] Unwrap a UMD wrapper only when its body is exactly TypeScript's emit The wrapper is dropped when its factory body moves to the top level, so any other statement in the wrapper, in its CommonJS branch or in its AMD branch would be lost. The check now matches the whole wrapper body and keeps the original text for any other shape. --- .../src/frontend/npm-static-umd.test.ts | 12 ++++ .../compiler/src/frontend/npm-static-umd.ts | 72 +++++++++++-------- 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-umd.test.ts b/packages/compiler/src/frontend/npm-static-umd.test.ts index 5280ecb17..14e0d5ea9 100644 --- a/packages/compiler/src/frontend/npm-static-umd.test.ts +++ b/packages/compiler/src/frontend/npm-static-umd.test.ts @@ -104,6 +104,18 @@ test("other shapes keep the original text", () => { expect(unwrap(umd(` exports.a = 1;`, "req, exp"))).toBeNull(); // Code after the wrapper. expect(unwrap(umd(` exports.a = 1;`) + "exports.b = 2;\n")).toBeNull(); + // Other statements in the wrapper, which unwrapping would drop. + const wrapped = umd(` exports.a = 1;`); + const call = " var v = factory(require, exports);\n"; + expect(unwrap(wrapped.replace(call, call + " exports.b = 2;\n"))).toBeNull(); + expect(unwrap(wrapped.replace(call, " exports.b = 2;\n" + call))).toBeNull(); + expect(unwrap(wrapped.replace(" }\n})(", " }\n exports.b = 2;\n})("))).toBeNull(); + expect( + unwrap(wrapped.replace("define.amd) {", "define.amd) {\n exports.b = 2;")), + ).toBeNull(); + expect( + unwrap(wrapped.replace("define(", "exports.b = 2;\n }\n else {\n define(")), + ).toBeNull(); // Not a UMD wrapper. expect(unwrap(`exports.a = 1;\n`)).toBeNull(); }); diff --git a/packages/compiler/src/frontend/npm-static-umd.ts b/packages/compiler/src/frontend/npm-static-umd.ts index 88400e046..4a5db5065 100644 --- a/packages/compiler/src/frontend/npm-static-umd.ts +++ b/packages/compiler/src/frontend/npm-static-umd.ts @@ -23,8 +23,8 @@ * the module's top level, where its requires and export assignments are the * module's own. * - * Accepted: the wrapper is the file's only statement, its CommonJS branch - * calls the factory as `factory(require, exports)`, the factory's + * Accepted: the wrapper is the file's only statement, the wrapper's body is + * exactly the emit above (so dropping it drops nothing else), the factory's * parameters are exactly `require` and `exports`, and the body uses no * `return` or `arguments` of its own. `this` in the body is the factory's * receiver, not the module's: it may appear only in TypeScript's helper @@ -54,7 +54,7 @@ export function unwrapUmdFactory(sf: ts.SourceFile): string | null { const wrapperParams = wrapper.parameters.nodes; if (wrapperParams.length !== 1 || !isPlainParameter(wrapperParams[0]!)) return null; const factoryName = (wrapperParams[0]!.name as ts.Identifier).text; - if (!callsFactoryForCommonJs(wrapper, factoryName)) return null; + if (!isTypeScriptUmdWrapper(wrapper, factoryName)) return null; const params = factory.parameters.nodes; if ( params.length !== 2 || @@ -83,35 +83,49 @@ function isPlainParameter(p: ts.ParameterDeclaration): boolean { return ts.isIdentifier(p.name) && p.initializer === undefined && p.dotDotDotToken === undefined; } -/** True when the wrapper's first statement is TypeScript's CommonJS test, - * `if (typeof module === "object" && typeof module.exports === "object")`, - * whose branch starts by calling the factory as `factory(require, exports)`. */ -function callsFactoryForCommonJs(wrapper: ts.FunctionExpression, factoryName: string): boolean { - const first = wrapper.body?.statements.nodes[0]; - if (first === undefined || !ts.isIfStatement(first)) return false; +/** True when the wrapper body is exactly TypeScript's emit and nothing else: + * one `if` statement whose CommonJS branch holds only the factory call and + * the `module.exports` assignment, and whose `else`, when present, is only + * the AMD branch: + * + * if (typeof module === "object" && typeof module.exports === "object") { + * var v = factory(require, exports); + * if (v !== undefined) module.exports = v; + * } + * else if (typeof define === "function" && define.amd) { + * define([...], factory); + * } + * + * Unwrapping drops the wrapper, so any other statement in it would be lost. */ +function isTypeScriptUmdWrapper(wrapper: ts.FunctionExpression, factoryName: string): boolean { + const stmts = wrapper.body?.statements.nodes ?? []; + if (stmts.length !== 1 || !ts.isIfStatement(stmts[0]!)) return false; + const test = stmts[0]; const sf = ts.getSourceFileOfNode(wrapper); - const condition = sf.text.slice(tokenStartOfNode(first.expression), first.expression.end); - if ( - condition.replace(/\s+/g, " ") !== - 'typeof module === "object" && typeof module.exports === "object"' - ) + const text = (n: ts.Node): string => + sf.text.slice(tokenStartOfNode(n), n.end).replace(/\s+/g, " "); + if (text(test.expression) !== 'typeof module === "object" && typeof module.exports === "object"') return false; - const branch = first.thenStatement; - const call = ts.isBlock(branch) ? branch.statements.nodes[0] : undefined; - if (call === undefined || !ts.isVariableStatement(call)) return false; + const branch = test.thenStatement; + if (!ts.isBlock(branch) || branch.statements.nodes.length !== 2) return false; + const [call, assign] = branch.statements.nodes as [ts.Statement, ts.Statement]; + if (!ts.isVariableStatement(call)) return false; const declarations = call.declarationList.declarations.nodes; - const init = declarations.length === 1 ? declarations[0]!.initializer : undefined; - if (init === undefined || !ts.isCallExpression(init)) return false; - const args = init.arguments.nodes; - return ( - ts.isIdentifier(init.expression) && - init.expression.text === factoryName && - args.length === 2 && - ts.isIdentifier(args[0]!) && - args[0].text === "require" && - ts.isIdentifier(args[1]!) && - args[1].text === "exports" - ); + const name = declarations.length === 1 ? declarations[0]!.name : undefined; + if (name === undefined || !ts.isIdentifier(name)) return false; + const result = name.text; + if (text(call) !== `var ${result} = ${factoryName}(require, exports);`) return false; + if (text(assign) !== `if (${result} !== undefined) module.exports = ${result};`) return false; + const amd = test.elseStatement; + if (amd === undefined) return true; + if (!ts.isIfStatement(amd) || amd.elseStatement !== undefined) return false; + if (text(amd.expression) !== 'typeof define === "function" && define.amd') return false; + const define = ts.isBlock(amd.thenStatement) ? amd.thenStatement.statements.nodes : []; + if (define.length !== 1 || !ts.isExpressionStatement(define[0]!)) return false; + const defineCall = define[0].expression; + if (!ts.isCallExpression(defineCall) || text(defineCall.expression) !== "define") return false; + const last = defineCall.arguments.nodes.at(-1); + return last !== undefined && ts.isIdentifier(last) && last.text === factoryName; } /** True when the factory body behaves the same as the module's top level: