Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/react-components/src/MasterDetailLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -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');
Expand All @@ -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');
});
Expand Down
81 changes: 81 additions & 0 deletions test/MasterDetailLayout.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,4 +388,85 @@ describe('MasterDetailLayout', () => {
);
});
});

describe('real transitions', () => {
function ViewA() {
return <div>View A</div>;
}

function ViewB() {
return <div>View B</div>;
}

function ViewC() {
return <div>View C</div>;
}

function withDetail(view: ReactNode, forceOverlay = false) {
return (
<MasterDetailLayout forceOverlay={forceOverlay}>
<MasterDetailLayout.Master>Master</MasterDetailLayout.Master>
<MasterDetailLayout.Detail>{view}</MasterDetailLayout.Detail>
</MasterDetailLayout>
);
}

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(<ViewA />));
await assertDetailsVisible('View A');
});

it('should remove the outgoing clone after a replace transition', async () => {
await result.rerender(withDetail(<ViewB />));
await assertDetailsVisible('View B');
await untilTransitionEnds();

expect(outgoingElements()).to.have.length(0);
expect(layout.textContent).to.not.include('View A');

await result.rerender(withDetail(<ViewC />));
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(<ViewA />, true));
await vi.waitFor(() => {
expect(layout.hasAttribute('overlay')).to.be.true;
});
const spy = sinon.spy(layout as any, '_startTransition');

await result.rerender(withDetail(<ViewB />, true));
// Change the detail while the replace animation is running
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await result.rerender(withDetail(<ViewC />, 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');
});
});
});
Loading