-
-
Notifications
You must be signed in to change notification settings - Fork 90
Schedule uncommitted reaction cleanup #121
Schedule uncommitted reaction cleanup #121
Conversation
Whilst, if it could be made to work, this approach would be very efficient, I'm unconvinced that it can be made to work 100% reliably as I'm not sure there's a reliable way to track whether a commit has occurred or not other than to use |
It would great if @gaearon could chime in here as it's fairly heavy stuff from React internals. If I am understanding edge case correctly, it means there is a risk of throwing away the Reaction that's not stale actually? Yea, that would be bad, let's not do that :) Sadly, I have no idea right now how to deal with it. Wish it would be possible to write a test for that edge case. |
Yes, @FredyC, I think in that edge case it would be possible to throw away a non-leaked Reaction. It might be possible to put potentially-leaked reactions in a bucket somewhere, and then when we see a commit phase run to completion, anything in that bucket is leaked. But I'm not at all sure if there's a reliable public way to track that. I've thought of a way that might enable the unit testing of such a case: trigger the cleanup timeout from the end of a render phase, causing the cleanup to happen before commit. I'll try to get such a test together. In the meantime, I've also thrown together a real useState+useEffect-based version of the fix - the one I've been trying to avoid. It doesn't create a reaction until the commit phase, so there's no possibility of leakage, but it does require every observer component to do a full second render cycle so it's a little less efficient. I have a suspicion that @gaearon has said in the past that we shouldn't be afraid of such a cycle in general (back about a year ago where we were being steered towards doing more work in It passes the StrictMode and leak tests but it blows up many of the other mobx-react-lite tests because many of them aggressively count the number of times components are rendered, and this obviously changes that count significantly. Here's that code if you want to see it, compared against the current trunk implementation of |
Yea, I am not too excited about it either. What if the observed component is somewhat heavy, re-rendering it twice is fairly awkward and might be a reason why people would avoid MobX in React. At this point, it feels like a matter of choosing the right tradeoff. Either there would be some lingering Reactions or we would be producing extra rerender. What is worse? I would be voting for keeping lingering Reactions as it would be a problem in really intensive apps only. What if we include some safety net to the current solution? I mean we have a list of (theoretically) uncommitted Reactions. We could keep time when the reaction last run and dispose of it only if it breaks it some threshold. Sure, it's not an exact science either, but it does lower the risk of disposing of something that's actually used. |
Btw, regarding your solution with But yea, there is still "the problem" of extra render that might be fairly annoying in some situations. |
Another idea. What if a timer was more like a debounce? Instead of static interval of cleanup, every call to the I don't think it's very common to have apps that are mounting/unmounting many observed components very often. And even if they are, it would only mean bigger memory footprint that would be cleared later. Edit: Tried the implementation 👇 Tests are passing. Do you think it covers the mentioned edge case? |
That's not a bad idea. I can't think of a scenario where it would fail, but the global debounce does make me a little nervous: an application doing real-time animations might not have a long enough idle period. The other way to do it is to have a per-reaction debounce instead of a global debounce: track the age of a 'potentially leaked' reaction, and if it's older than a certain amount, it's eligible for cleanup. No global idleness needed. |
Yea I agree, was thinking the same. Besides global objects are always a pain when writing app tests. It could cause some flakiness. I am not sure I follow your idea though. Where would you keep that time? Do you mean writing it directly on |
I'll code it up. Much clearer than English. :-) But basically, imagine, instead of pushing a reaction into the 'cleanup' list, pushing a |
(Btw, if we could use Set or Map it could be made more efficient. But we can't assume those... or can we?) |
What I suppose would be super safe and not global at all is to have a separate timer for each reaction. It starts when the Reaction it's created and if it's not committed within like 50ms (probably less) it's safe to assume it's lingering. It's most likely overkill, but safe one! :) I am thinking if there isn't a potential for memory leak within application tests. With a global timer if you use eg.
Well, according to the compat table, the |
Tracking the creation time of each reaction is equivalent to having a separate timer for each reaction. In tests, if you don't push the fake timers ahead, you're leaking reactions to what's probably a temporary set of data that will be recreated in the next tests, so you probably haven't leaked anything at all? |
Huh. So you are saying it's not more expensive to have numerous Btw, we should also check for |
I think it would be more expensive to have a separate real timer per reaction, so I'm suggesting just having one real timer running, checking simple numeric timeouts on each 'potentially leaked reaction'. The other thing worth considering in unit tests is that all this leakage is only an issue in strict or concurrent mode. (We do now run all our unit tests in strict mode, which has been an exciting journey.) But right now, mobx-react-lite always leaks reactions in that case, so whatever we do here is a win. :) |
Alright, let's do a global timer and WeakMap then :) |
I'm writing some tests first. Red, Green, Refactor and all that. (I'm also suspicious about what happens if an observable change occurs between first render and first commit: the change in #119 would mean that gets ignored, and possibly never handled, so we may need to track that a MobX change occurred before commit, which would need to trigger a second render. I'll get a test together for it.) |
@RoystonS You are right there is that risk for sure. That's kinda why I wanted to Perhaps we shouldn't be overthinking this. Maybe it's not so big performance problem to have that double re-render there. There is a chance that React can optimize it and only one render happens in the end. Also, the silver lining is that wouldn't even need this custom garbage collector doing it that way. Btw, just realized, if we do NOT use |
Right. That was a bit of a mammoth run. Two new unit tests. The first confirms and demonstrates the suspicion I had after PR #119, that changes made to an observable will be lost if they occur between its render and commit. This unit test fails both before and after PR #119, so that PR didn't make it worse. The second (which is a rather harder test to write) demonstrates that if the scheduled cleanup task comes in between a component having its first render and its first Armed with failing unit tests we can now look at solutions. Fixing the first is easy: we need to track that there was a change before commit, and trigger a re-render during commit. Fixing the second requires some way to track that a reaction has actually been leaked from a discarded render. It's probably sufficient to consider that a reaction is leaked if it was created 'some time ago' (e.g. > 10 seconds?) I need to do a bit more research into Suspense and some of the way fiber render trees are paused. Hopefully facebook/react#15317 might help clarify. |
This is a test to check that observable changes made between the first component render and commit are not lost. It currently fails (and did so before the change in PR mobxjs#119)
This demonstrates (in a slightly contrived way) how, if the cleanup timer fires between a recent component being rendered and it being committed, that it would incorrectly tidy up a reaction for a soon-to-be-committed component.
We had an existing test to check that observable changes between render and commit didn't go missing, but it only checked in Strict mode, and there's a problem with non-Strict mode.
Here's another unit test that illustrates a further issue with the options. I already had a test for checking that observable changes that occur between first render and commit shouldn't be lost, but I was only running that test in Strict Mode. A check against our codebase (with 100+ observer components) flagged up a case where it was an issue. So I've added a non-strict mode test. I'll continue the discussion in PR #119 as we have a problem, @FredyC... |
Honestly, I got a bit lost in the solution, so I am just going to trust both of you it's alright :) @RoystonS Still planning on catching up with tests? Or is that 7% drop something you are unable to test reliably? |
I'm confused. The code that is being flagged as uncovered (i.e. the cleanup stuff) is tested. I think something funny might have happened with the tests when I renamed the test file. I thought it was all covered. Will check. |
When I renamed this file, I forgot the .test. suffix. D'oh.
That's better. It was indeed an issue of the test file name: when I renamed it, I forgot the .test. suffix so my new tests weren't running. Now they are, there's actually an increase in coverage. :-) |
Nice! :) So I take it we are ready to merge it? It's battle tested I assume? :) |
I'm just extracting the tracking and cleanup logic out of Define "battle-tested". I've certainly been running it in our codebase for the last 2 weeks since my previous comments and we've not seen any issues but it's been Easter and we've not done detailed memory heap analysis, etc. Regarding readiness for merge... it's "quite a non-trivial change": as I mentioned before, I did have one unit test that needed fixing up, so I don't know whether you'd want to consider a major version bump in order to signal potential breaking changes? If you don't want a major version bump, it might be worth considering pushing up via a non-default dist-tag (e.g. '@next') so that people can elect into it to try it before making it mainstream? |
Yes, the |
Co-Authored-By: RoystonS <[email protected]>
Alright, published under |
* Test cleaning up reactions for uncommitted components * First attempt at a fix for cleaning up reactions from uncommitted components * Use debounce instead of fixed interval * Add unit test for missing observable changes before useEffect runs This is a test to check that observable changes made between the first component render and commit are not lost. It currently fails (and did so before the change in PR #119) * Add test for cleanup timer firing too early for some components This demonstrates (in a slightly contrived way) how, if the cleanup timer fires between a recent component being rendered and it being committed, that it would incorrectly tidy up a reaction for a soon-to-be-committed component. * Update test for missing changes to check Strict and non-Strict mode We had an existing test to check that observable changes between render and commit didn't go missing, but it only checked in Strict mode, and there's a problem with non-Strict mode. * Add cleanup tracking and more tests This adds full cleanup tracking, and even more tests: - we now track how long ago potentially leaked reactions were created, and only clean those that were leaked 'a while ago' - if a reaction is incorrectly disposed because a component went away for a very long time and came back again later (in a way React doesn't even do right now), we safely recreate it and re-render - trap the situation where a change is made to a tracked observable between first render and commit (where we couldn't force an update because we hadn't _been_ committed) and force a re-render - more unit tests * Fix renamed test file When I renamed this file, I forgot the .test. suffix. D'oh. * Extract tracking and cleanup logic out to separate file * Update src/useObserver.ts Co-Authored-By: RoystonS <[email protected]> * Move some more tracking internals into the tracking code
* Test cleaning up reactions for uncommitted components * First attempt at a fix for cleaning up reactions from uncommitted components * Use debounce instead of fixed interval * Add unit test for missing observable changes before useEffect runs This is a test to check that observable changes made between the first component render and commit are not lost. It currently fails (and did so before the change in PR #119) * Add test for cleanup timer firing too early for some components This demonstrates (in a slightly contrived way) how, if the cleanup timer fires between a recent component being rendered and it being committed, that it would incorrectly tidy up a reaction for a soon-to-be-committed component. * Update test for missing changes to check Strict and non-Strict mode We had an existing test to check that observable changes between render and commit didn't go missing, but it only checked in Strict mode, and there's a problem with non-Strict mode. * Add cleanup tracking and more tests This adds full cleanup tracking, and even more tests: - we now track how long ago potentially leaked reactions were created, and only clean those that were leaked 'a while ago' - if a reaction is incorrectly disposed because a component went away for a very long time and came back again later (in a way React doesn't even do right now), we safely recreate it and re-render - trap the situation where a change is made to a tracked observable between first render and commit (where we couldn't force an update because we hadn't _been_ committed) and force a re-render - more unit tests * Fix renamed test file When I renamed this file, I forgot the .test. suffix. D'oh. * Extract tracking and cleanup logic out to separate file * Update src/useObserver.ts Co-Authored-By: RoystonS <[email protected]> * Move some more tracking internals into the tracking code
- update before useeffect: mobxjs/mobx-react-lite#121 - fast-refresh: mobxjs/mobx-react-lite#226
* Schedule uncommitted reaction cleanup (#121) * Test cleaning up reactions for uncommitted components * First attempt at a fix for cleaning up reactions from uncommitted components * Use debounce instead of fixed interval * Add unit test for missing observable changes before useEffect runs This is a test to check that observable changes made between the first component render and commit are not lost. It currently fails (and did so before the change in PR #119) * Add test for cleanup timer firing too early for some components This demonstrates (in a slightly contrived way) how, if the cleanup timer fires between a recent component being rendered and it being committed, that it would incorrectly tidy up a reaction for a soon-to-be-committed component. * Update test for missing changes to check Strict and non-Strict mode We had an existing test to check that observable changes between render and commit didn't go missing, but it only checked in Strict mode, and there's a problem with non-Strict mode. * Add cleanup tracking and more tests This adds full cleanup tracking, and even more tests: - we now track how long ago potentially leaked reactions were created, and only clean those that were leaked 'a while ago' - if a reaction is incorrectly disposed because a component went away for a very long time and came back again later (in a way React doesn't even do right now), we safely recreate it and re-render - trap the situation where a change is made to a tracked observable between first render and commit (where we couldn't force an update because we hadn't _been_ committed) and force a re-render - more unit tests * Fix renamed test file When I renamed this file, I forgot the .test. suffix. D'oh. * Extract tracking and cleanup logic out to separate file * Update src/useObserver.ts Co-Authored-By: RoystonS <[email protected]> * Move some more tracking internals into the tracking code * 2.0.0-alpha.0 * 2.0.0-alpha.1 * 2.0.0-alpha.2 * Upgrade to React 16.9 * Add dedup script and run it * Remove deprecated hooks * Increase size limit * Remove note about Next version * Remove unused productionMode util * Improve readme * Pin dependencies * Merge master properly * Ignore build cache files * Revert removal of tsdx dep * Remove .browserlistrc * 2.0.0-alpha.3 * Bundling need to use build tsconfig * 2.0.0-alpha.4 * Remove object destructuring from optimizeForReactDOM/Native (#240) * Preserve generics when using `observer` (#244) * Preserve generics when using `observer` * Remove any casting from statics as it's no longer needed * Re-add overloads for explicitly specifying props type * Allow for passing options without `forwardRef` * Merge new `observer` overloads * Remove copy of UMD bundle * 2.0.0-alpha.5 * Batched updates are mandatory * Replace .npmignore with files field * Fix tests * Increase size limit Co-authored-by: Royston Shufflebotham <[email protected]> Co-authored-by: Renovate Bot <[email protected]> Co-authored-by: Tarvo R <[email protected]> Co-authored-by: Lukáš Novotný <[email protected]>
As s followup for this PR: |
(Recreated from PR #120 as I screwed up some repo stuff.)
This PR builds on PR #119 and introduces some fixes for #53:
Note that there's one aspect I'm unsure of, both of how to test or fix in a nice way, so I'm pushing this up for feedback. cc: @mweststrate, @FredyC
The happy day case, which I've tested for, and coded for:
But there's an edge case I'm concerned about (which is why I don't think that this PR is sufficient). What if it's been a while since the timer was kicked off, but the latest render phase hasn't had a corresponding commit phase yet?
I think, to do this properly, we need to record that a reaction is safe to clean up only once a commit phase has happened?