[RAC] SelectionIndicator remains permanently offset after SSR hydration in StrictMode
Provide a general summary of the issue here
When a SelectionIndicator is rendered through SSR and hydrated inside React StrictMode, it can remain permanently offset from the selected item.
This appears to happen because SharedElement temporarily writes translate, width, and height as inline styles. Its layout-effect cleanup cancels the pending requestAnimationFrame that should restore those properties, but does not perform the restore. The stale inline values can then be reused by the next effect run, leaving the indicator stuck in the wrong position.
Expected Behavior?
After SSR hydration, the SelectionIndicator should settle at the position of the selected item, including when React StrictMode is enabled.
Any temporary inline translate, width, and height values written for the transition should be restored to their previous values. After the transition completes, the indicator should remain aligned with its selected tab.
Current Behavior
With SSR hydration and React StrictMode, selecting an item other than the first item can leave the SelectionIndicator permanently offset from the selected item.
The temporary inline styles are not restored. For example, the indicator can remain in this state:
style="translate: -195.359px; width: 49.5625px; height: 32px;"The incorrect position remains stable after 300 ms, 1.5 seconds, and 3 seconds. It does not settle back to the selected item after the transition. It only corrects itself after another user-driven selection change.
Possible Solution
A possible fix would be to restore the pending inline style values during the layout-effect cleanup instead of only cancelling the animation frame:
return () => {
if (frame != null) {
cancelAnimationFrame(frame);
for (let [property, value] of values) {
element.style[property] = value;
}
}
// Continue with the existing snapshot logic.
};When the cleanup runs before the scheduled frame, no paint has occurred with the temporary override yet. Restoring the previous values before taking the next snapshot prevents the following effect run from reading stale inline styles.
As an additional hardening measure, the implementation could record whether each property was originally unset and call removeProperty() when restoring it. However, this alone does not fix an element that was already left with stale inline values; the cleanup-side restore is still necessary.
Context
We encountered this while building a tab component with React Aria Components in a Next.js App Router application. The development environment uses SSR, hydration, and React StrictMode by default.
The initial selected tab was not the first tab. Its SelectionIndicator appeared over empty space instead of under the selected tab and remained there indefinitely.
We isolated the behavior by testing client rendering, SSR hydration, and StrictMode separately. The permanent offset only occurred when SSR hydration and StrictMode were combined. The reproduction in this report uses Tabs, but the affected shared-element implementation is also used by SelectionIndicator in other collection components.
️ Steps to Reproduce
The issue requires these three conditions:
- Render the component using SSR and hydrate it on the client.
- Wrap the application in React
StrictMode. - Initially select an item that is not the first item.
The SelectionIndicator must also have a CSS transition that includes translate, width, and height.
Minimal component:
import {StrictMode} from "react";
import {hydrateRoot} from "react-dom/client";
import {renderToString} from "react-dom/server";
import {
SelectionIndicator,
Tab,
TabList,
TabPanel,
Tabs
} from "react-aria-components";
const App = () => (
<Tabs selectedKey="t5">
<TabList aria-label="Sections">
{["t1", "t2", "t3", "t4", "t5"].map((key) => (
<Tab id={key} key={key}>
<SelectionIndicator className="indicator" />
{key.toUpperCase()}
</Tab>
))}
</TabList>
<TabPanel id="t5">Panel</TabPanel>
</Tabs>
);
const tree = (
<StrictMode>
<App />
</StrictMode>
);
const container = document.getElementById("root");
container.innerHTML = renderToString(tree);
hydrateRoot(container, tree);Relevant CSS:
.indicator {
transition-property: translate, width, height;
transition-duration: 200ms;
}After hydration, wait for the transition to finish and compare the selected tab's bounding rectangle with the indicator's bounding rectangle.
Measured in Playwright Chromium after waiting two seconds:
| Rendering mode | First item selected | Fifth item selected |
|---|---|---|
| Client render | 0 px | 0 px |
| Client render + StrictMode | 0 px | 0 px |
| SSR + hydration | 0 px | 0 px |
| SSR + hydration + StrictMode | 0 px | approximately -195 px |
The value is indicator.left - selectedTab.left. A correct result is 0.
The incorrect offset remains after 300 ms, 1.5 seconds, and 3 seconds. Selecting another tab manually causes it to correct itself.
Version
react-aria-components 1.20.0 (also reproduced with 1.21.0)
What browsers are you seeing the problem on?
Other
If other, please specify.
Playwright Chromium
What operating system are you using?
macOS
Your Company/Team
No response
Tracking Issue
No response
Source: adobe/react-spectrum