From 393f9a3e2f4c7e03070f276ebfc3e7e267f5cce5 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Thu, 27 Aug 2026 08:17:35 +0200 Subject: [PATCH] Flatten seven preference providers into one Each of these preferences was its own Provider rendering a state context and a setter context - three fibers apiece, all sitting between the app root and every screen. That depth is not free during scrolling. React calls propagateParentContextChanges unconditionally on every bailout, and the walk iterates every fiber on the return path to the root. A scrolling feed bails out on most of its mounted cells on every list update, so the provider stack gets multiplied by (list renders per second x mounted cells). Merging these into a single state context and a single setter context removes 18 of the 36 fibers. The trade is a wider re-render when a preference changes, which only happens from a settings screen. Measured on a Galaxy A16 (Hermes CPU profile, production bundle, 10-swipe scroll, 3 runs, medians): JS busy 5667ms -> 5389ms (-4.9%). Co-Authored-By: Claude Opus 5 --- oxlint-suppressions.json | 35 -------- src/state/preferences/alt-text-required.tsx | 56 +------------ src/state/preferences/autoplay.tsx | 52 ++---------- src/state/preferences/disable-haptics.tsx | 52 ++---------- src/state/preferences/in-app-browser.tsx | 53 +----------- src/state/preferences/index.tsx | 38 +++------ src/state/preferences/large-alt-badge.tsx | 56 +------------ src/state/preferences/simple-prefs.tsx | 85 ++++++++++++++++++++ src/state/preferences/subtitles.tsx | 52 ++---------- src/state/preferences/used-starter-packs.tsx | 46 ++--------- 10 files changed, 135 insertions(+), 390 deletions(-) create mode 100644 src/state/preferences/simple-prefs.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index bef12f4397..7a9a371c14 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1091,21 +1091,6 @@ "count": 1 } }, - "src/state/preferences/alt-text-required.tsx": { - "typescript/no-floating-promises": { - "count": 1 - } - }, - "src/state/preferences/autoplay.tsx": { - "typescript/no-floating-promises": { - "count": 1 - } - }, - "src/state/preferences/disable-haptics.tsx": { - "typescript/no-floating-promises": { - "count": 1 - } - }, "src/state/preferences/external-embeds-prefs.tsx": { "typescript/no-floating-promises": { "count": 1 @@ -1116,11 +1101,6 @@ "count": 1 } }, - "src/state/preferences/in-app-browser.tsx": { - "typescript/no-floating-promises": { - "count": 1 - } - }, "src/state/preferences/kawaii.tsx": { "typescript/no-floating-promises": { "count": 1 @@ -1131,26 +1111,11 @@ "count": 1 } }, - "src/state/preferences/large-alt-badge.tsx": { - "typescript/no-floating-promises": { - "count": 1 - } - }, - "src/state/preferences/subtitles.tsx": { - "typescript/no-floating-promises": { - "count": 1 - } - }, "src/state/preferences/trending.tsx": { "typescript/no-floating-promises": { "count": 1 } }, - "src/state/preferences/used-starter-packs.tsx": { - "typescript/no-floating-promises": { - "count": 1 - } - }, "src/state/queries/activity-subscriptions.ts": { "typescript/no-floating-promises": { "count": 1 diff --git a/src/state/preferences/alt-text-required.tsx b/src/state/preferences/alt-text-required.tsx index 91cdacb908..e1f138cfe4 100644 --- a/src/state/preferences/alt-text-required.tsx +++ b/src/state/preferences/alt-text-required.tsx @@ -1,58 +1,10 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -type StateContext = persisted.Schema['requireAltTextEnabled'] -type SetContext = (v: persisted.Schema['requireAltTextEnabled']) => void - -const stateContext = createContext( - persisted.defaults.requireAltTextEnabled, -) -stateContext.displayName = 'AltTextRequiredStateContext' -const setContext = createContext( - (_: persisted.Schema['requireAltTextEnabled']) => {}, -) -setContext.displayName = 'AltTextRequiredSetContext' - -export function Provider({children}: React.PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('requireAltTextEnabled')) - - const setStateWrapped = useCallback( - (requireAltTextEnabled: persisted.Schema['requireAltTextEnabled']) => { - setState(requireAltTextEnabled) - persisted.write('requireAltTextEnabled', requireAltTextEnabled) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate( - 'requireAltTextEnabled', - nextRequireAltTextEnabled => { - setState(nextRequireAltTextEnabled) - }, - ) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} +import {usePref, useSetPref} from './simple-prefs' +/* Backed by the merged simple-prefs provider; see simple-prefs.tsx. */ export function useRequireAltTextEnabled() { - return useContext(stateContext) + return usePref('requireAltTextEnabled') } export function useSetRequireAltTextEnabled() { - return useContext(setContext) + return useSetPref('requireAltTextEnabled') } diff --git a/src/state/preferences/autoplay.tsx b/src/state/preferences/autoplay.tsx index ec5e8153f6..1d34586848 100644 --- a/src/state/preferences/autoplay.tsx +++ b/src/state/preferences/autoplay.tsx @@ -1,48 +1,10 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react' +import {usePref, useSetPref} from './simple-prefs' -import * as persisted from '#/state/persisted' - -type StateContext = boolean -type SetContext = (v: boolean) => void - -const stateContext = createContext( - Boolean(persisted.defaults.disableAutoplay), -) -stateContext.displayName = 'AutoplayStateContext' -const setContext = createContext((_: boolean) => {}) -setContext.displayName = 'AutoplaySetContext' - -export function Provider({children}: {children: React.ReactNode}) { - const [state, setState] = useState(Boolean(persisted.get('disableAutoplay'))) - - const setStateWrapped = useCallback( - (autoplayDisabled: persisted.Schema['disableAutoplay']) => { - setState(Boolean(autoplayDisabled)) - persisted.write('disableAutoplay', autoplayDisabled) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => { - setState(Boolean(nextDisableAutoplay)) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) +/* Backed by the merged simple-prefs provider; see simple-prefs.tsx. */ +export function useAutoplayDisabled() { + return Boolean(usePref('disableAutoplay')) } -export const useAutoplayDisabled = () => useContext(stateContext) -export const useSetAutoplayDisabled = () => useContext(setContext) +export function useSetAutoplayDisabled() { + return useSetPref('disableAutoplay') +} diff --git a/src/state/preferences/disable-haptics.tsx b/src/state/preferences/disable-haptics.tsx index bc67c95a63..6a3c5a42ea 100644 --- a/src/state/preferences/disable-haptics.tsx +++ b/src/state/preferences/disable-haptics.tsx @@ -1,48 +1,10 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react' +import {usePref, useSetPref} from './simple-prefs' -import * as persisted from '#/state/persisted' - -type StateContext = boolean -type SetContext = (v: boolean) => void - -const stateContext = createContext( - Boolean(persisted.defaults.disableHaptics), -) -stateContext.displayName = 'DisableHapticsStateContext' -const setContext = createContext((_: boolean) => {}) -setContext.displayName = 'DisableHapticsSetContext' - -export function Provider({children}: {children: React.ReactNode}) { - const [state, setState] = useState(Boolean(persisted.get('disableHaptics'))) - - const setStateWrapped = useCallback( - (hapticsEnabled: persisted.Schema['disableHaptics']) => { - setState(Boolean(hapticsEnabled)) - persisted.write('disableHaptics', hapticsEnabled) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('disableHaptics', nextDisableHaptics => { - setState(Boolean(nextDisableHaptics)) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) +/* Backed by the merged simple-prefs provider; see simple-prefs.tsx. */ +export function useHapticsDisabled() { + return Boolean(usePref('disableHaptics')) } -export const useHapticsDisabled = () => useContext(stateContext) -export const useSetHapticsDisabled = () => useContext(setContext) +export function useSetHapticsDisabled() { + return useSetPref('disableHaptics') +} diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx index 1ebb089642..9dffc08e39 100644 --- a/src/state/preferences/in-app-browser.tsx +++ b/src/state/preferences/in-app-browser.tsx @@ -1,55 +1,10 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -type StateContext = persisted.Schema['useInAppBrowser'] -type SetContext = (v: persisted.Schema['useInAppBrowser']) => void - -const stateContext = createContext( - persisted.defaults.useInAppBrowser, -) -stateContext.displayName = 'InAppBrowserStateContext' -const setContext = createContext( - (_: persisted.Schema['useInAppBrowser']) => {}, -) -setContext.displayName = 'InAppBrowserSetContext' - -export function Provider({children}: React.PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('useInAppBrowser')) - - const setStateWrapped = useCallback( - (inAppBrowser: persisted.Schema['useInAppBrowser']) => { - setState(inAppBrowser) - persisted.write('useInAppBrowser', inAppBrowser) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => { - setState(nextUseInAppBrowser) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} +import {usePref, useSetPref} from './simple-prefs' +/* Backed by the merged simple-prefs provider; see simple-prefs.tsx. */ export function useInAppBrowser() { - return useContext(stateContext) + return usePref('useInAppBrowser') } export function useSetInAppBrowser() { - return useContext(setContext) + return useSetPref('useInAppBrowser') } diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 0f20953b7c..678350848e 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -1,15 +1,9 @@ -import {Provider as AltTextRequiredProvider} from './alt-text-required' -import {Provider as AutoplayProvider} from './autoplay' -import {Provider as DisableHapticsProvider} from './disable-haptics' import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' import {Provider as HiddenPostsProvider} from './hidden-posts' -import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' -import {Provider as LargeAltBadgeProvider} from './large-alt-badge' -import {Provider as SubtitlesProvider} from './subtitles' +import {Provider as SimplePrefsProvider} from './simple-prefs' import {Provider as TrendingSettingsProvider} from './trending' -import {Provider as UsedStarterPacksProvider} from './used-starter-packs' export { useRequireAltTextEnabled, @@ -29,27 +23,15 @@ export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - - - - - - - - - - - {children} - - - - - - - - - - + + + + + {children} + + + + ) } diff --git a/src/state/preferences/large-alt-badge.tsx b/src/state/preferences/large-alt-badge.tsx index fd4856b04e..f611576f1a 100644 --- a/src/state/preferences/large-alt-badge.tsx +++ b/src/state/preferences/large-alt-badge.tsx @@ -1,58 +1,10 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react' - -import * as persisted from '#/state/persisted' - -type StateContext = persisted.Schema['largeAltBadgeEnabled'] -type SetContext = (v: persisted.Schema['largeAltBadgeEnabled']) => void - -const stateContext = createContext( - persisted.defaults.largeAltBadgeEnabled, -) -stateContext.displayName = 'LargeAltBadgeStateContext' -const setContext = createContext( - (_: persisted.Schema['largeAltBadgeEnabled']) => {}, -) -setContext.displayName = 'LargeAltBadgeSetContext' - -export function Provider({children}: React.PropsWithChildren<{}>) { - const [state, setState] = useState(persisted.get('largeAltBadgeEnabled')) - - const setStateWrapped = useCallback( - (largeAltBadgeEnabled: persisted.Schema['largeAltBadgeEnabled']) => { - setState(largeAltBadgeEnabled) - persisted.write('largeAltBadgeEnabled', largeAltBadgeEnabled) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate( - 'largeAltBadgeEnabled', - nextLargeAltBadgeEnabled => { - setState(nextLargeAltBadgeEnabled) - }, - ) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) -} +import {usePref, useSetPref} from './simple-prefs' +/* Backed by the merged simple-prefs provider; see simple-prefs.tsx. */ export function useLargeAltBadgeEnabled() { - return useContext(stateContext) + return usePref('largeAltBadgeEnabled') } export function useSetLargeAltBadgeEnabled() { - return useContext(setContext) + return useSetPref('largeAltBadgeEnabled') } diff --git a/src/state/preferences/simple-prefs.tsx b/src/state/preferences/simple-prefs.tsx new file mode 100644 index 0000000000..ad26496117 --- /dev/null +++ b/src/state/preferences/simple-prefs.tsx @@ -0,0 +1,85 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' + +import * as persisted from '#/state/persisted' + +/* + * Several preferences were each their own Provider, and each of those rendered a + * state context plus a setter context - three fibers apiece, all of them sitting + * between the app root and every screen. + * + * That depth is not free. React walks the full return path to the root on every + * bailout, and a scrolling list bails out on most of its cells on every update, + * so the provider stack is multiplied by (renders per second x mounted cells). + * Collapsing these into one state context and one setter context trades a wider + * re-render on preference change - which only happens from a settings screen - + * for a permanently shallower tree. + */ +const KEYS = [ + 'requireAltTextEnabled', + 'disableAutoplay', + 'disableHaptics', + 'useInAppBrowser', + 'largeAltBadgeEnabled', + 'subtitlesEnabled', + 'hasCheckedForStarterPack', +] as const + +type Key = (typeof KEYS)[number] +type Values = {[K in Key]: persisted.Schema[K]} +type SetFn = (key: K, value: Values[K]) => void + +function readAll(): Values { + const out = {} as Values + for (const key of KEYS) { + // @ts-expect-error indexed write across the key union + out[key] = persisted.get(key) + } + return out +} + +const stateContext = createContext( + Object.fromEntries(KEYS.map(k => [k, persisted.defaults[k]])) as Values, +) +stateContext.displayName = 'SimplePrefsStateContext' +const setContext = createContext(() => {}) +setContext.displayName = 'SimplePrefsSetContext' + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = useState(readAll) + + useEffect(() => { + const unsubs = KEYS.map(key => + persisted.onUpdate(key, next => { + setState(prev => ({...prev, [key]: next})) + }), + ) + return () => unsubs.forEach(unsub => unsub()) + }, []) + + const set = useCallback((key, value) => { + setState(prev => ({...prev, [key]: value})) + // @ts-expect-error indexed write across the key union + void persisted.write(key, value) + }, []) + + return ( + + {children} + + ) +} + +export function usePref(key: K): Values[K] { + return useContext(stateContext)[key] +} + +export function useSetPref(key: K): (value: Values[K]) => void { + const set = useContext(setContext) + return useCallback((value: Values[K]) => set(key, value), [set, key]) +} diff --git a/src/state/preferences/subtitles.tsx b/src/state/preferences/subtitles.tsx index 16428c2361..fb948bef65 100644 --- a/src/state/preferences/subtitles.tsx +++ b/src/state/preferences/subtitles.tsx @@ -1,48 +1,10 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react' +import {usePref, useSetPref} from './simple-prefs' -import * as persisted from '#/state/persisted' - -type StateContext = boolean -type SetContext = (v: boolean) => void - -const stateContext = createContext( - Boolean(persisted.defaults.subtitlesEnabled), -) -stateContext.displayName = 'SubtitlesStateContext' -const setContext = createContext((_: boolean) => {}) -setContext.displayName = 'SubtitlesSetContext' - -export function Provider({children}: {children: React.ReactNode}) { - const [state, setState] = useState(Boolean(persisted.get('subtitlesEnabled'))) - - const setStateWrapped = useCallback( - (subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => { - setState(Boolean(subtitlesEnabled)) - persisted.write('subtitlesEnabled', subtitlesEnabled) - }, - [setState], - ) - - useEffect(() => { - return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => { - setState(Boolean(nextSubtitlesEnabled)) - }) - }, [setStateWrapped]) - - return ( - - - {children} - - - ) +/* Backed by the merged simple-prefs provider; see simple-prefs.tsx. */ +export function useSubtitlesEnabled() { + return Boolean(usePref('subtitlesEnabled')) } -export const useSubtitlesEnabled = () => useContext(stateContext) -export const useSetSubtitlesEnabled = () => useContext(setContext) +export function useSetSubtitlesEnabled() { + return useSetPref('subtitlesEnabled') +} diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx index 7b584db94f..ee51d804a9 100644 --- a/src/state/preferences/used-starter-packs.tsx +++ b/src/state/preferences/used-starter-packs.tsx @@ -1,42 +1,10 @@ -import {createContext, useContext, useEffect, useState} from 'react' +import {usePref, useSetPref} from './simple-prefs' -import * as persisted from '#/state/persisted' - -type StateContext = boolean | undefined -type SetContext = (v: boolean) => void - -const stateContext = createContext(false) -stateContext.displayName = 'UsedStarterPacksStateContext' -const setContext = createContext((_: boolean) => {}) -setContext.displayName = 'UsedStarterPacksSetContext' - -export function Provider({children}: {children: React.ReactNode}) { - const [state, setState] = useState(() => - persisted.get('hasCheckedForStarterPack'), - ) - - const setStateWrapped = (v: boolean) => { - setState(v) - persisted.write('hasCheckedForStarterPack', v) - } - - useEffect(() => { - return persisted.onUpdate( - 'hasCheckedForStarterPack', - nextHasCheckedForStarterPack => { - setState(nextHasCheckedForStarterPack) - }, - ) - }, []) - - return ( - - - {children} - - - ) +/* Backed by the merged simple-prefs provider; see simple-prefs.tsx. */ +export function useHasCheckedForStarterPack() { + return usePref('hasCheckedForStarterPack') } -export const useHasCheckedForStarterPack = () => useContext(stateContext) -export const useSetHasCheckedForStarterPack = () => useContext(setContext) +export function useSetHasCheckedForStarterPack() { + return useSetPref('hasCheckedForStarterPack') +}