Tree-shake prototype method calls on literals - #1916
Conversation
guybedford
left a comment
There was a problem hiding this comment.
👍 nice work!
I ran some quick tests and it seems to reduce the build size of rollup itself which is a good start, although just marginally.
Interestingly there seems to be a performance improvement with this change - I wonder if that's due to these acting as better stop conditions, or some other random noise.
| withNew: boolean; | ||
| args?: (ExpressionEntity | SpreadElement)[]; | ||
| caller: CallExpressionType; | ||
| caller: Object; |
There was a problem hiding this comment.
Is there any way to keep this as a more descriptive type? This analysis code can be quite complex to follow for users new to the codebase, so maintaining transparent types where possible would be useful in aiding understanding.
There was a problem hiding this comment.
Actually the only use of caller is to identify if the corresponding CallExpression has already been encountered in this run. Which does not exist in the case of implicit calls be builtins.
Would it be OK to instead rename caller to callIdentifier to make it more clear this is only an identification label?
| valueOf: returnsNumber | ||
| }), | ||
| string: assembleMemberDescriptions({ | ||
| charAt: returnsString, |
There was a problem hiding this comment.
I can't believe you skipped string.blink() 😜
There was a problem hiding this comment.
I KNOW. Especially since I just checked that even Node supports it! Should I change that?
There was a problem hiding this comment.
I would personally be deeply upset if my codebase wasn't able to tree-shake my pure blink-rendering code.
| toLowerCase: returnsString, | ||
| toString: returnsString, | ||
| toUpperCase: returnsString, | ||
| trim: returnsString, |
There was a problem hiding this comment.
Maybe add trimLeft and trimRight.
There was a problem hiding this comment.
I wanted to but MDN was warning me about it:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimLeft
There was a problem hiding this comment.
Ah, ok sure, didn't see those.
| lastIndexOf: returnsNumber, | ||
| map: callsArgReturnsArray, | ||
| reduce: callsArgReturnsUnknown, | ||
| reduceRight: callsArgReturnsUnknown, |
There was a problem hiding this comment.
Could reverse be added here as returnsArray?
Also would it be possible to support shift, push, pop and unshift, sort and splice?
There was a problem hiding this comment.
Unfortunately, mutating methods are much more difficult. The reason is this:
If we want to check if e.g. .reverse() can be called on a variable, we check if this variable has a unique value, e.g. in
const x = [1, 2, 3]x would have a unique value, while in
let x = [1, 2, 3];
x = [3, 4, 5];the value is not unique i.e. we are checking for reassignments. In case of a reassignment, we just internally mark the variable as "reassigned" and treat it like undefined since otherwise (i.e. if we were tracking all assignments), there would be a possibly exponential performance degradation (learned this the hard way).
Now if there is a unique value, we continue asking the original expression if it supports .reverse() until we hit the ArrayLiteral. But the algorithm no longer knows what the original variable was or any variables in between. So we do not know if any of those variables are already included in the bundle. Which is a problem because in that situation, the call would have the side-effect of mutating an included variable.
I guess its possible to find a solution for this but for now I just wanted to focus on the low-hanging fruit.
| slice: returnsArray, | ||
| some: callsArgReturnsBoolean, | ||
| toLocaleString: returnsString, | ||
| toString: returnsString |
There was a problem hiding this comment.
Is the assumption that these can work because we know we specifically have an array of literal values so there are no toString side effects?
If these cases work, surely join would work too then?
But if we don't actually know what we are calling toString on, I would argue we should remove these.
There was a problem hiding this comment.
Ah, I see, did not consider this meta-effect. So I guess yes, we could consider removing it. Which in the case of join would really be a pity. Or we act under the assumption that toString never has a side-effect. Might be reasonable to do as we also assume that builtins have not been modified. Even if a toString method is provided, I would assume that people would not rely on it having side-effects. I may be proven wrong but unless you know a specific package that relies on this, I would argue to maintain this assumption until someone encounters an issue here.
What do you think?
There was a problem hiding this comment.
toString should always be pure definitely, even if it is overridden. So it's the edge case of the edge case that catches yes... and we have assumptions like this all over for builtins so I suppose it is consistent with the assumptions of the project. It may be worth documenting somewhere these kinds of assumptions so that users can easily determine if Rollup is right for their codebase before hitting odd bugs like these though. Personally I find projects that clearly state their assumptions incredibly reassuring as you can fully appreciate the decisions you are making with it.
| hasEffectsWhenCalledAtPath: path => { | ||
| if (path.length === 1) { | ||
| const subPath = path[0]; | ||
| return isUnknownKey(subPath) || !literalMembers[type][subPath]; |
There was a problem hiding this comment.
literalMembers[type] can be hoisted out of this function.
There was a problem hiding this comment.
I was hoping so, too. Unfortunately there is some circular dependency between the data structures in this file (which is the main reason I moved everything into the same file). I.e., createUnknownLiteral is called the first time before we hit the definition of literalMembers. If you have a suggestion how to untangle these that would be cool but I fear it will need to remain like this for now.
There was a problem hiding this comment.
It sounds like the simplest fix may just be to flatten UNKNOWN_LITERALS into UNKNOWN_LITERAL_NUMBER, UNKNOWN_LITERAL_BOOLEAN and UNKNOWN_LITERAL_STRING. The collection object could still exist fine if lookups are needed in other places.
| propertyIsEnumerable: returnsBoolean, | ||
| toLocaleString: returnsString, | ||
| toString: returnsString, | ||
| valueOf: returnsUnknown |
There was a problem hiding this comment.
Another thing - it could be worth having these on the other literal types as well as they all extend from object too.
|
Another thing I was wondering here - should array expressions containing spread literals be deoptimized, or does the code handling spread already do that? Since those iterators can have side effects. |
|
Thanks for the quick feedback. I don't know if I manage to put in the changes today but I will add them at the latest tomorrow morning. About spread elements: Those are currently heavily deoptimized i.e. using a spread element is always a side-effect, you can try it in the REPL! Certainly not ideal, might be worthwhile to revisit once we add object spread elements. |
| number: createUnknownLiteral('number'), | ||
| string: createUnknownLiteral('string') | ||
| }; | ||
| const returnsBoolean: RawMemberDescription = { value: { returns: UNKNOWN_LITERALS.boolean, callsArgs: null } }; |
There was a problem hiding this comment.
(then these would become UNKNOWN_LITERAL_BOOLEAN etc..
|
That's all my feedback on this one - will be back online week of 4th of Feb here 😁 |
|
Awesome, thanks & have fun! |
|
Ok, updating this will take a moment longer, but since you're on vacation I guess this does not matter much :) As it turned out after explaining to you in great length why we cannot include them, mutating array methods are actually NOT a problem because nested calls on included objects are always included anyway. Such a handy heuristic! So gonna include those + some regression tests for other things that came up in the review just to be safe. |
access and correct toString identification
* Add inherited object members to all literals
c471107 to
67a7131
Compare
Finally found some time to do some programming of my own!
With this PR, Rollup will recognise all standard prototype methods on string, number, boolean, array and object literals. This includes methods that call one of their arguments such as
Array.map, for which side-effect detection is in place, and mutating methods likeArray.sortfor which it is taken care that all calls will be included if the mutated variable is included. This will also work across variables as long as those variables are not reassigned. For example, this will be removed:However if a side-effect is introduced in the filter function, e.g.
.filter(char => console.log(char) || true), then everything will be retained. This also includes mutations of included variables:However if we remove the export of
exportedObject, then nothing is retained.Assumptions: As always with Rollup's tree-shaking, we assume that all builtin methods behave according to their specification. This also includes the assumption that e.g.
.toString, even if overridden, does not have any side-effects.Possible future extensions: With this in place, it should also not be too difficult to start adding return types (i.e. string, boolean, number, object, array or unknown) to builtin global functions (except that it will be a lot of work...). Furthermore, a similar logic might be applied to handle global functions that call their arguments, e.g.
Promise.resolve(myFunc)could be removed if callingmyFuncdoes not have a side-effect.