88 * scene is explicitly opened.
99 */
1010
11- import React , { Suspense , lazy } from 'react' ;
11+ import React , {
12+ Suspense ,
13+ lazy ,
14+ useCallback ,
15+ useEffect ,
16+ useLayoutEffect ,
17+ useRef ,
18+ useState ,
19+ } from 'react' ;
1220import type { SceneTabId } from '../components/SceneBar/types' ;
1321import { useSceneManager } from '../hooks/useSceneManager' ;
1422import { useI18n } from '@/infrastructure/i18n/hooks/useI18n' ;
@@ -36,6 +44,37 @@ const WelcomeScene = lazy(() => import('./welcome/WelcomeScene'));
3644const MiniAppScene = lazy ( ( ) => import ( './miniapps/MiniAppScene' ) ) ;
3745const PanelViewScene = lazy ( ( ) => import ( './panel-view/PanelViewScene' ) ) ;
3846
47+ // Keep in sync with bitfun-motion-view-exit in app/styles/motion.scss.
48+ const SCENE_EXIT_DURATION_MS = 140 ;
49+
50+ interface SceneTransition {
51+ outgoingTabId : SceneTabId ;
52+ incomingTabId : SceneTabId ;
53+ phase : 'holding' | 'exiting' ;
54+ }
55+
56+ interface SceneReadyBoundaryProps {
57+ sceneId : SceneTabId ;
58+ onReady : ( sceneId : SceneTabId ) => void ;
59+ children : React . ReactNode ;
60+ }
61+
62+ /**
63+ * This effect commits only after a lazy scene has resolved through Suspense.
64+ * It lets the viewport hold the outgoing pixels until the incoming tree is
65+ * actually paintable instead of exposing a fallback between the two scenes.
66+ */
67+ const SceneReadyBoundary : React . FC < SceneReadyBoundaryProps > = ( {
68+ sceneId,
69+ onReady,
70+ children,
71+ } ) => {
72+ useLayoutEffect ( ( ) => {
73+ onReady ( sceneId ) ;
74+ } , [ onReady , sceneId ] ) ;
75+
76+ return < > { children } </ > ;
77+ } ;
3978
4079interface SceneViewportProps {
4180 workspacePath ?: string ;
@@ -45,8 +84,72 @@ interface SceneViewportProps {
4584const SceneViewport : React . FC < SceneViewportProps > = ( { workspacePath, isEntering = false } ) => {
4685 const { openTabs, activeTabId } = useSceneManager ( ) ;
4786 const { t } = useI18n ( 'common' ) ;
87+ const [ transition , setTransition ] = useState < SceneTransition | null > ( null ) ;
88+ const [ readyVersion , setReadyVersion ] = useState ( 0 ) ;
89+ const readySceneIdsRef = useRef < Set < SceneTabId > > ( new Set ( ) ) ;
90+ const previousActiveTabIdRef = useRef < SceneTabId > ( activeTabId ) ;
4891 useDialogCompletionNotify ( ) ;
4992
93+ const markSceneReady = useCallback ( ( sceneId : SceneTabId ) => {
94+ if ( readySceneIdsRef . current . has ( sceneId ) ) return ;
95+ readySceneIdsRef . current . add ( sceneId ) ;
96+ setReadyVersion ( version => version + 1 ) ;
97+ } , [ ] ) ;
98+
99+ // Derive the outgoing id during render as well as from state. This keeps a
100+ // just-closed active tab (notably the welcome tab) in the keyed React tree
101+ // for its exit frame instead of unmounting and remounting it after layout.
102+ const outgoingTabId = previousActiveTabIdRef . current !== activeTabId
103+ ? previousActiveTabIdRef . current
104+ : transition ?. outgoingTabId ?? null ;
105+ const renderedTabIds = openTabs . map ( tab => tab . id ) ;
106+ if ( outgoingTabId && ! renderedTabIds . includes ( outgoingTabId ) ) {
107+ renderedTabIds . push ( outgoingTabId ) ;
108+ }
109+
110+ useLayoutEffect ( ( ) => {
111+ const previousActiveTabId = previousActiveTabIdRef . current ;
112+ previousActiveTabIdRef . current = activeTabId ;
113+
114+ if ( ! previousActiveTabId || previousActiveTabId === activeTabId ) {
115+ return ;
116+ }
117+
118+ setTransition ( {
119+ outgoingTabId : previousActiveTabId ,
120+ incomingTabId : activeTabId ,
121+ phase : readySceneIdsRef . current . has ( activeTabId ) ? 'exiting' : 'holding' ,
122+ } ) ;
123+ } , [ activeTabId ] ) ;
124+
125+ useLayoutEffect ( ( ) => {
126+ if (
127+ transition ?. phase !== 'holding'
128+ || ! readySceneIdsRef . current . has ( transition . incomingTabId )
129+ ) {
130+ return ;
131+ }
132+
133+ setTransition ( current => (
134+ current ?. incomingTabId === transition . incomingTabId
135+ ? { ...current , phase : 'exiting' }
136+ : current
137+ ) ) ;
138+ } , [ readyVersion , transition ] ) ;
139+
140+ useEffect ( ( ) => {
141+ if ( transition ?. phase !== 'exiting' ) return ;
142+
143+ const completedTransition = transition ;
144+ const exitTimer = window . setTimeout ( ( ) => {
145+ setTransition ( current => (
146+ current === completedTransition ? null : current
147+ ) ) ;
148+ } , SCENE_EXIT_DURATION_MS ) ;
149+
150+ return ( ) => window . clearTimeout ( exitTimer ) ;
151+ } , [ transition ] ) ;
152+
50153 // All tabs closed — show empty state
51154 if ( openTabs . length === 0 ) {
52155 return (
@@ -64,19 +167,24 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
64167 return (
65168 < div className = "bitfun-scene-viewport" data-testid = "scene-viewport" >
66169 < div className = "bitfun-scene-viewport__clip" data-testid = "scene-viewport-clip" >
67- { openTabs . map ( tab => {
68- const isActive = tab . id === activeTabId ;
170+ { renderedTabIds . map ( tabId => {
171+ const isActive = tabId === activeTabId ;
172+ const isOutgoing = ! isActive && tabId === outgoingTabId ;
173+ const isExiting = isOutgoing && transition ?. phase === 'exiting' ;
69174 return (
70175 < div
71- key = { tab . id }
176+ key = { tabId }
72177 className = { [
73178 'bitfun-scene-viewport__scene' ,
74179 isActive && 'bitfun-scene-viewport__scene--active' ,
180+ isOutgoing && 'bitfun-scene-viewport__scene--outgoing' ,
181+ isExiting && 'bitfun-scene-viewport__scene--exiting' ,
75182 ] . filter ( Boolean ) . join ( ' ' ) }
76183 aria-hidden = { ! isActive }
77184 data-testid = "scene-viewport-scene"
78- data-scene-id = { tab . id }
185+ data-scene-id = { tabId }
79186 data-scene-active = { isActive ? 'true' : 'false' }
187+ data-scene-transition = { isExiting ? 'exit' : undefined }
80188 >
81189 < Suspense
82190 fallback = {
@@ -92,7 +200,9 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
92200 ) : null
93201 }
94202 >
95- { renderScene ( tab . id , workspacePath , isEntering , isActive ) }
203+ < SceneReadyBoundary sceneId = { tabId } onReady = { markSceneReady } >
204+ { renderScene ( tabId , workspacePath , isEntering , isActive ) }
205+ </ SceneReadyBoundary >
96206 </ Suspense >
97207 </ div >
98208 ) ;
0 commit comments