com.google.javascript.jscomp.IsolatePolyfills Maven / Gradle / Ivy
Show all versions of closure-compiler-unshaded Show documentation
/*
* Copyright 2020 The Closure Compiler Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.javascript.jscomp;
import static com.google.common.base.Preconditions.checkNotNull;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableSet;
import com.google.javascript.jscomp.CompilerOptions.PropertyCollapseLevel;
import com.google.javascript.jscomp.PolyfillUsageFinder.Polyfill;
import com.google.javascript.jscomp.PolyfillUsageFinder.PolyfillUsage;
import com.google.javascript.jscomp.PolyfillUsageFinder.Polyfills;
import com.google.javascript.jscomp.parsing.parser.FeatureSet;
import com.google.javascript.jscomp.resources.ResourceLoader;
import com.google.javascript.rhino.IR;
import com.google.javascript.rhino.Node;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
/**
* Rewrites potential polyfill usages to use the hidden JSCompiler polyfills instead of the global.
*
* When $jscomp.ISOLATE_POLYFILLS is enabled, the $jscomp.polyfill library function does not add
* polyfills to the global scope or as properties on the native type. Instead, classes like Map are
* added to the $jscomp.polyfills object. Methods like `String.prototype.includes` are defined under
* a unique Symbol on String.prototype.
*
*
This pass rewrites polyfill usages so that they access the actual polyfills instead of trying
* to access the native types. For example, new Map()
becomes
* new $jscomp.polyfills['Map']
.
*
*
Limitations of this pass: a) it ignores destructuring and b) it does not support rewriting
* writes to polyfilled methods.
*/
class IsolatePolyfills implements CompilerPass {
private final AbstractCompiler compiler;
private final Polyfills polyfills;
private final Node jscompPolyfillsObject;
private static final String POLYFILL_TEMP = "$jscomp$polyfillTmp";
private final Node jscompLookupMethod = IR.name("$jscomp$lookupPolyfilledValue");
private boolean usedPolyfillMethodLookup = false;
private boolean isTempVarInitialized = false;
IsolatePolyfills(AbstractCompiler compiler) {
this(
compiler,
Polyfills.fromTable(
ResourceLoader.loadTextResource(IsolatePolyfills.class, "js/polyfills.txt")));
}
@VisibleForTesting
IsolatePolyfills(AbstractCompiler compiler, Polyfills polyfills) {
this.compiler = compiler;
this.polyfills = polyfills;
boolean hasPropertyCollapsingRun =
compiler.getOptions().getPropertyCollapseLevel().equals(PropertyCollapseLevel.ALL);
jscompPolyfillsObject =
hasPropertyCollapsingRun ? createCollapsedName() : createJSCompPolyfillsAccess();
}
/** Returns a name `$jscomp$polyfills` */
private static Node createCollapsedName() {
Node collapsedName = IR.name("$jscomp$polyfills");
collapsedName.putBooleanProp(Node.IS_CONSTANT_NAME, true);
return collapsedName;
}
/** Returns a getprop `$jscomp.polyfills` */
private static Node createJSCompPolyfillsAccess() {
Node jscomp = IR.name("$jscomp");
jscomp.putBooleanProp(Node.IS_CONSTANT_NAME, true);
return IR.getprop(jscomp, "polyfills");
}
@Override
public void process(Node externs, Node root) {
// Calculate the set of polyfills that are actually present in the AST. It may be a subset of
// the potential polyfills which PolyfillFindingCallback finds (it's fine if it's a superset.)
ImmutableSet injectedPolyfills = findAllInjectedPolyfills();
List polyfillUsages = new ArrayList<>();
new PolyfillUsageFinder(compiler, this.polyfills)
.traverseIncludingGuarded(root, polyfillUsages::add);
LinkedHashSet visitedNodes = new LinkedHashSet<>();
for (PolyfillUsage usage : polyfillUsages) {
if (
// Some nodes map to more than one polyfill usage. For example, `x.includes` maps to both
// Array.prototype.includes and String.prototype.includes, but only needs to be isolated once.
visitedNodes.contains(usage.node())
// Skip visiting nodes whose 'polyfill.library' is empty. This is true for language
// features like `Proxy` and `String.raw` that have no associated polyfill, and hence are
// unnecessary to isolate.
|| usage.polyfill().library.isEmpty()
// The PolyfillFindingCallback may detect possible polyfill usages that are not
// in fact injected. (possibly because RemoveUnusedCode deleted the polyfill.)
|| !injectedPolyfills.contains(usage.polyfill().nativeSymbol)) {
continue;
}
this.rewritePolyfill(usage);
visitedNodes.add(usage.node());
}
cleanUpJscompLookupPolyfilledValue();
}
/**
* Searches the AST for all calls to $jscomp.polyfill and returns the polyfilled symbol names.
*
* At the moment we don't track anywhere the set of all polyfills that have been injected. That
* set may be modified by RewritePolyfills, Es6InjectRuntimeLibraries, and RemoveUnusedCode. If
* desired, we could delete this method by making any passes that add/remove polyfill calls
* responsible for tracking their presence.
*
*
Note: this set cannot be injected into the constructor because it is not known until the
* polyfill injection pass actually runs.
*/
private ImmutableSet findAllInjectedPolyfills() {
ImmutableSet.Builder actualPolyfills = ImmutableSet.builder();
Node lastInjectedNode = compiler.getNodeForCodeInsertion(null);
NodeTraversal.traverse(
compiler,
lastInjectedNode,
new NodeTraversal.AbstractShallowCallback() {
@Override
public void visit(NodeTraversal t, Node n, Node parent) {
if (isJSCompPolyfillCall(n)) {
// CALL
// GETPROP/NAME $jscomp.polyfill
// STRING NativeSymbol.prototype.method
// [...]
String polyfilledSymbol = n.getSecondChild().getString();
actualPolyfills.add(polyfilledSymbol);
}
}
});
return actualPolyfills.build();
}
private boolean isJSCompPolyfillCall(Node call) {
if (!call.isCall()) {
return false;
}
String jscompPolyfillName =
compiler.getOptions().getPropertyCollapseLevel().equals(PropertyCollapseLevel.ALL)
? "$jscomp$polyfill"
: "$jscomp.polyfill";
return call.getFirstChild().matchesQualifiedName(jscompPolyfillName);
}
/**
* Rewrites a potential access of a polyfilled class or method to first look for the non-global,
* polyfilled version.
*/
private void rewritePolyfill(PolyfillUsage polyfillUsage) {
final Polyfill polyfill = polyfillUsage.polyfill();
// If the output FeatureSet includes all of the features of the language version in which
// the polyfilled symbol was introduced, we can assume that the intended platform has
// the symbol defined natively, so the compiler won't include the polyfill in its output and it
// won't need to be isolated.
if (compiler
.getOptions()
.getOutputFeatureSet()
.contains(FeatureSet.valueOf(polyfill.nativeVersion))) {
return;
}
final Node polyfillAccess = polyfillUsage.node();
final Node parent = polyfillUsage.node().getParent();
if (FILES_ALLOWED_UNQUALIFIED_POLYFILL_ACCESSES.contains(polyfillAccess.getSourceFileName())) {
return;
}
// For now we need to assume that these lvalues are unrelated to the polyfills, as we are not
// using any type information. If we wanted, we could support assignments to properties that are
// already present, e.g.
// Array.includes = intercept;
// to
// tmp = Array;
// tmp[$maybePolyfillProp(tmp, 'includes)] = intercept;
if (NodeUtil.isLValue(polyfillAccess)
|| (parent.isAssign() && polyfillAccess.isFirstChildOf(parent))) {
return;
}
final String name = polyfillUsage.name();
boolean isGlobalClass = name.indexOf('.') == -1 && polyfill.kind.equals(Polyfill.Kind.STATIC);
if (isGlobalClass) {
// e.g. Symbol, Map, window.Map, or goog.global.Map
// Optional chaining is forbidden on global classes, hence producing `getelem` for property
// access would suffice.
polyfillAccess.replaceWith(
IR.getelem(jscompPolyfillsObject.cloneTree(), IR.string(name)) // $jscomp.polyfills['Map']
.srcrefTree(polyfillAccess));
} else if ((parent.isCall() || parent.isOptChainCall())
&& polyfillAccess.isFirstChildOf(parent)) {
// e.g. `getStr().includes('x')` or `getStr()?.includes('x')`
rewritePolyfillInCall(polyfillAccess);
} else {
// e.g. [].includes.call(myIter, 0)
Node methodName = IR.string(polyfillAccess.getString()).srcref(polyfillAccess);
Node receiver = polyfillAccess.removeFirstChild();
// The `$jscomp$lookupPolyfilledValue` can handle both normal prop access as well as optional
// by checking whether the lhs (receiver) is null or undefined first.
polyfillAccess.replaceWith(
createPolyfillMethodLookup(receiver, methodName, NodeUtil.isOptChainNode(polyfillAccess))
.srcrefTree(polyfillAccess));
}
compiler.reportChangeToEnclosingScope(parent);
}
// Code in the runtime libraries that may execute before any polyfills are injected and needs
// to opt out from polyfill isolation.
// - util/global.js looks for `globalThis`
// - util/shouldpolyfill.js checks whether Symbol is native
// - es6/util/construct.js looks for the native Reflect.construct
// If desired we could programmatically detect these cases instead of having this allowlist of
// polyfill accesses, but that might silently allow other usages of third-party polyfills
// into the codebase.
// TODO(b/156776817): crash on early references to polyfills that are not in our allowlist.
private static final ImmutableSet FILES_ALLOWED_UNQUALIFIED_POLYFILL_ACCESSES =
ImmutableSet.of(
AbstractCompiler.RUNTIME_LIB_DIR + "util/global.js",
AbstractCompiler.RUNTIME_LIB_DIR + "util/shouldpolyfill.js",
AbstractCompiler.RUNTIME_LIB_DIR + "es6/util/construct.js");
/**
* Rewrites a call where the receiver is a potential polyfilled method
*
* Before: receiver.method(arg)
*
*
After: lookupPolyfilledValue(receiver, 'method').call(receiver, arg)
*
*
Or, if evaluating the receiver may have side effects, we store the receiver in a temporary
* variable to avoid evaluating it twice:
*
*
After: (tmpNode = receiver, lookupPolyfilledValue(tmpNode, 'method'))
* .call(tmpNode, arg)
*/
private void rewritePolyfillInCall(Node callee) {
final Node methodName = IR.string(callee.getString()).srcref(callee);
final Node receiver = callee.removeFirstChild();
final boolean isCalleeOptChain = NodeUtil.isOptChainNode(callee);
boolean requiresTemp = compiler.getAstAnalyzer().mayEffectMutableState(receiver);
final Node polyfilledMethod;
final Node thisNode;
if (requiresTemp) {
// e.g. `sideEffects().includes(arg)`
thisNode = createTempName(callee);
// (tmpNode = sideEffects(), lookupMethod(tmpNode, 'includes'))
polyfilledMethod =
IR.comma(
IR.assign(thisNode.cloneTree(), receiver),
createPolyfillMethodLookup(thisNode.cloneTree(), methodName, isCalleeOptChain));
} else {
thisNode = receiver;
polyfilledMethod =
createPolyfillMethodLookup(receiver.cloneTree(), methodName, isCalleeOptChain);
}
// Fix the `this` type by using .call:
// lookupMethod(receiver, 'includes', isOptChainNode).call(receiver, arg)
Node receiverDotCall;
if (NodeUtil.isOptChainNode(callee)) {
// If optional chaining exists at this point, we can have it in the output
receiverDotCall = IR.startOptChainGetprop(polyfilledMethod, "call").srcrefTree(callee);
} else {
receiverDotCall = IR.getprop(polyfilledMethod, "call").srcrefTree(callee);
}
callee.replaceWith(receiverDotCall);
thisNode.insertAfter(receiverDotCall);
}
private Node createTempName(Node srcref) {
if (!isTempVarInitialized) {
isTempVarInitialized = true;
Node decl = IR.var(IR.name(POLYFILL_TEMP)).srcrefTree(srcref);
compiler.getNodeForCodeInsertion(null).addChildToFront(decl);
}
// The same temporary variable is always used for every polyfill invocation. This is believed
// to be safe and makes the code easier to generate and smaller. There's a change it will make
// it harder for V8 to optimize, though. If proves to be a problem we could introduce unique tmp
// variables.
return IR.name(POLYFILL_TEMP).srcref(srcref);
}
/**
* Returns a call $jscomp$lookupPolyfilledValue(receiver, 'methodName', isOptChainNode)
*
*/
private Node createPolyfillMethodLookup(Node receiver, Node methodName, boolean isOptChainNode) {
usedPolyfillMethodLookup = true;
Node call;
if (isOptChainNode) {
call = IR.call(jscompLookupMethod.cloneTree(), receiver, methodName, IR.trueNode());
} else {
// 3rd param of `jscompLookupMethod` is optional
call = IR.call(jscompLookupMethod.cloneTree(), receiver, methodName);
}
call.putBooleanProp(Node.FREE_CALL, true);
return call;
}
/**
* Deletes the dummy externs declaration of $jscomp$lookupPolyfilledValue and the function itself
* if unused.
*
*
The RewritePolyfills pass injected a definition of $jscomp$lookupPolyfilledValue into the
* externs. This prevented dead code elimination, since the function is never unused until this
* pass runs. However, now we need to delete the externs definition so that variable renaming can
* actually rename $jscomp$lookupPolyfilledValue.
*/
private void cleanUpJscompLookupPolyfilledValue() {
Node syntheticExternsRoot = compiler.getSynthesizedExternsInput().getAstRoot(compiler);
Scope syntheticExternsScope =
new SyntacticScopeCreator(compiler).createScope(syntheticExternsRoot, /* parent= */ null);
Var externVar =
checkNotNull(
syntheticExternsScope.getVar(jscompLookupMethod.getString()),
"Failed to find synthetic $jscomp$lookupPolyfilledValue extern");
NodeUtil.deleteNode(externVar.getParentNode(), compiler);
if (usedPolyfillMethodLookup) {
return;
}
Scope syntheticCodeScope =
new SyntacticScopeCreator(compiler)
.createScope(compiler.getNodeForCodeInsertion(/* module= */ null), /* parent= */ null);
Var syntheticVar = syntheticCodeScope.getVar(jscompLookupMethod.getString());
// Don't error if we can't find a definition for jscompLookupMethod. It's possible that we are
// running in transpileOnly mode and are not injecting runtime libraries.
if (syntheticVar != null) {
NodeUtil.deleteNode(syntheticVar.getParentNode(), compiler);
}
}
}