adobe / adobe/react-spectrum

[RAC] SelectionIndicator remains permanently offset after SSR hydration in StrictMode

Open
#10,570 0 comments 1 reaction 0 assignees View on GitHub
Dominant language
TypeScript
Stars
15.9k
Forks
1.6k
Avg merge
3d 9m
Merged PRs (30d)
59

Description

### 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:

```html
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:

```js
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:

1. Render the component using SSR and hydrate it on the client.
2. Wrap the application in React `StrictMode`.
3. 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:

```jsx
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 = () => (


{["t1", "t2", "t3", "t4", "t5"].map((key) => (


{key.toUpperCase()}

))}

Panel

);

const tree = (



);

const container = document.getElementById("root");
container.innerHTML = renderToString(tree);
hydrateRoot(container, tree);
```

Relevant CSS:

```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_

Contributor guide

Open the contributing guide

Research direction

Locate the SharedElement implementation used by SelectionIndicator and inspect its layout-effect cleanup and requestAnimationFrame handling. Reproduce the issue with the provided Tabs example under SSR hydration and React StrictMode. Done means temporary translate, width, and height values are restored during cleanup and the indicator remains aligned with the initially selected tab.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.