Skip to content

Infinite render loop on mousemove when Button + controlled Tabs + Skeleton(isLoading) + Text are descendants of a DropZone #10057

@ToyWalrus

Description

@ToyWalrus

Provide a general summary of the issue here

When a tree contains all of the following, any mouse movement after mount throws Maximum update depth exceeded and blanks the page:

  • <DropZone> (react-aria-components)
  • An S2 <Button> whose child is a string/<Text>
  • A controlled <Tabs> (selectedKey + onSelectionChange)
  • A <Skeleton isLoading> containing <Text>, inside a <TabPanel>

Take any one of those away and the bug disappears. The initial render is clean, the loop starts on the first pointermove after mount. Reproduces in dev (Vite + StrictMode) and in production builds.

🤔 Expected Behavior?

Moving the mouse over a page that happens to contain a <DropZone> ancestor of a <Button>, <Tabs>, and <Skeleton> shouldn't kill the page.

😯 Current Behavior

The page renders fine on first paint. The moment the mouse moves anywhere in the element, React throws:

Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
    at getRootForUpdatedFiber
    at enqueueConcurrentHookUpdate
    at dispatchSetStateInternal
    at dispatchSetState
    at <anonymous frame inside a react-aria hook>
    at commitHookEffectListMount
    at commitHookPassiveMountEffects
    at commitPassiveMountOnFiber

Component stack at the point of failure:

<ForwardRef(DropZone)>
<App>

React DevTools' "Highlight updates when components render" shows <Text> flashing at the highest frequency, with the surrounding Button, internal Tabs components, and the page root flashing one revision behind.

A few things Claude and I checked along the way that turned out not to be the cause, in case they save someone time:

  • Initially suspected the useId setValue(newId) path in @react-aria/utils (the #7655 regression via PR #7757). I added a breakpoint on that line but newId is always null, the setter never fires. So whatever hook is looping, it isn't that setValue.
  • I also checked the @react-stately/utils@3.11.0 useControlledState regression (#9420) by pinning to 3.10.8 via a pnpm override. Bug still reproduces.

The loop is reproducible in both:

  • Vite dev server with React Fast Refresh + StrictMode
  • vite build + vite preview production bundle (no HMR, no StrictMode double-invoke)

💁 Possible Solution

I couldn't pin down the exact setter, but the strongest suspect from the static analysis is useValueEffect in @react-aria/utils. Its inner useLayoutEffect runs on every render with no deps:

useLayoutEffect(() => {
  currValue.current = value;
  if (effect.current) nextRef.current();
});

If a downstream consumer (e.g. useSlotId's document.getElementById(id) ? id : undefined generator) keeps producing a different value across renders, setValue is called every commit and never settles. A same-value guard inside nextRef.current() (or a deps array on the useLayoutEffect) may break the cycle...?

🔦 Context

No response

🖥️ Steps to Reproduce

import { StrictMode, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, Provider as S2Provider, Skeleton, Tab, TabList, TabPanel, Tabs, Text } from '@react-spectrum/s2';
import { DropZone as RACDropZone } from 'react-aria-components';

const App = () => {
  const [tab, setTab] = useState('a');

  return (
    <S2Provider locale="en-US">
      <RACDropZone aria-label="DropZone">
        <Button onPress={() => {}}>Sign in</Button>
        <Tabs aria-label="x" selectedKey={tab} onSelectionChange={(k) => setTab(k as string)}>
          <TabList>
            <Tab id="a">A</Tab>
          </TabList>
          <TabPanel id="a">
            <Skeleton isLoading>
              <Text>x</Text>
            </Skeleton>
          </TabPanel>
        </Tabs>
      </RACDropZone>
    </S2Provider>
  );
};

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
);
  1. Drop the snippet into a fresh Vite + React 19 + TS project, using pnpm (doubt if that's relevant or not but it's my environment)
  2. pnpm install, pnpm dev.
  3. Open the page. It renders.
  4. Move the mouse anywhere in the element.
  5. Console fires Maximum update depth exceeded. Page blanks.

Variants that do not trigger the crash (all four conditions are required):

  • Uncontrolled <Tabs> (drop selectedKey / onSelectionChange).
  • <Skeleton> without isLoading.
  • <Button><span>Sign in</span></Button> (bypasses the implicit <Text> wrap).
  • Replacing <DropZone> with a plain <div>

Packages

  • @react-spectrum/s2@1.3.1
  • react-aria-components@1.17.0
  • @react-aria/utils@3.33.0
  • @react-stately/utils@3.10.8 (pinned via pnpm override; bug still reproduces; confirms this is unrelated to @react-stately/utils@3.11.0 triggers rerender loop #9420)
  • react@19.2.4 / react-dom@19.2.4
  • Vite 6 dev server and production preview, StrictMode, Chrome / Firefox / Safari

Version

1.3.1

What browsers are you seeing the problem on?

Chrome

If other, please specify.

No response

What operating system are you using?

MacOS 26.3.1 (Tahoe)

🧢 Your Company/Team

Adobe/Incubator

🕷 Tracking Issue

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions