Skip to content

fix: retry on Suspense render and preserve hook state#5054

Open
mary-ext wants to merge 1 commit intopreactjs:mainfrom
mary-ext:fix-suspense
Open

fix: retry on Suspense render and preserve hook state#5054
mary-ext wants to merge 1 commit intopreactjs:mainfrom
mary-ext:fix-suspense

Conversation

@mary-ext
Copy link

@mary-ext mary-ext commented Mar 15, 2026

I was figuring out why react-freeze didn't work and it boiled down these issues:

  1. if a parent rerenders while a child suspends, Preact doesn't try rerendering the immediate suspended children. react-freeze throws a promise that never resolves and expects the parent Freeze component to unsuspend it
  2. when a child suspends, all of the hook state gets destroyed, which doesn't exactly align with React's behavior of preserving the state for already-mounted components (components suspending during initial mount have their state discarded, matching React's WIP fiber discard)
  3. when a child suspends, Preact does not differentiate useEffect and useLayoutEffect, only the latter should be cleaned up during suspension

@github-actions
Copy link

github-actions bot commented Mar 15, 2026

📊 Tachometer Benchmark Results

Summary

⏳ Benchmarks are currently running. Results below are out of date.

duration

  • create10k: unsure 🔍 -1% - +1% (-8.22ms - +11.73ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - +0% (-0.03ms - +0.03ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -1% - +2% (-0.65ms - +1.17ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -5% - +1% (-0.81ms - +0.25ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -3% - +2% (-1.65ms - +0.91ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -1% - +7% (-0.01ms - +0.15ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -2% - +2% (-0.73ms - +0.65ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -2% - +5% (-0.60ms - +1.39ms)
    preact-local vs preact-main

usedJSHeapSize

  • create10k: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 +0% - +0% (+0.00ms - +0.01ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -5% - +1% (-0.33ms - +0.10ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -4% - +1% (-0.04ms - +0.01ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +0% (-0.01ms - +0.01ms)
    preact-local vs preact-main

Results

⏳ Benchmarks are currently running. Results below are out of date.
create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local832.16ms - 846.54ms-unsure 🔍
-1% - +1%
-8.22ms - +11.73ms
preact-main830.69ms - 844.50msunsure 🔍
-1% - +1%
-11.73ms - +8.22ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local19.04ms - 19.04ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main19.04ms - 19.04msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-
filter-list

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.50ms - 16.54ms-unsure 🔍
-0% - +0%
-0.03ms - +0.03ms
preact-main16.50ms - 16.54msunsure 🔍
-0% - +0%
-0.03ms - +0.03ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.54ms - 1.55ms-unsure 🔍
+0% - +0%
+0.00ms - +0.01ms
preact-main1.54ms - 1.54msunsure 🔍
-0% - -0%
-0.01ms - -0.00ms
-
hydrate1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local59.63ms - 61.01ms-unsure 🔍
-1% - +2%
-0.65ms - +1.17ms
preact-main59.47ms - 60.65msunsure 🔍
-2% - +1%
-1.17ms - +0.65ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local6.63ms - 6.88ms-unsure 🔍
-5% - +1%
-0.33ms - +0.10ms
preact-main6.69ms - 7.05msunsure 🔍
-2% - +5%
-0.10ms - +0.33ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.33ms - 16.82ms-unsure 🔍
-5% - +1%
-0.81ms - +0.25ms
preact-main16.39ms - 17.32msunsure 🔍
-2% - +5%
-0.25ms - +0.81ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.72ms - 3.73ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main3.72ms - 3.73msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-
replace1k
  • Browser: chrome-headless
  • Sample size: 100
  • Built by: CI #5440
  • Commit: ccf8ff0

duration

VersionAvg timevs preact-localvs preact-main
preact-local58.07ms - 59.72ms-unsure 🔍
-3% - +2%
-1.65ms - +0.91ms
preact-main58.29ms - 60.24msunsure 🔍
-2% - +3%
-0.91ms - +1.65ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.99ms - 3.00ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main2.99ms - 2.99msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local26.25ms - 27.00ms-unsure 🔍
-1% - +3%
-0.37ms - +0.70ms
preact-main26.08ms - 26.84msunsure 🔍
-3% - +1%
-0.70ms - +0.37ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local32.14ms - 33.24ms-unsure 🔍
-3% - +2%
-0.88ms - +0.64ms
preact-main32.29ms - 33.33msunsure 🔍
-2% - +3%
-0.64ms - +0.88ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local33.64ms - 34.59ms-unsure 🔍
-1% - +4%
-0.18ms - +1.22ms
preact-main33.07ms - 34.12msunsure 🔍
-4% - +1%
-1.22ms - +0.18ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local26.24ms - 26.75ms-unsure 🔍
-1% - +2%
-0.23ms - +0.55ms
preact-main26.04ms - 26.64msunsure 🔍
-2% - +1%
-0.55ms - +0.23ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local26.55ms - 28.22ms-unsure 🔍
-1% - +7%
-0.33ms - +1.93ms
preact-main25.83ms - 27.35msunsure 🔍
-7% - +1%
-1.93ms - +0.33ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local21.53ms - 22.14ms-unsure 🔍
-2% - +2%
-0.52ms - +0.48ms
preact-main21.46ms - 22.25msunsure 🔍
-2% - +2%
-0.48ms - +0.52ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 230
  • Built by: CI #5440
  • Commit: ccf8ff0

duration

VersionAvg timevs preact-localvs preact-main
preact-local2.01ms - 2.14ms-unsure 🔍
-1% - +7%
-0.01ms - +0.15ms
preact-main1.96ms - 2.05msunsure 🔍
-7% - +0%
-0.15ms - +0.01ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.00ms - 1.03ms-unsure 🔍
-4% - +1%
-0.04ms - +0.01ms
preact-main1.01ms - 1.05msunsure 🔍
-1% - +4%
-0.01ms - +0.04ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local30.77ms - 31.98ms-unsure 🔍
-2% - +2%
-0.73ms - +0.65ms
preact-main31.08ms - 31.74msunsure 🔍
-2% - +2%
-0.65ms - +0.73ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.25ms - 1.25ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main1.25ms - 1.25msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-
update10th1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local30.16ms - 31.64ms-unsure 🔍
-2% - +5%
-0.60ms - +1.39ms
preact-main29.84ms - 31.17msunsure 🔍
-4% - +2%
-1.39ms - +0.60ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.95ms - 2.96ms-unsure 🔍
-0% - +0%
-0.01ms - +0.01ms
preact-main2.95ms - 2.96msunsure 🔍
-0% - +0%
-0.01ms - +0.01ms
-

tachometer-reporter-action v2 for CI

@JoviDeCroock
Copy link
Member

JoviDeCroock commented Mar 15, 2026

when a child suspends, all of the hook state gets destroyed, which doesn't exactly align with React's behavior of preserving the state

This is kind of incorrect, for a mounted component react preserves the hooks-state however for a component that has yet to mount the state is discarded --> WIP Fiber discarded

when a child suspends, Preact does not differentiate useEffect and useLayoutEffect, only the latter should be cleaned up during suspension

I should check this but in react 17-18 this was that way in React as well - during my research at least. Which is also the reason why urql keeps the promise-cache.

I'm going to try and find time in the following weeks to verify this

@JoviDeCroock
Copy link
Member

I tested this in https://stackblitz.com/edit/vitejs-vite-emrmkojz?file=src%2FApp.tsx&terminal=dev

  • Hooks state is reset when suspend happens during mount, not as part of an update
  • Layout effects are cleaned up during the suspense bubble, regular effects are only cleaned up when the promise resolves --> I prefer not to make this differentiation

@mary-ext
Copy link
Author

mary-ext commented Mar 16, 2026

This is kind of incorrect, for a mounted component react preserves the hooks-state however for a component that has yet to mount the state is discarded --> WIP Fiber discarded

I inferred this would be the case when I glanced on software-mansion/react-freeze@2e6ced2 but somehow ignored it and didn't think of checking in-depth aside from checking whether hook state is preserved at all, definitely worth replicating

@mary-ext
Copy link
Author

mary-ext commented Mar 16, 2026

Layout effects are cleaned up during the suspense bubble, regular effects are only cleaned up when the promise resolves --> I prefer not to make this differentiation

noted, might not make too much sense here I think

@mary-ext mary-ext force-pushed the fix-suspense branch 2 times, most recently from b4efcd7 to c54d8bd Compare March 16, 2026 01:32
resolve().then(assert).catch(assert);
});

it('should reset hooks of components', () => {
Copy link
Member

Choose a reason for hiding this comment

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

Can we re-introduce these tests

Copy link
Author

Choose a reason for hiding this comment

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

reintroduced but renamed the test case since the behavior changed

Comment on lines +243 to +246
this._suspendedVNode = null;
this._pendingSuspensionCount = 0;
this._suspenders = null;
state._suspended = null;
Copy link
Member

@JoviDeCroock JoviDeCroock Mar 16, 2026

Choose a reason for hiding this comment

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

Saving some bytes, I am however weary about mutating state here, what is the reason for this? Also we reset this._suspendedVNode does this mean that two subsequent renders will not work?

Suggested change
this._suspendedVNode = null;
this._pendingSuspensionCount = 0;
this._suspenders = null;
state._suspended = null;
this._pendingSuspensionCount = 0;
state._suspended = this._suspendedVNode = this._suspenders = null;

Copy link
Author

Choose a reason for hiding this comment

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

we need state._suspended to be null immediately as the render code immediately below it checks state._suspended to decide whether to render props.children or the fallback, using setState mean we'd wait another tick.

I'm not sure which case you mean by two subsequent renders here but I have added tests for them

@coveralls
Copy link

Coverage Status

coverage: 98.736% (+0.07%) from 98.666%
when pulling ccf8ff0 on mary-ext:fix-suspense
into 21dd6d0 on preactjs:main.

});

vnode._component.__hooks = null;
if (vnode._dom != null) {
Copy link
Member

Choose a reason for hiding this comment

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

My concern with this is that in Suspenseful scenarios we will set _dom maybe we should use _excess for everything then to counter-act this becoming a problem https://github.com/preactjs/preact/blob/main/src/diff/index.js#L361 - this does mean that we can't backport this to the 10.x release line safely. It would be part of v11

Copy link
Author

@mary-ext mary-ext Mar 16, 2026

Choose a reason for hiding this comment

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

seems fine as a v11-only change, should I change it to _excess then? though it's worth noting that _detachOnNextRender, which calls detachedClone, is only set when we're not in the middle of hydration

/**
* We do not set `suspended: true` during hydration because we want the actual markup
* to remain on screen and hydrate it when the suspense actually gets resolved.
* While in non-hydration cases the usual fallback -> component flow would occour.
*/
if (
!this._pendingSuspensionCount++ &&
!(suspendingVNode._flags & MODE_HYDRATE)
) {
this.setState({
_suspended: (this._detachOnNextRender = this._vnode._children[0])
});
}

Copy link
Member

@JoviDeCroock JoviDeCroock Mar 16, 2026

Choose a reason for hiding this comment

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

I mainly mean that during the old hydration route the hooks clearing logic will miss behave because it will see suspended vnodes as mounted due to the presence of _dom. I don't think it's a bad thing to, in a separate refactor, move that core logic to fully use _excess

Copy link
Author

Choose a reason for hiding this comment

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

ah sorry, should've checked how it actually goes for v10, I've added an _excess check as an extra

- If a parent rerenders while a child suspends, retry rerendering
  suspended children. This supports patterns like react-freeze where
  a never-resolving thenable freezes a subtree and a parent re-render
  clears it.

- Preserve hook state (useState, etc.) across suspend/unsuspend for
  already-mounted components. Discard hook state for components that
  suspend during initial mount (matching React's WIP fiber discard).

- Run effect cleanups (both useEffect and useLayoutEffect) uniformly
  during suspension. Effects rerun when unsuspended.
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