1- import { useEffect , useRef , useState } from "react" ;
1+ import { useEffect , useRef , useState , useSyncExternalStore } from "react" ;
22import { Keyboard , View } from "react-native" ;
33import {
44 CommonActions ,
@@ -10,6 +10,8 @@ import {
1010import { AsyncResult } from "effect/unstable/reactivity" ;
1111
1212import { useConnectionController } from "../connection/useConnectionController" ;
13+ import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider" ;
14+ import type { MobileThemeId } from "../../lib/mobileTheme" ;
1315import { useProjects , useThreadShells } from "../../state/entities" ;
1416import { enqueueThreadOutboxMessage } from "../../state/thread-outbox" ;
1517import { holdEditingQueuedMessage } from "../../state/use-thread-outbox" ;
@@ -19,6 +21,7 @@ import {
1921 getNativeShowcaseOrientation ,
2022 getNativeShowcasePairingUrls ,
2123 getNativeShowcaseScene ,
24+ getNativeShowcaseTheme ,
2225 markNativeShowcaseReady ,
2326 type ShowcaseScene ,
2427} from "./nativeShowcaseScene" ;
@@ -27,6 +30,12 @@ import {
2730 SHOWCASE_PENDING_TASK_DEFINITIONS ,
2831} from "./showcasePendingTasks" ;
2932import { retryShowcaseOperation } from "./showcaseRetry" ;
33+ import {
34+ clearShowcaseRenderSignal ,
35+ getShowcaseRenderSignal ,
36+ isShowcaseNativeContentReady ,
37+ subscribeToShowcaseRenderSignal ,
38+ } from "./showcaseRenderSignal" ;
3039
3140const SHOWCASE_ENABLED = process . env . EXPO_PUBLIC_SHOWCASE === "1" ;
3241const SHOWCASE_THREAD_ID = "remote-command-center" ;
@@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null {
4857export function ShowcaseCaptureCoordinator ( props : { readonly pathname : string } ) {
4958 const navigation = useNavigation ( ) ;
5059 const { connectPairingUrl } = useConnectionController ( ) ;
60+ const {
61+ isReady : appearancePreferencesReady ,
62+ themeId,
63+ themeIds,
64+ setThemeIdForBothAppearances,
65+ } = useAppearancePreferences ( ) ;
5166 const workspace = useWorkspaceState ( ) ;
5267 const projects = useProjects ( ) ;
5368 const threads = useThreadShells ( ) ;
@@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
5671 const [ pairingUrls , setPairingUrls ] = useState < ReadonlyArray < string > > ( [ ] ) ;
5772 const [ pendingTasksReady , setPendingTasksReady ] = useState ( false ) ;
5873 const [ requestedScene , setRequestedScene ] = useState < ShowcaseScene | null > ( null ) ;
74+ const [ requestedTheme , setRequestedTheme ] = useState < MobileThemeId | null > ( null ) ;
75+ const [ themeRequestSettled , setThemeRequestSettled ] = useState ( false ) ;
5976 const [ readyScene , setReadyScene ] = useState < ShowcaseScene | null > ( null ) ;
6077 const [ orientationSettled , setOrientationSettled ] = useState ( false ) ;
78+ const requestedSceneRef = useRef < ShowcaseScene | null > ( null ) ;
79+ const renderSignal = useSyncExternalStore (
80+ subscribeToShowcaseRenderSignal ,
81+ getShowcaseRenderSignal ,
82+ getShowcaseRenderSignal ,
83+ ) ;
6184
6285 useEffect ( ( ) => {
6386 if ( ! SHOWCASE_ENABLED || pairingUrls . length > 0 ) return ;
6487
65- const readPairingUrls = ( ) => {
88+ const readLaunchRequest = ( ) => {
6689 const values = getNativeShowcasePairingUrls ( ) ;
67- if ( values . length > 0 ) setPairingUrls ( values ) ;
90+ if ( values . length === 0 ) return ;
91+ // The palette rides the same launch request as the pairing URLs, so
92+ // reading it here settles it without a timeout that could expire while
93+ // the request is still on its way.
94+ setRequestedTheme ( getNativeShowcaseTheme ( ) ) ;
95+ setThemeRequestSettled ( true ) ;
96+ setPairingUrls ( values ) ;
6897 } ;
69- readPairingUrls ( ) ;
70- const interval = setInterval ( readPairingUrls , 250 ) ;
98+ readLaunchRequest ( ) ;
99+ const interval = setInterval ( readLaunchRequest , 250 ) ;
71100 return ( ) => clearInterval ( interval ) ;
72101 } , [ pairingUrls . length ] ) ;
73102
@@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
95124
96125 const readRequestedScene = ( ) => {
97126 const value = getNativeShowcaseScene ( ) ;
98- if ( value ) setRequestedScene ( value ) ;
127+ if ( ! value || requestedSceneRef . current === value ) return ;
128+ requestedSceneRef . current = value ;
129+ // A native draw belongs only to the scene request that produced it. In
130+ // particular, revisiting review must wait for its newly mounted surface.
131+ clearShowcaseRenderSignal ( ) ;
132+ setRequestedScene ( value ) ;
99133 } ;
100134 readRequestedScene ( ) ;
101135 const interval = setInterval ( readRequestedScene , 250 ) ;
102136 return ( ) => clearInterval ( interval ) ;
103137 } , [ ] ) ;
104138
139+ // Captures pick a palette for both color schemes so the requested theme is
140+ // used whichever system appearance the runner set on the device.
141+ const themeApplied =
142+ requestedTheme === null
143+ ? themeRequestSettled
144+ : themeIds . light === requestedTheme && themeIds . dark === requestedTheme ;
145+
146+ useEffect ( ( ) => {
147+ if (
148+ ! SHOWCASE_ENABLED ||
149+ requestedTheme === null ||
150+ themeApplied ||
151+ // Writing before stored preferences load would be overwritten by them.
152+ ! appearancePreferencesReady
153+ ) {
154+ return ;
155+ }
156+ setThemeIdForBothAppearances ( requestedTheme ) ;
157+ } , [ appearancePreferencesReady , requestedTheme , setThemeIdForBothAppearances , themeApplied ] ) ;
158+
105159 useEffect ( ( ) => {
106160 if ( ! SHOWCASE_ENABLED || pairingUrls . length === 0 ) return ;
107161 let cancelled = false ;
@@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
219273 ! hasFixture ||
220274 // Never report a scene ready while the capture orientation is still
221275 // being applied — a screenshot taken early has the wrong dimensions.
222- ! orientationSettled
276+ ! orientationSettled ||
277+ // Likewise for the palette: an early screenshot shows the default theme.
278+ ! themeApplied ||
279+ ! isShowcaseNativeContentReady ( { scene, themeId, renderSignal } )
223280 ) {
224281 setReadyScene ( null ) ;
225282 return ;
226283 }
227- // Review owns its readiness marker because route activation happens before
228- // the VCS request is parsed and the native diff surface is mounted.
229- if ( scene === "review" ) {
230- setReadyScene ( null ) ;
231- return ;
232- }
233284 if ( scene === "terminal" ) Keyboard . dismiss ( ) ;
234285
235286 let renderFrame : number | null = null ;
@@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
247298 if ( renderFrame !== null ) cancelAnimationFrame ( renderFrame ) ;
248299 if ( readyFrame !== null ) cancelAnimationFrame ( readyFrame ) ;
249300 } ;
250- } , [ hasFixture , orientationSettled , requestedScene , scene ] ) ;
301+ } , [ hasFixture , orientationSettled , renderSignal , requestedScene , scene , themeApplied , themeId ] ) ;
251302
252303 if ( ! SHOWCASE_ENABLED || readyScene === null ) return null ;
253304
0 commit comments