Skip to content

Conversation

@dagatsoin
Copy link
Contributor

@dagatsoin dagatsoin commented May 15, 2025

Why

This PR fixes a bug where useSpring lead to unmount component (eg. blank page)

Related: #2371

What

The root cause was an instruction which removes the current update during the useIsomorphicLayoutEffect callback.

@kierancap I would like your opinion on this if you have a minute. I don't know exactly the impact of this.

Checklist

  • Demo added: check before/after the demo css-variables
  • Ready to be merged

@vercel
Copy link

vercel bot commented May 15, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Updated (UTC)
react-spring ✅ Ready (Inspect) Visit Preview May 20, 2025 8:32pm

@changeset-bot
Copy link

changeset-bot bot commented May 15, 2025

⚠️ No Changeset found

Latest commit: 7158d68

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@joshuaellis
Copy link
Member

Do we have a reproduction of this in a sandbox? if i look at the examples in the docs, they all appear to work 🤔

@dagatsoin
Copy link
Contributor Author

@joshuaellis yes,

  • for the version with a bug : css-variables demo
  • for the version without a bug : compile the demo of my branch and launch the css-variables demo

@kierancap
Copy link

I'll take a look at this properly in a few hours. That line in particular was taken from the stale PR we had for React 19 before mine. I assumed it was fixing a cleanup of the ref. Would be good to test properly without it.

Whatever the cause of this bug is - Seeing as the CI tests passed, we should try and enhance the coverage to try and replicate this via CI.

If you were able to recreate the bug consistently is there a particular case where it fails?

As Josh mentioned, the local tests and the deployed docs worked! Odd!

Thoughts?

@kierancap
Copy link

I've just had a proper look through - I think we can remove this safely - I don't think the cleanup is necessary as the next runthrough of updates will overwrite any previously parsed updates. So in terms of the logic for removing it, all good on my end.

One thing I would like to suggest though is to catch the crashing behaviour in a test (like i mentioned before). I guess this is dependant on what causes the crashing, i.e what error it is. What are you guys' thoughts on that? Worth doing?

@dagatsoin
Copy link
Contributor Author

dagatsoin commented May 16, 2025

@kierancap agree to add a test on this, but I would first document the internal a bit. It is not easy to grasp for everyone.
The test will be more relevant.

So, what this business code actually do?

PS: the current website is not up to date so it run with the v9
You have a link upper to see a repro case.

@kierancap
Copy link

