@@ -18,6 +18,7 @@ let act;
1818let assertLog ;
1919let Scheduler ;
2020let textCache ;
21+ let startTransition ;
2122
2223describe ( '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