Skip to content

fix: restore SharedElement styles when a layout effect is cleaned up - #10653

Open
Hashim1999164 wants to merge 4 commits into
adobe:mainfrom
Hashim1999164:fix/sharedElementRestoreOnCleanup
Open

Hashim1999164 wants to merge 4 commits into
adobe:mainfrom
Hashim1999164:fix/sharedElementRestoreOnCleanup

Conversation

@Hashim1999164

@Hashim1999164 Hashim1999164 commented Sep 25, 2026 •

Copy link
Copy Markdown

Closes #10570

SharedElement writes temporary translate, width, and height values so a selection indicator can animate from a previous instance. A requestAnimationFrame then restores the real styles. React StrictMode cleanup cancelled that frame and did not restore, so the next snapshot stored the temporary values. After SSR hydration the indicator stayed offset.

This change restores those styles when the pending frame is cancelled. A browser hydration matrix covers StrictMode on and off, plus first and last selected tabs. A Storybook story wraps the same layout in StrictMode.

Test plan:
Open the AnimatedSelectionIndicator story and confirm the indicator sits on Settings after load.
Run the Tabs browser hydration tests.
Change tabs with mouse and keyboard. The indicator should follow the selected tab.

I used an AI assistant while drafting this. I reviewed the SharedElement restore and the tests, and I pointed the assistant at AGENTS.md.

Checklist:
Linked issue 10570.
Added tests and a story.
Filled test instructions.
Docs were already accurate, so they are unchanged.
Keyboard and mouse tab behavior still works.
I understand every change.
I followed the AI contribution guidance.

Project: personal

StrictMode cleanup cancelled the restore frame and left temporary
translate, width, and height overrides on the element. The next
snapshot then stored those values, so the indicator stayed offset
after hydration.
@github-actions github-actions Bot added the RAC label Sep 25, 2026

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How do either of these display or confirm the issue with SSR?

Can you provide a before and after screenshot? The original issue didn't provide that or an easy reproduction, so it makes it hard to verify.

An additional AI review of this PR claims

In StrictMode, React mounts a SharedElement, immediately tears its effect down, then re-runs it. The teardown saves a position snapshot, so on the re-run the element sees that snapshot and thinks it's animating from a previous element — when really it's just itself. That wrong branch skips the step that clears the entering state, so data-entering stays on the element forever.

and gives this test which currently fails. Please verify if the concern is real.