Yeah agreed about documenting the internals. Based on my understanding the ref is just handling queuing updates. I think the cleanup line may have come from the thought of needing the clean up an update post process, but based on my glance yesterday it seems like thats unnecessary (I'm also guessing that the ref is used asynchronously in other parts too - hence the crashing)

As to the actual design of the code, I'm not totally sure - It's not well documented so I've just tried to peace together what I can based on the code itself.

The last updates to that system pre our PR was 5-6 years ago, so I doubt we can get any assistance on that either.

If @joshuaellis has any understanding about the spring update system, would be great to enlighten myself and we can write some docs outlining it internally

@joshuaellis
Copy link
Member

If @joshuaellis has any understanding about the spring update system, would be great to enlighten myself and we can write some docs outlining it internally

It was written by a previous maintainer, i normally have to read it a couple of times before i really understand it.

I think the point about it not being caught by a test is important, i would revert the change temporarily and get a test to fail then go from there. It might be that this is caught in a more integration test where you render a component instead of just the hook perhaps?

@kierancap
Copy link

Ah that's fair enough!

Would absolutely be worth testing this from multiple angles, seeing as it escaped us the first time!

I'd be interested to see if the bug can be triggered via the hook alone - or if it's dependant on a full React environment (UI rendering etc) to appear

@dagatsoin
Copy link
Contributor Author

some more in depth explanations @joshuaellis @kierancap

TL;DR
On the second render, declareUpdates is not called because the dependencies of the memo hooks do not change.
As a result, there is no controller, and the animation does not start.

======

After diving back into the code, it's clear that the cleanup line makes no sense. I'm pretty sure it was just a WIP line meant for testing, which accidentally ended up in our PR.

What I learned

Bug reproduction environment

With v10, it does not appear in production mode, regardless of the following conditions:

  • With or without useRef
  • With or without the cleanup line
  • With React 18 or 19

Usage: the updates and controllers arrays

These are internal state structures:

updates stores the successive states the animation should reach. (e.g., when you click repeatedly for an element to follow your mouse, each click adds a new update with a new destination)

ctrls are the controller instances handling each step of the animation

Logic: starting the animation

For a spring, starting an animation means sending the next state to reach.
In our code, this happens at line 209.
If there is a new update, we start the animation.

Usage: useRef

In Strict Mode, React triggers two renders.

When there's only one render, everything works fine

  • updates and ctrls are populated during the useMemo hooks
  • line 209 condition is true
  • the spring starts.

But on the second render, since the useMemo dependencies haven't changed, the useMemo hooks are not triggered, resulting in empty updates and ctrls arrays.

Using useRef (and also useMemo to store ctrls) allows us to keep references to previously populated arrays.

The bug

The bug originates from the cleanup line where we remove an update.
In Strict Mode, this means:

  • First render
  • useMemo is triggered, and updates/ctrls are populated
  • The queue starts processing inside the useIsomorphicLayoutEffect callback:
    • The condition at line 209 is true, so the spring starts
    • The same update is removed from the array
  • Second render
  • useMemo is triggered, but updates/ctrls remain unchanged (💀 )
  • The queue starts processing again in the useIsomorphicLayoutEffect callback:
    • The condition at line 209 is false because the first update was already removed during the previous render and was not be restored during the useMemo hooks

🐛🐛🐛 The spring won't start 🐛 🐛🐛

Solution

Removing the faulty cleanup line.

@kierancap
Copy link

kierancap commented May 20, 2025

some more in depth explanations @joshuaellis @kierancap

TL;DR

On the second render, declareUpdates is not called because the dependencies of the memo hooks do not change.

As a result, there is no controller, and the animation does not start.

======

After diving back into the code, it's clear that the cleanup line makes no sense. I'm pretty sure it was just a WIP line meant for testing, which accidentally ended up in our PR.

What I learned

Bug reproduction environment

With v10, it does not appear in production mode, regardless of the following conditions:

  • With or without useRef

  • With or without the cleanup line

  • With React 18 or 19

Usage: the updates and controllers arrays

These are internal state structures:

updates stores the successive states the animation should reach. (e.g., when you click repeatedly for an element to follow your mouse, each click adds a new update with a new destination)

ctrls are the controller instances handling each step of the animation

Logic: starting the animation

For a spring, starting an animation means sending the next state to reach.

In our code, this happens at line 209.

If there is a new update, we start the animation.

Usage: useRef

In Strict Mode, React triggers two renders.

When there's only one render, everything works fine

  • updates and ctrls are populated during the useMemo hooks

  • line 209 condition is true

  • the spring starts.

But on the second render, since the useMemo dependencies haven't changed, the useMemo hooks are not triggered, resulting in empty updates and ctrls arrays.

Using useRef (and also useMemo to store ctrls) allows us to keep references to previously populated arrays.

The bug

The bug originates from the cleanup line where we remove an update.

In Strict Mode, this means:

  • First render

  • useMemo is triggered, and updates/ctrls are populated

  • The queue starts processing inside the useIsomorphicLayoutEffect callback:

    • The condition at line 209 is true, so the spring starts

    • The same update is removed from the array

  • Second render

  • useMemo is triggered, but updates/ctrls remain unchanged (💀 )

  • The queue starts processing again in the useIsomorphicLayoutEffect callback:

    • The condition at line 209 is false because the first update was already removed during the previous render and was not be restored during the useMemo hooks

🐛🐛🐛 The spring won't start 🐛 🐛🐛

Solution

Removing the faulty cleanup line.

Incredible work diagnosing this!!

It's so rare to see actual occasions where Strict Mode causes bugs like this, but that's what it's there for!

You are probably absolutely correct that the line was probably there for debugging rather than an actual addition to the code. If the bug is purely caused by the ref being removed and Strict Mode causing the oddness, there's not much value in testing that scenario. (I saw a commit about a test, if that's already in place, feel free to keep it! I just know from personal experience that testing Strict Mode oddness is a challenge)

I'd be happy for this PR to be moved forward in that case. If @joshuaellis doesn't have any further comments of course :)

Great work again mate!

@dagatsoin
Copy link
Contributor Author

thx @kierancap!
In fact, the test is really simple and the case was not covered.
You can test it if you want in the current branch by re copying the delete cleanup line to fail it.

@dagatsoin dagatsoin marked this pull request as ready for review May 20, 2025 20:35
Copy link
Member

@joshuaellis joshuaellis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work 👏🏼

@joshuaellis joshuaellis merged commit de1244b into pmndrs:next May 21, 2025
14 checks passed
@joshuaellis joshuaellis mentioned this pull request May 21, 2025
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants