diff --git a/packages/react-components/src/MasterDetailLayout.tsx b/packages/react-components/src/MasterDetailLayout.tsx index 34cb029a..f93f3917 100644 --- a/packages/react-components/src/MasterDetailLayout.tsx +++ b/packages/react-components/src/MasterDetailLayout.tsx @@ -70,6 +70,10 @@ function Detail({ children }: DetailProps) { const nextDetailsKey = currentDetailsKey.current + 1; const [state, setState] = useState('idle'); const [currentChildren, setCurrentChildren] = useState(children); + // The transition completion handler closes over the children from when the + // transition started, so read the latest ones through a ref instead. + const latestChildren = useRef(children); + latestChildren.current = children; useLayoutEffect(() => { const layout = currentDetailsRef.current?.closest( @@ -92,6 +96,7 @@ function Detail({ children }: DetailProps) { // _startTransition calls the callback synchronously for add/replace, // and asynchronously for remove. + let outgoingClone: HTMLElement | undefined; layout ._startTransition(transitionType, async () => { if (transitionType === 'replace' && currentDetailsRef.current) { @@ -100,6 +105,7 @@ function Detail({ children }: DetailProps) { // WC can animate the clone out, then hide the React-managed original // so the WC's post-animation cleanup won't remove it from the DOM. const clone = currentDetailsRef.current.cloneNode(true) as HTMLElement; + outgoingClone = clone; clone.setAttribute('slot', 'detail-outgoing'); layout.appendChild(clone); currentDetailsRef.current.setAttribute('slot', 'detail-hidden'); @@ -119,8 +125,10 @@ function Detail({ children }: DetailProps) { layout.recalculateLayout(); }) .then(() => { - // Animation finished — sync React state to match DOM reality - setCurrentChildren(children); + // Animation finished — remove the clone the WC does not own, then + // sync React state to match DOM reality + outgoingClone?.remove(); + setCurrentChildren(latestChildren.current); currentDetailsKey.current = nextDetailsKey; setState('idle'); }); diff --git a/test/MasterDetailLayout.spec.tsx b/test/MasterDetailLayout.spec.tsx index b69f3a07..1c4f2e71 100644 --- a/test/MasterDetailLayout.spec.tsx +++ b/test/MasterDetailLayout.spec.tsx @@ -388,4 +388,85 @@ describe('MasterDetailLayout', () => { ); }); }); + + describe('real transitions', () => { + function ViewA() { + return
View A
; + } + + function ViewB() { + return
View B
; + } + + function ViewC() { + return
View C
; + } + + function withDetail(view: ReactNode, forceOverlay = false) { + return ( + + Master + {view} + + ); + } + + async function untilTransitionEnds() { + await vi.waitFor(() => { + expect(layout.hasAttribute('transition')).to.be.false; + }); + } + + function outgoingElements() { + return layout.querySelectorAll('[slot="detail-outgoing"]'); + } + + beforeEach(async () => { + // Use the real _startTransition + delete (layout as any)._startTransition; + layout.style.width = '800px'; + + await result.rerender(withDetail()); + await assertDetailsVisible('View A'); + }); + + it('should remove the outgoing clone after a replace transition', async () => { + await result.rerender(withDetail()); + await assertDetailsVisible('View B'); + await untilTransitionEnds(); + + expect(outgoingElements()).to.have.length(0); + expect(layout.textContent).to.not.include('View A'); + + await result.rerender(withDetail()); + await assertDetailsVisible('View C'); + await untilTransitionEnds(); + + expect(outgoingElements()).to.have.length(0); + expect(layout.textContent).to.not.include('View B'); + }); + + it('should run one transition when the detail changes during a transition', async () => { + // Replace is instant in split mode, so use overlay mode where it animates + await result.rerender(withDetail(, true)); + await vi.waitFor(() => { + expect(layout.hasAttribute('overlay')).to.be.true; + }); + const spy = sinon.spy(layout as any, '_startTransition'); + + await result.rerender(withDetail(, true)); + // Change the detail while the replace animation is running + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await result.rerender(withDetail(, true)); + + await assertDetailsVisible('View C'); + await untilTransitionEnds(); + + expect(spy.calledOnce).to.be.true; + expect(outgoingElements()).to.have.length(0); + expect(layout.textContent).to.not.include('View B'); + }); + }); });