Test code ```

interface EnteringTabsProps {
onEntering: () => void;
}

function EnteringTabs({onEntering}: EnteringTabsProps) {
let ref = useRef<HTMLDivElement | null>(null);
// data-entering is only applied for a single frame, so observe it rather than polling
// for it. The observer is attached during the commit that mounts the tabs, which is
// before the microtask that applies the entering state runs.
useLayoutEffect(() => {
let observer = new MutationObserver(records => {
if (records.some(r => (r.target as HTMLElement).hasAttribute('data-entering'))) {
onEntering();
}
});
observer.observe(ref.current!, {
attributes: true,
attributeFilter: ['data-entering'],
subtree: true
});
return () => observer.disconnect();
}, [onEntering]);

return (



<TabList aria-label="Entering tabs" style={{display: 'flex', gap: 12}}>
{interruptedKeys.map(key => (
<Tab key={key} id={key} style={{position: 'relative', padding: '12px 20px'}}>
<SelectionIndicator
style={{
position: 'absolute',
inset: 0,
transitionProperty: 'translate, width, height',
transitionDuration: '200ms'
}}
/>
{key}

))}

{interruptedKeys.map(key => (

{key}

))}


);
}

it('does not get stuck in the entering state when effects are double invoked', async () => {
let enteringCount = 0;
let onEntering = () => {
enteringCount++;
};
let {container} = await render(



);

// The entering state is applied on mount...
await expect.poll(() => enteringCount).toBeGreaterThan(0);
// ...and must be cleared once the entering frame has run.
await expect
.poll(() => getIndicator(getTab(container, 'two')).hasAttribute('data-entering'))
.toBe(false);
});

</details>


export type TabsStory = StoryFn<typeof Tabs>;

export const AnimatedSelectionIndicator: TabsStory = () => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can already put the storybook into strict mode with strict=true in the url or using the checkbox in the toolbar

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good call, dropped the StrictMode wrap from the story. use the toolbar checkbox / ?strict=true instead


export type TabsStory = StoryFn<typeof Tabs>;

export const AnimatedSelectionIndicator: TabsStory = () => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I ran this story, it doesn't appear to have changed before or after the changes. Is this what you were seeing? or how did you verify in the storybook?

TabsAnimation.mov

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

yeah the story alone was a weak demo for the ssr/hydration case. the real coverage is the browser hydration matrix in Tabs.browser.test.tsx (strict on/off, first/last selected). that asserts the indicator translate/width/height are cleared after hydrate.

pushed a follow up for the StrictMode double-invoke race your note called out too: a cancelled effect run could still fire its entering microtask and leave data-entering stuck. guarding those async bits now, plus the test you sketched.

@Hashim1999164

Copy link
Copy Markdown
Author

@snowystinger thanks for digging in.

short version:

  • story StrictMode wrap removed (toolbar / strict=true instead)
  • the visual offset bug is mainly an SSR hydration thing, so before/after is covered by the Tabs.browser hydration tests rather than the story alone
  • the AI StrictMode data-entering concern looked real (stale entering microtask after effect cleanup). pushed a guard for that + the double-invoke test you pasted

happy to tweak further if something still looks off

Cancel entering/exiting microtasks and frames from a cleaned-up effect run
so StrictMode double-invoke cannot leave data-entering stuck. Drop the
redundant StrictMode story wrap and cover the race with a browser test.
@Hashim1999164
Hashim1999164 force-pushed the fix/sharedElementRestoreOnCleanup branch from 06c3c1c to 668ff9b Compare September 29, 2026 15:20
@snowystinger

Copy link
Copy Markdown
Member

Thanks, looks like lint and browser tests are failing

@Hashim1999164

Copy link
Copy Markdown
Author

@snowystinger yeah those two were on me.

lint was oxlint yelling about useLayoutEffect coming from react, plus oxfmt on the browser test. both cleaned up.

the strict mode test was stuck at enteringCount 0. cleanup was snapshotting the same node, so the replay thought it was animating from a previous element and skipped entering entirely. i drop that self snapshot now, so the replay still enters and the next frame clears data-entering. pushed.

@snowystinger

Copy link
Copy Markdown
Member

There's more failing

@Hashim1999164

Copy link
Copy Markdown
Author

lint and typecheck were still red. import order is fixed and the dropped snapshot is typed as optional now.

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I was testing the storybook, this PR appears to have broken the forward animation. So arrow left is working, but arrow right is not.

I know it's convenient to rely on the AI, but I'm going to need you, the human, to test these things too. Otherwise this PR will stop helping us and will instead hamper us getting through all the other PRs and work we have. I really appreciate your understanding.

<Tabs defaultSelectedKey="settings">
<TabList aria-label="Sections" style={{display: 'flex', gap: 12}}>
{['overview', 'activity', 'settings'].map(key => (
<Tab key={key} id={key} style={{position: 'relative', padding: '12px 20px'}}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
<Tab key={key} id={key} style={{position: 'relative', padding: '12px 20px'}}>
<Tab
key={key}
id={key}
style={{position: 'relative', padding: '12px 20px', outlineOffset: '2px'}}>

let cancelled = false;
// StrictMode cleanup snapshots this same node. That is not a move between parents,
// so drop it and take the entering path on the replay.
if (prevSnapshot && element && prevSnapshot.element === element) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it's missing isVisible

export type TabsStory = StoryFn<typeof Tabs>;

// Use Storybook toolbar / `?strict=true` for StrictMode. Hydration coverage lives in Tabs.browser.test.tsx.
export const AnimatedSelectionIndicator: TabsStory = () => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I need you to explain why you're adding this story. It does not do SSR, so what does it have to do with the bug report?

You description says:

Open the AnimatedSelectionIndicator story and confirm the indicator sits on Settings after load.

But this was the case prior to your changes as well, nothing has changed from what I see. Can you provide a screenshot of before and after, maybe I'm just missing something?

Should testing this story actually be about data-entering being stuck and the description needs some updating?

<TabList aria-label="Hydrated tabs" style={{display: 'flex', gap: 12}}>
{keys.map(key => (
<Tab key={key} id={key} style={{position: 'relative', padding: '12px 20px'}}>
<SelectionIndicator

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please add styles to this so that I can see it when i watch or pause the browser test, they can just be what the storybook story had

// for it. The observer is attached during the commit that mounts the tabs, which is
// before the microtask that applies the entering state runs.
useLayoutEffect(() => {
let observer = new MutationObserver(records => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

instead of using a MutationObserver, you could probably use renderProps isEntering

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants