Override array methods to avoid proxy creation while iterating and updating #1184
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Stacked on top of #1164 (perf tweaks) and #1183 (finalization callbacks).
This PR:
Implementation
We have a known list of both mutating and non-mutating array methods in
proxy.ts
. We check field access in theget
trap, and if it's one of the known array methods, track which method to indicate we're in an array operation.We then manually implement the operation by calling the method on the underlying
state.copy_
, or in some cases reimplementing some of the behavior. For example,filter
does a manual iteration and calls the predicate with the existing values to avoid accessing the draft array and creating proxies for every accessed field. But, for consistency and further updates, it does then doresult.push(state.draft[i])
every time the predicate returnstrue
. That means we avoid some proxy creation overhead and only create proxies for values included in the result array. For methods likesort
andreverse
, we mutate thecopy_
array, and manually set theassigned_
indices, avoiding any access to the drafted array and thus no proxy creation for fields.This gives a significant speedup for almost all array benchmark scenarios.
Note that for
filter / find
etc, we're now passing likely-raw values into the callbacks. If the user were to mutate those, they'd be really mutated and we wouldn't prevent or pick up on that. Since those are semantically read-only operations and you shouldn't be mutating in those callbacks in the first place, this seems like a reasonable tradeoff to me.I've opted to not override
map / flatMap / reduce / reduceRight
. Those can return arbitrary values, and also added noticeably more bundle size from implementation. I decided it wasn't worth the complexity to optimize those methods.Performance
Running the perf benchmarks with this PR, I get:
That's a very sizeable 30%+ increase over #1183 , which is not surprising given that all the array behaviors suddenly have significantly less overhead.
Bundle Size
Per #1183, the series of PRs does increase bundle size noticeably:
Eyeballing bundle sizes, this PR increases the
immer.production.js
minified bundle size by another 1-1.5K on top of #1183, from ~14K to ~15K. If I build a Vite app and measure using Sonda, actual size in a built app appears to have grown from:As I said, I'm very sensitive to bundle size changes. I have been assuming we would probably want to make this PR into a new plugin, so that users can opt-in to whether they want the extra bundle size to gain better array perf.
Right now the plugin system only has methods for loading and getting a plugin. We'd probably want to add a util to see if a plugin is currently loaded, roughly like
const isPluginLoaded = (name: string) => !!plugins[name]
. That way we can do a quick check inside ofget
. Alternately, we could haveproxy.ts
expose a setter to indicate this plugin is available, and have the plugin logic update that flag when this plugin is loaded.