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
});