-
-
Notifications
You must be signed in to change notification settings - Fork 867
Rewrite finalization system to use a callback approach instead of tree traversal #1183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
422ba2b to
53e71db
Compare
Pull Request Test Coverage Report for Build 18857343219Details
💛 - Coveralls |
mweststrate
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is absolutely marvelous work! Thanks you so much! I left a bunch of questions and potential further optimizable hotspots, but all are pretty small I think
__tests__/base.js
Outdated
|
|
||
| // This actually seems to pass now! | ||
| it("cannot return an object that references itself", () => { | ||
| it.skip("cannot return an object that references itself", () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this skip intended? if so, let's leave a comment why :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The .skip was because this test no longer threw an error.
I did some double-checking and the behavior is now much more nuanced:
- Just doing
() => res.selfdoesn't error. - self-references can show up in the final output...
- but as soon as you modify a value that is circular, that reference gets copied and isn't itself circular any more (ie its circular field now points to the original reference that is circular)
I rewrote the test to reflect that behavior
src/core/finalize.ts
Outdated
| (state.assigned_ && state.assigned_.size > 0)) | ||
|
|
||
| if (shouldFinalize) { | ||
| if (patches && inversePatches) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NIT: I think we actually only need to check one, and the other just got there for the typechecker's happiness, but isn't actually needed at runtime, so inversePatches! below might do the trick
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I rewrote the patch handling to pass around the scope instead, so that we only destructure {patches, inversePatches} inside of the patch logic.
src/core/finalize.ts
Outdated
| // For sets we clone before iterating, otherwise we can get in endless loop due to modifying during iteration, see #628 | ||
| // To preserve insertion order in all cases we then clear the set | ||
| if (target.type_ === ArchType.Set && target.copy_) { | ||
| const copy = new Set(target.copy_) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NIT, might be cheaper to do
const copy = target.copy_; target.copy_ = new Set(); copy.forEach...?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't think there's a meaningful difference here. However, I did move this into the patches plugin, so that we save the byte size until we actually expect to see Sets.
|
I propose this as major version, although all checks pass and things look correct to me, any fall out from subtly changed semantics is easier to handle if it is a new major, and I really like the new iteration default as well :) |
|
Awesome! And yeah, I agree - sometimes changes this big are worth releasing a new major anyway even if it's internal only and still compatible :) |
|
Also: I'm still seeing huge increases in perf from changing the baseline |
Ported Mutative's "finalization callback" approach as a more targeted and performant implementation for finalization compared to the existing recursive tree traversal approach: - Added cleanup callbacks for each draft that's created - Added callbacks to handle root drafts, assigned values, and recursing inside of plain values - Updated state creation to return [draft, state] to avoid a lookup - Rewrote patch generation system to work with callbacks instead of during tree traversal
53e71db to
cfae1ac
Compare
Update, 2025-10-27I rebased the PR on top of the latest I've gone through and applied most of the PR feedback from @mweststrate . I didn't include pre-sizing arrays - that broke the callback loop logic with both Further ChangesI did a significant amount of byte-shaving work:
I set up a Vite + React example app and did builds with the existing v10 version of Immer, this branch before I did any byte-shaving, and this branch with all the byte-shaving: v10.2.0 presumably got bigger from the addition of So, net result, this PR is ~600 bytes bigger than v10.2.0 after adding all the rewritten finalization logic and subtracting all the byte shaving. PerformanceAfter #1164 , we were already +20% faster than v10.1.3 once you turn on loose iteration (per latest numbers in #1187): This PR makes that strict iteration the default. From there, the performance in this PR initially looks like a no-op vs those numbers, still around +20% vs v10.1.3: But, it's worth noting that's dealing with some of the array scenarios apparently getting somewhat slower. (I don't know why those are slower yet, other than just more logic running in general.) I think it's reasonable to assume that If I remove a couple of the array scenarios, the results look like: I realize that's sort of cherry-picking results ("it's faster if we ignore the parts where it got slower!"), but also hopefully more reflective of real-world usage. On top of that, this doesn't include any of the array overrides from #1184 , which make all of the array ops drastically faster. Finally, the changes in this PR also significantly cut down on the amount of time spent recursing through object trees. There's still Given all that, I do think this PR is a net improvement and worth merging. |
|
Looks great and I'm happy to merge this! The following would work, correct?
|
|
You lost me on those numbers, since we're on 10.2.0 right now :) I did turn on loose iteration in this PR, which would be the main "breaking" change. In theory the rest of these changes are all internal, but there's enough churn that my own gut decision would be to ship this as 11.0 on principle. The array ops in #1184 ought to be shippable in a 10.x minor since they're opt-in. I think I see one or two stray comments in this PR I want to clean up - I can get to that tonight. |
|
One note about this PR's behavior: per the changed tests, it does seem to alter the behavior around circular references so that Immer can handle them without throwing, which is an improvement. |
|
🎉 This PR is included in version 11.0.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
|
11.x, sorry :) Merged as breaking change, release should go out soon!
Yeah this is something I don't want to advertise, fundamentally the mental model breaks when having circular refs between nodes, making the data structure no longer a tree. It is unclear what it would mean for example to mutate the "same" object twice through two different paths. Or even with only one modfication, what it would mean for patches, pointer/structural equality downstream, etc etc. So I'd argue this remains "unsupported" with "undefined behavior" that might change arbitrarily between releases. |




Stacked on top of #1164 for assorted smaller perf tweaks, followed by #1184 for array method overrides:
This PR ports Mutative's "finalization callback" approach as a more targeted and performant implementation for finalization compared to the existing recursive tree traversal approach:
inside of plain values
[draft, state]to avoid a lookupduring tree traversal
It also makes some additional tweaks to perf and internal logic:
strictIteration: false(technically a breaking change) to get the benefits of faster loose iterationhas / get / setto allow passingtype? = getArchetype(value)as the last argument. We know this in most places we call them, so we can pass it in without callinggetArchetype()repeatedlyassigned_from aRecordto aMapto enable size lookupsfreeze()to remove anisDraftablecheck that wasn't necessary (and slightly expensive), and to useeach(val, cb, false)for consistency with loose iterationPerformance
Stacked on top of the misc perf improvements from #1164 , and with
strictIteration: false, this branch shows:Since #1164 was ~20% faster than v10, this is another ~4.5% faster.
I'll note that's with some of the array scenarios actually showing regressions, so the other scenarios appear to be a bigger improvement than 4-5%. I don't have a full explanation for why those scenarios got a bit slower. Some of it may be doing additional work when creating proxies - I'd still like to dig into it a bit further. However, the array scenarios are all in the microsecond range already, so not like it's a huge difference. Additionally, the next architectural PR that adds overriding the array methods results in drastic improvements for all of those.
Bundle Size
Eyeballing bundle sizes, this PR increases the
immer.production.jsminified bundle size by about 1700 bytes, from ~12K to ~14K. If I build a Vite app and measure using Sonda, actual size in a built app appears to have grown from:I'm always very sensitive to increasing bundle size, so there's a very real question of whether adding a couple K here is worth it for a net 5-7% perf increase, especially when the larger benefits seem to be from the smaller tweaks and the array method overrides.