Skip to content

Commit f6790c1

Browse files
authored
Add ViewTransition event callback unit tests (facebook#36467)
Pulling some basic test coverage out of facebook#36135 as these unit tests are needed regardless of nested enter/exit work.
1 parent 8fc5763 commit f6790c1

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ let act;
1818
let assertLog;
1919
let Scheduler;
2020
let textCache;
21+
let startTransition;
2122

2223
describe('ReactDOMViewTransition', () => {
2324
let container;
@@ -31,6 +32,7 @@ describe('ReactDOMViewTransition', () => {
3132
assertLog = require('internal-test-utils').assertLog;
3233
Suspense = React.Suspense;
3334
ViewTransition = React.ViewTransition;
35+
startTransition = React.startTransition;
3436
if (gate(flags => flags.enableSuspenseList)) {
3537
SuspenseList = React.unstable_SuspenseList;
3638
}
@@ -176,4 +178,288 @@ describe('ReactDOMViewTransition', () => {
176178
expect(container.textContent).toContain('Card 2');
177179
expect(container.textContent).toContain('Card 3');
178180
});
181+
182+
describe('ViewTransition event callbacks', () => {
183+
let originalGetBoundingClientRect;
184+
let originalGetAnimations;
185+
let originalAnimate;
186+
let originalStartViewTransition;
187+
188+
beforeEach(() => {
189+
// Save originals
190+
originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
191+
originalGetAnimations = Element.prototype.getAnimations;
192+
originalAnimate = Element.prototype.animate;
193+
originalStartViewTransition = document.startViewTransition;
194+
195+
// Mock CSS.escape if it doesn't exist
196+
if (typeof CSS === 'undefined') {
197+
global.CSS = {escape: str => str};
198+
} else if (!CSS.escape) {
199+
CSS.escape = str => str;
200+
}
201+
202+
// Mock document.fonts
203+
if (!document.fonts) {
204+
Object.defineProperty(document, 'fonts', {
205+
value: {status: 'loaded', ready: Promise.resolve()},
206+
configurable: true,
207+
});
208+
}
209+
210+
// Mock getAnimations on Element.prototype (Web Animations API)
211+
Element.prototype.getAnimations = function () {
212+
return [];
213+
};
214+
215+
// Mock animate on Element.prototype (Web Animations API)
216+
Element.prototype.animate = function () {
217+
return {cancel() {}, finished: Promise.resolve()};
218+
};
219+
220+
// Mock getBoundingClientRect to return content-length-based sizes
221+
// so that hasInstanceChanged can detect updates when text changes.
222+
Element.prototype.getBoundingClientRect = function () {
223+
const text = this.textContent || '';
224+
const width = text.length * 10 + 10;
225+
const height = 20;
226+
return new DOMRect(0, 0, width, height);
227+
};
228+
229+
// Mock document.startViewTransition
230+
document.startViewTransition = function ({update}) {
231+
update();
232+
return {
233+
ready: Promise.resolve(),
234+
finished: Promise.resolve(),
235+
skipTransition() {},
236+
};
237+
};
238+
});
239+
240+
afterEach(() => {
241+
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
242+
Element.prototype.getAnimations = originalGetAnimations;
243+
Element.prototype.animate = originalAnimate;
244+
if (originalStartViewTransition) {
245+
document.startViewTransition = originalStartViewTransition;
246+
} else {
247+
delete document.startViewTransition;
248+
}
249+
});
250+
251+
// @gate enableViewTransition
252+
it('fires onEnter when a ViewTransition mounts', async () => {
253+
const onEnter = jest.fn();
254+
const startViewTransitionSpy = jest.fn(document.startViewTransition);
255+
document.startViewTransition = startViewTransitionSpy;
256+
257+
function App({show}) {
258+
if (!show) {
259+
return null;
260+
}
261+
return (
262+
<ViewTransition onEnter={onEnter}>
263+
<div>Hello</div>
264+
</ViewTransition>
265+
);
266+
}
267+
268+
const root = ReactDOMClient.createRoot(container);
269+
270+
// Initial render without the ViewTransition
271+
await act(() => {
272+
root.render(<App show={false} />);
273+
});
274+
expect(onEnter).not.toHaveBeenCalled();
275+
expect(startViewTransitionSpy).not.toHaveBeenCalled();
276+
277+
// Mount the ViewTransition inside startTransition
278+
await act(() => {
279+
startTransition(() => {
280+
root.render(<App show={true} />);
281+
});
282+
});
283+
284+
expect(startViewTransitionSpy).toHaveBeenCalled();
285+
expect(onEnter).toHaveBeenCalledTimes(1);
286+
});
287+
288+
// @gate enableViewTransition
289+
it('fires onExit when a ViewTransition unmounts', async () => {
290+
const onExit = jest.fn();
291+
292+
function App({show}) {
293+
if (!show) {
294+
return null;
295+
}
296+
return (
297+
<ViewTransition onExit={onExit}>
298+
<div>Goodbye</div>
299+
</ViewTransition>
300+
);
301+
}
302+
303+
const root = ReactDOMClient.createRoot(container);
304+
305+
// Initial render with the ViewTransition
306+
await act(() => {
307+
startTransition(() => {
308+
root.render(<App show={true} />);
309+
});
310+
});
311+
expect(onExit).not.toHaveBeenCalled();
312+
313+
// Unmount the ViewTransition inside startTransition
314+
await act(() => {
315+
startTransition(() => {
316+
root.render(<App show={false} />);
317+
});
318+
});
319+
320+
expect(onExit).toHaveBeenCalledTimes(1);
321+
});
322+
323+
// @gate enableViewTransition
324+
it('fires onUpdate when content inside a ViewTransition changes', async () => {
325+
const onUpdate = jest.fn();
326+
const onEnter = jest.fn();
327+
328+
function App({text}) {
329+
return (
330+
<ViewTransition onUpdate={onUpdate} onEnter={onEnter}>
331+
<div>{text}</div>
332+
</ViewTransition>
333+
);
334+
}
335+
336+
const root = ReactDOMClient.createRoot(container);
337+
338+
// Initial render
339+
await act(() => {
340+
startTransition(() => {
341+
root.render(<App text="Short" />);
342+
});
343+
});
344+
345+
onEnter.mockClear();
346+
expect(onUpdate).not.toHaveBeenCalled();
347+
348+
// Update content inside startTransition (different text length
349+
// produces different getBoundingClientRect values in our mock)
350+
await act(() => {
351+
startTransition(() => {
352+
root.render(<App text="Much longer content here" />);
353+
});
354+
});
355+
356+
expect(onUpdate).toHaveBeenCalledTimes(1);
357+
// onEnter should NOT fire on an update
358+
expect(onEnter).not.toHaveBeenCalled();
359+
});
360+
361+
// @gate enableViewTransition
362+
it('fires onShare for paired named transitions instead of onEnter/onExit', async () => {
363+
const onShareA = jest.fn();
364+
const onExitA = jest.fn();
365+
const onShareB = jest.fn();
366+
const onEnterB = jest.fn();
367+
368+
function App({page}) {
369+
if (page === 'a') {
370+
return (
371+
<ViewTransition
372+
key="a"
373+
name="hero"
374+
onShare={onShareA}
375+
onExit={onExitA}>
376+
<div>Page A</div>
377+
</ViewTransition>
378+
);
379+
}
380+
return (
381+
<ViewTransition
382+
key="b"
383+
name="hero"
384+
onShare={onShareB}
385+
onEnter={onEnterB}>
386+
<div>Page B</div>
387+
</ViewTransition>
388+
);
389+
}
390+
391+
const root = ReactDOMClient.createRoot(container);
392+
393+
// Render page A
394+
await act(() => {
395+
startTransition(() => {
396+
root.render(<App page="a" />);
397+
});
398+
});
399+
400+
// Clear any enter callbacks from initial mount
401+
onShareA.mockClear();
402+
onExitA.mockClear();
403+
onShareB.mockClear();
404+
onEnterB.mockClear();
405+
406+
// Switch from page A to page B inside startTransition
407+
await act(() => {
408+
startTransition(() => {
409+
root.render(<App page="b" />);
410+
});
411+
});
412+
413+
// onShare should fire on the exiting side (page A)
414+
expect(onShareA).toHaveBeenCalledTimes(1);
415+
// onExit should NOT fire when share takes precedence
416+
expect(onExitA).not.toHaveBeenCalled();
417+
// onEnter should NOT fire on the entering side when paired
418+
expect(onEnterB).not.toHaveBeenCalled();
419+
});
420+
421+
// @gate enableViewTransition
422+
it('fires onEnter when Suspense content resolves', async () => {
423+
const onEnter = jest.fn();
424+
425+
function App() {
426+
return (
427+
<ViewTransition onEnter={onEnter}>
428+
<Suspense fallback={<div>Loading...</div>}>
429+
<div>
430+
<AsyncText text="Loaded" />
431+
</div>
432+
</Suspense>
433+
</ViewTransition>
434+
);
435+
}
436+
437+
const root = ReactDOMClient.createRoot(container);
438+
439+
// Initial render - content suspends
440+
await act(() => {
441+
startTransition(() => {
442+
root.render(<App />);
443+
});
444+
});
445+
446+
assertLog(['Suspend! [Loaded]', 'Suspend! [Loaded]']);
447+
// onEnter fires for the fallback appearing
448+
const enterCallsAfterFallback = onEnter.mock.calls.length;
449+
onEnter.mockClear();
450+
451+
// Resolve the suspended content
452+
await act(() => {
453+
resolveText('Loaded');
454+
});
455+
assertLog(['Loaded']);
456+
457+
expect(container.textContent).toBe('Loaded');
458+
// The reveal of the resolved content should trigger enter
459+
// (or it may have triggered on the initial fallback mount)
460+
expect(
461+
onEnter.mock.calls.length + enterCallsAfterFallback,
462+
).toBeGreaterThanOrEqual(1);
463+
});
464+
});
179465
});

0 commit comments

Comments
 (0)