1- import {
2- createContext ,
3- startTransition ,
4- use ,
5- useCallback ,
6- useEffect ,
7- useState ,
8- useLayoutEffect ,
9- useMemo ,
10- useRef ,
11- type ReactNode ,
12- } from "react" ;
1+ import { createContext , startTransition , use , useCallback , useMemo , type ReactNode } from "react" ;
132import { AppState , Appearance , Platform , useColorScheme } from "react-native" ;
143
154import { useAtomSet , useAtomValue } from "@effect/atom-react" ;
16- import { AsyncResult } from "effect/reactivity" ;
5+ import { Atom , AsyncResult } from "effect/reactivity" ;
176
187import { ScopedTheme , ScopedVariables , Uniwind } from "uniwind" ;
198
@@ -22,9 +11,14 @@ import {
2211 resolveAppearancePreferences ,
2312 type ResolvedAppearance ,
2413} from "../../../lib/appearancePreferences" ;
14+ import { appAtomRegistry } from "../../../state/atom-registry" ;
2515import { mobilePreferencesAtom , updateMobilePreferencesAtom } from "../../../state/preferences" ;
2616import type { Preferences } from "../../../persistence/mobile-preferences" ;
27- import { isSystemColorsAvailable , readSystemColorPalettes } from "../../../lib/materialYouPalette" ;
17+ import {
18+ isSystemColorsAvailable ,
19+ readSystemColorPalettes ,
20+ type MaterialYouPalettes ,
21+ } from "../../../lib/materialYouPalette" ;
2822import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme" ;
2923import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables" ;
3024import type { MobileThemeVariables } from "../../../lib/mobileTheme" ;
@@ -74,6 +68,115 @@ interface AppearancePreferencesContextValue {
7468
7569const AppearancePreferencesContext = createContext < AppearancePreferencesContextValue | null > ( null ) ;
7670
71+ function storedPreferencesOf < E > ( result : AsyncResult . AsyncResult < Preferences , E > ) {
72+ return AsyncResult . isSuccess ( result ) ? result . value : null ;
73+ }
74+
75+ /**
76+ * Material You palettes follow the system wallpaper, which can change while the
77+ * app is in the background.
78+ */
79+ const systemColorPalettesAtom = Atom . make ( ( get ) : MaterialYouPalettes | null => {
80+ const initial = readSystemColorPalettes ( ) ;
81+ if ( ! isSystemColorsAvailable ) return initial ;
82+ let current = initial ;
83+ const refresh = ( ) => {
84+ const next = readSystemColorPalettes ( ) ;
85+ if ( JSON . stringify ( current ) === JSON . stringify ( next ) ) return ;
86+ current = next ;
87+ get . setSelf ( next ) ;
88+ } ;
89+ const subscription = AppState . addEventListener ( "change" , ( state ) => {
90+ if ( state === "active" ) refresh ( ) ;
91+ } ) ;
92+ const focusSubscription = AppState . addEventListener ( "focus" , refresh ) ;
93+ get . addFinalizer ( ( ) => {
94+ subscription . remove ( ) ;
95+ focusSubscription . remove ( ) ;
96+ } ) ;
97+ return initial ;
98+ } ) . pipe ( Atom . withLabel ( "mobile:system-color-palettes" ) ) ;
99+
100+ function resolveThemeVariablesByAppearance (
101+ themeIds : MobileThemeIds ,
102+ palettes : MaterialYouPalettes | null ,
103+ ) : Readonly < Record < MobileThemeAppearance , MobileThemeVariables > > {
104+ const resolve = ( appearance : MobileThemeAppearance ) => {
105+ const base = getMobileThemeRuntimeVariables ( themeIds [ appearance ] , appearance , Platform . OS ) ;
106+ return themeIds [ appearance ] === "material-you" && palettes
107+ ? materialYouPaletteToMobileThemeVariables ( palettes [ appearance ] , appearance , base )
108+ : base ;
109+ } ;
110+ return { light : resolve ( "light" ) , dark : resolve ( "dark" ) } ;
111+ }
112+
113+ /** The appearance whose theme is on screen, read from the native scheme. */
114+ function resolveThemeAppearance ( stored : Preferences | null ) : MobileThemeAppearance {
115+ const themeMode = normalizeMobileThemeMode ( stored ?. themeMode ) ;
116+ if ( themeMode !== "system" ) return themeMode ;
117+ return Appearance . getColorScheme ( ) === "dark" ? "dark" : "light" ;
118+ }
119+
120+ function runtimeStateFor ( stored : Preferences | null ) : MobileThemeRuntimeState {
121+ return {
122+ baseFontSize : resolveAppearancePreferences ( stored ) . baseFontSize ,
123+ themeMode : normalizeMobileThemeMode ( stored ?. themeMode ) ,
124+ themeAppearance : resolveThemeAppearance ( stored ) ,
125+ } ;
126+ }
127+
128+ const themeRuntimeStateAtom = Atom . make ( ( get ) =>
129+ runtimeStateFor ( storedPreferencesOf ( get ( mobilePreferencesAtom ) ) ) ,
130+ ) . pipe (
131+ Atom . withEquality (
132+ ( left : MobileThemeRuntimeState , right : MobileThemeRuntimeState ) =>
133+ left . baseFontSize === right . baseFontSize &&
134+ left . themeMode === right . themeMode &&
135+ left . themeAppearance === right . themeAppearance ,
136+ ) ,
137+ ) ;
138+
139+ // Native appearance and Uniwind text variables are process-global, so the
140+ // last applied state is too. Setters apply ahead of the preference write so the
141+ // first frame after a change already uses it; the atom subscription then sees
142+ // no difference.
143+ let appliedRuntimeState : MobileThemeRuntimeState | null = null ;
144+
145+ function applyThemeRuntime ( next : MobileThemeRuntimeState ) {
146+ for ( const operation of createMobileThemeRuntimeOperations ( appliedRuntimeState , next ) ) {
147+ if ( operation . kind === "update-text-variables" ) {
148+ Uniwind . updateCSSVariables ( operation . themeName , operation . variables ) ;
149+ continue ;
150+ }
151+ if ( operation . kind === "set-appearance-mode" ) {
152+ Appearance . setColorScheme (
153+ operation . themeMode === "system" ? "unspecified" : operation . appearance ,
154+ ) ;
155+ }
156+ }
157+ appliedRuntimeState = next ;
158+ }
159+
160+ function currentRuntimeState ( ) {
161+ return appliedRuntimeState ?? appAtomRegistry . get ( themeRuntimeStateAtom ) ;
162+ }
163+
164+ /**
165+ * App-lifetime: applies theme preferences to the native runtime and holds the
166+ * system palette listeners. Holding them here instead of `keepAlive` lets the
167+ * fast-refresh disposer release the replaced module's AppState listeners.
168+ */
169+ export function installAppearanceRuntime ( ) : ( ) => void {
170+ const unsubscribeRuntime = appAtomRegistry . subscribe ( themeRuntimeStateAtom , applyThemeRuntime , {
171+ immediate : true ,
172+ } ) ;
173+ const releasePalettes = appAtomRegistry . mount ( systemColorPalettesAtom ) ;
174+ return ( ) => {
175+ unsubscribeRuntime ( ) ;
176+ releasePalettes ( ) ;
177+ } ;
178+ }
179+
77180export function AppearancePreferencesProvider ( props : { readonly children : ReactNode } ) {
78181 const preferencesResult = useAtomValue ( mobilePreferencesAtom ) ;
79182 const savePreferences = useAtomSet ( updateMobilePreferencesAtom ) ;
@@ -94,37 +197,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
94197 ) ;
95198 const themeId = themeIds [ themeAppearance ] ;
96199 const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable ;
97- const [ systemColorPalettes , setSystemColorPalettes ] = useState ( readSystemColorPalettes ) ;
98- useEffect ( ( ) => {
99- if ( ! isSystemColorsAvailable ) return ;
100- const refresh = ( ) => {
101- const next = readSystemColorPalettes ( ) ;
102- setSystemColorPalettes ( ( previous ) =>
103- JSON . stringify ( previous ) === JSON . stringify ( next ) ? previous : next ,
104- ) ;
105- } ;
106- const subscription = AppState . addEventListener ( "change" , ( state ) => {
107- if ( state === "active" ) refresh ( ) ;
108- } ) ;
109- const focusSubscription = AppState . addEventListener ( "focus" , refresh ) ;
110- return ( ) => {
111- subscription . remove ( ) ;
112- focusSubscription . remove ( ) ;
113- } ;
114- } , [ ] ) ;
115- const themeVariablesByAppearance = useMemo ( ( ) => {
116- const resolve = ( appearance : MobileThemeAppearance ) => {
117- const base = getMobileThemeRuntimeVariables ( themeIds [ appearance ] , appearance , Platform . OS ) ;
118- return themeIds [ appearance ] === "material-you" && systemColorPalettes
119- ? materialYouPaletteToMobileThemeVariables (
120- systemColorPalettes [ appearance ] ,
121- appearance ,
122- base ,
123- )
124- : base ;
125- } ;
126- return { light : resolve ( "light" ) , dark : resolve ( "dark" ) } ;
127- } , [ themeIds , systemColorPalettes ] ) ;
200+ const systemColorPalettes = useAtomValue ( systemColorPalettesAtom ) ;
201+ const themeVariablesByAppearance = resolveThemeVariablesByAppearance (
202+ themeIds ,
203+ systemColorPalettes ,
204+ ) ;
128205 const themeVariables = themeVariablesByAppearance [ themeAppearance ] ;
129206 const activeThemeName = getMobileUniwindThemeName ( themeId , themeAppearance ) ;
130207 const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences ;
@@ -133,38 +210,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
133210 [ baseFontSize , codeFontSize , codeWordBreak , terminalFontSize ] ,
134211 ) ;
135212 const isReady = AsyncResult . isSuccess ( preferencesResult ) ;
136- const runtimeState = useMemo < MobileThemeRuntimeState > (
137- ( ) => ( {
138- baseFontSize,
139- themeAppearance,
140- themeMode,
141- } ) ,
142- [ baseFontSize , themeAppearance , themeMode ] ,
143- ) ;
144- const appliedRuntimeStateRef = useRef < MobileThemeRuntimeState | null > ( null ) ;
145- const selectedThemeIdsRef = useRef ( themeIds ) ;
146-
147- const applyThemeRuntime = useCallback ( ( next : MobileThemeRuntimeState ) => {
148- const operations = createMobileThemeRuntimeOperations ( appliedRuntimeStateRef . current , next ) ;
149- for ( const operation of operations ) {
150- if ( operation . kind === "update-text-variables" ) {
151- Uniwind . updateCSSVariables ( operation . themeName , operation . variables ) ;
152- continue ;
153- }
154- if ( operation . kind === "set-appearance-mode" ) {
155- Appearance . setColorScheme (
156- operation . themeMode === "system" ? "unspecified" : operation . appearance ,
157- ) ;
158- }
159- }
160- appliedRuntimeStateRef . current = next ;
161- } , [ ] ) ;
162-
163- const syncThemeRuntime = useCallback (
164- ( next : MobileThemeRuntimeState ) => applyThemeRuntime ( next ) ,
165- [ applyThemeRuntime ] ,
166- ) ;
167-
168213 const updatePreferences = useCallback (
169214 ( patch : Partial < Preferences > ) => {
170215 startTransition ( ( ) => savePreferences ( patch ) ) ;
@@ -179,37 +224,30 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
179224 [ savePreferences ] ,
180225 ) ;
181226
182- useLayoutEffect ( ( ) => {
183- selectedThemeIdsRef . current = themeIds ;
184- syncThemeRuntime ( runtimeState ) ;
185- } , [ runtimeState , syncThemeRuntime , themeIds ] ) ;
186-
187227 const setThemeIdForAppearance = useCallback (
188228 ( appearance : MobileThemeAppearance , value : MobileThemeId ) => {
189- const patch = createMobileThemeSelectionPatch (
190- selectedThemeIdsRef . current ,
191- themeAppearance ,
192- appearance ,
193- value ,
229+ // Read at call time: a second pick before the next render must build on
230+ // the first one's optimistic write.
231+ const selected = resolveMobileThemeIds (
232+ storedPreferencesOf ( appAtomRegistry . get ( mobilePreferencesAtom ) ) ?? { } ,
233+ ) ;
234+ updateThemePreferences (
235+ createMobileThemeSelectionPatch ( selected , themeAppearance , appearance , value ) ,
194236 ) ;
195- selectedThemeIdsRef . current = resolveMobileThemeIds ( patch ) ;
196- updateThemePreferences ( patch ) ;
197237 } ,
198238 [ themeAppearance , updateThemePreferences ] ,
199239 ) ;
200240
201241 const setThemeIdForBothAppearances = useCallback (
202242 ( value : MobileThemeId ) => {
203- const patch = createMobileThemePairPatch ( value ) ;
204- selectedThemeIdsRef . current = resolveMobileThemeIds ( patch ) ;
205- updateThemePreferences ( patch ) ;
243+ updateThemePreferences ( createMobileThemePairPatch ( value ) ) ;
206244 } ,
207245 [ updateThemePreferences ] ,
208246 ) ;
209247
210248 const setThemeMode = useCallback (
211249 ( value : MobileThemeMode ) => {
212- const current = appliedRuntimeStateRef . current ?? runtimeState ;
250+ const current = currentRuntimeState ( ) ;
213251
214252 // Clear forced appearance before publishing System so useColorScheme batches the system palette into the preference commit.
215253 if ( value === "system" ) {
@@ -223,23 +261,22 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
223261 themeMode : value ,
224262 } ;
225263
226- updateThemePreferences ( { themeMode : value } ) ;
227264 if ( value === "system" ) {
228- appliedRuntimeStateRef . current = next ;
265+ appliedRuntimeState = next ;
229266 } else {
230- syncThemeRuntime ( next ) ;
267+ applyThemeRuntime ( next ) ;
231268 }
269+ updateThemePreferences ( { themeMode : value } ) ;
232270 } ,
233- [ runtimeState , syncThemeRuntime , updateThemePreferences ] ,
271+ [ updateThemePreferences ] ,
234272 ) ;
235273
236274 const setBaseFontSize = useCallback (
237275 ( value : number ) => {
238- const current = appliedRuntimeStateRef . current ?? runtimeState ;
239- syncThemeRuntime ( { ...current , baseFontSize : value } ) ;
276+ applyThemeRuntime ( { ...currentRuntimeState ( ) , baseFontSize : value } ) ;
240277 updatePreferences ( { baseFontSize : value } ) ;
241278 } ,
242- [ runtimeState , syncThemeRuntime , updatePreferences ] ,
279+ [ updatePreferences ] ,
243280 ) ;
244281
245282 const setTerminalFontSize = useCallback (
0 commit comments