Skip to content

fix(LineSidebar, OptionWheel): clear rafRef on cleanup so the loop restarts under StrictMode (#1014) - #1019

Open
noron12234 wants to merge 1 commit into
DavidHDev:mainfrom
noron12234:feat/fix-raf-stall-strictmode
Open

fix(LineSidebar, OptionWheel): clear rafRef on cleanup so the loop restarts under StrictMode (#1014)#1019
noron12234 wants to merge 1 commit into
DavidHDev:mainfrom
noron12234:feat/fix-raf-stall-strictmode

Conversation

@noron12234

Copy link
Copy Markdown

Closes #1014.

Root cause

The unmount cleanup cancels the frame but never clears the id:

useEffect(
  () => () => {
    if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
  },
  []
);

React StrictMode runs cleanup and then re-runs the effect on the same instance, so the second mount hits:

const startLoop = useCallback(() => {
  if (rafRef.current != null) return;   // <- stale id from before the cleanup
  ...

and the loop is never scheduled again. runFrame does clear the ref correctly when the animation settles, so the ref is only ever left stale by the cleanup path — which is exactly the StrictMode remount.

That also explains why this is invisible in this repo: the docs site's src/main.jsx does not wrap the app in StrictMode, while the Vite/Next scaffolds consumers start from do.

Reproduction

Stock npm create vite@latest -- --template react-ts app (which renders inside <StrictMode>), official TS-TW sources, hovering the middle of the list:

build --effect set on animation runs
current main, StrictMode on 0 elements
current main, StrictMode off (control) 12 elements
this PR, StrictMode on 12 elements

Values with StrictMode on after the fix are identical to the StrictMode-off control (0.7183, 0.9231, 1.0000, 0.9054, …), so nothing else changed.

Same A/B for OptionWheel, counting elements that received a wheel transform:

build elements transformed
current main, StrictMode on 0
this PR, StrictMode on 5 (translate(-14.02px, calc(-50% - 133.42px)) rotate(-12deg), …)

No console errors in any run.

Why this fix rather than the one in the issue

#1014 suggests making startLoop() always cancelAnimationFrame and restart. That works, but it also throws away the "one loop at a time" invariant and resets lastRef on every call, so a startLoop() during an in-flight animation would restart the frame-timing baseline. The ref is only ever left stale by the cleanup, so clearing it there fixes the cause and leaves the running-loop behaviour untouched. Happy to switch to the other shape if you prefer it.

"Possibly other components using the same animation loop pattern"

Checked. The defect needs all three of: the rAF id stored in a useRef (survives a StrictMode remount), an early-return guard on that ref, and a cancel that does not reset it. Scanning every .jsx/.tsx under src/ for that combination returns exactly these two components and nothing else — the other ~60 rAF components either keep the id in an effect-scoped local (recreated on remount) or have no early-return guard.

Scope

All four variants of both components (8 files), per the contributing guide. Only the cleanup block changed.

Note: these 8 files already fail prettier --check on main, and they still do — I did not reformat them, so the diff stays limited to the fix.

…n restart

The unmount cleanup cancels the pending frame but leaves the id in
rafRef.current. React StrictMode runs cleanup and then re-runs the effect
on the same component instance, so on the second mount startLoop() sees a
non-null rafRef.current, takes the 'a loop is already running' early
return, and the loop is never scheduled again.

Result under StrictMode: OptionWheel never applies transforms and
LineSidebar never updates --effect. Closes DavidHDev#1014.

Applied to all four variants of both components.
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.

[BUG] Animation loop can become permanently stuck because startLoop() never reschedules when rafRef.current is stale

1 participant