@samitouri / QOS-React-2 / commits / f6790c1068

Add ViewTransition event callback unit tests (#36467)

Pulling some basic test coverage out of https://github.com/facebook/react/pull/36135 as these unit tests are needed regardless of nested enter/exit work.

Jack Pope committed May 14, 2026 at 08:34 UTC f6790c1068234252ecb65b515f170dca825cdcad
1 file changed +286
packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
+286
@@ -18,6 +18,7 @@ let act;
18 let assertLog;
19 let Scheduler;
20 let textCache;
21 +let startTransition;
22
23 describe('ReactDOMViewTransition', () => {
24 let container;
@@ -31,6 +32,7 @@ describe('ReactDOMViewTransition', () => {
32 assertLog = require('internal-test-utils').assertLog;
33 Suspense = React.Suspense;
34 ViewTransition = React.ViewTransition;
35 + startTransition = React.startTransition;
36 if (gate(flags => flags.enableSuspenseList)) {
37 SuspenseList = React.unstable_SuspenseList;
38 }
@@ -176,4 +178,288 @@ describe('ReactDOMViewTransition', () => {
178 expect(container.textContent).toContain('Card 2');
179 expect(container.textContent).toContain('Card 3');
180 });
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 + });
465 });