feat(src/plugins/settings): use global state context to reduce rerenders

This commit is contained in:
PalmDevs 2025-07-21 22:15:31 +07:00
parent 6852c816d0
commit d2ac578cff
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
5 changed files with 117 additions and 42 deletions

View file

@ -2,14 +2,14 @@ import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { Design } from '@revenge-mod/discord/design'
import { useState } from 'react'
import type { AssetId } from '@revenge-mod/assets/types'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
export interface FilterAndSortActionSheetProps {
filters: Record<
string,
{
icon: AssetId
filter: (p: AnyPlugin, iflags: number) => boolean
filter: (p: AnyPlugin, meta: InternalPluginMeta) => boolean
}
>
filter: string[]

View file

@ -3,7 +3,7 @@ import { styles } from '@revenge-mod/components/_'
import FormSwitch from '@revenge-mod/components/FormSwitch'
import { Tokens } from '@revenge-mod/discord/common'
import { Design } from '@revenge-mod/discord/design'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import { ReactNavigationNative } from '@revenge-mod/externals/react-navigation'
import {
enablePlugin,
InternalPluginFlags,
@ -11,8 +11,10 @@ import {
preInitPlugin,
startPlugin,
} from '@revenge-mod/plugins/_'
import { memo } from 'react'
import { Image, Pressable } from 'react-native'
import { useClickOutside } from 'react-native-click-outside'
import { usePluginEnabled } from './PluginStateProvider'
import {
enableTooltipTarget,
essentialTooltipTarget,
@ -20,7 +22,7 @@ import {
setEnablePluginTooltipVisible,
setEssentialPluginTooltipVisible,
} from './TooltipProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { View } from 'react-native'
const { Card, Text, Stack, IconButton, createStyles } = Design
@ -28,22 +30,23 @@ const { Card, Text, Stack, IconButton, createStyles } = Design
const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')!
const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')!
export function InstalledPluginCard({
export const InstalledPluginCard = memo(function InstalledPluginCard({
plugin,
iflags,
enabled,
meta,
rightGap,
}: {
plugin: AnyPlugin
iflags: number
enabled: boolean
meta: InternalPluginMeta
rightGap?: boolean
}) {
const navigation = ReactNavigationNative.useNavigation()
const enabled = usePluginEnabled(plugin)
const {
manifest: { name, description, author, icon },
} = plugin
const essential = Boolean(iflags & InternalPluginFlags.Essential)
const essential = Boolean(meta.iflags & InternalPluginFlags.Essential)
const styles_ = usePluginCardStyles()
const settingsRef = useClickOutside<View>(() => {
@ -94,9 +97,7 @@ export function InstalledPluginCard({
variant="secondary"
icon={SettingsIcon}
onPress={() => {
RootNavigationRef.getRootNavigationRef().navigate(
plugin.manifest.id,
)
navigation.navigate(plugin.manifest.id)
}}
disabled={!enabled}
/>
@ -146,7 +147,7 @@ export function InstalledPluginCard({
</Stack>
</Card>
)
}
})
const usePluginCardStyles = createStyles({
icon: {

View file

@ -0,0 +1,77 @@
import { pEmitter, pList } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { ReactNode } from 'react'
export interface PluginEnabledStates {
[pluginId: string]: boolean
}
const PluginStatesContext = createContext<{
enabled: PluginEnabledStates
} | null>(null)
export default function PluginStatesProvider({
children,
}: {
children: ReactNode
}) {
const [enabled, setEnabled] = useState<PluginEnabledStates>(() => {
const states: PluginEnabledStates = {}
for (const plugin of pList.values()) {
states[plugin.manifest.id] = Boolean(
plugin.flags & PluginFlags.Enabled,
)
}
return states
})
useEffect(() => {
const handleEnabled = (plugin: AnyPlugin) => {
setEnabled(prev => ({
...prev,
[plugin.manifest.id]: true,
}))
}
const handleDisabled = (plugin: AnyPlugin) => {
setEnabled(prev => ({
...prev,
[plugin.manifest.id]: false,
}))
}
pEmitter.on('enabled', handleEnabled)
pEmitter.on('disabled', handleDisabled)
return () => {
pEmitter.off('enabled', handleEnabled)
pEmitter.off('disabled', handleDisabled)
}
}, [])
const contextValue = useMemo(
() => ({
enabled: enabled,
}),
[enabled],
)
return (
<PluginStatesContext.Provider value={contextValue}>
{children}
</PluginStatesContext.Provider>
)
}
export function usePluginEnabled(plugin: AnyPlugin) {
const context = useContext(PluginStatesContext)
if (!context) {
console.warn('usePluginEnabled not used within a PluginStateProvider')
return Boolean(plugin.flags & PluginFlags.Enabled)
}
return context.enabled[plugin.manifest.id]
}

View file

@ -15,10 +15,7 @@ const RevengePluginsSetting: SettingsItem = {
type: 'route',
IconComponent: () => <TableRowAssetIcon name="PuzzlePieceIcon" />,
title: () => 'Plugins',
useTrailing: () => {
useUpdateOnPluginStatesChange()
return `${enabledCount} enabled`
},
useTrailing: () => `${useEnabledPluginCount()} enabled`,
screen: {
route: RouteNames[Setting.RevengePlugins],
getComponent: () =>
@ -39,7 +36,7 @@ pEmitter.on('enabled', () => {
enabledCount++
})
export function useUpdateOnPluginStatesChange() {
function useEnabledPluginCount() {
const reRender = useReRender()
useEffect(() => {
@ -51,6 +48,8 @@ export function useUpdateOnPluginStatesChange() {
pEmitter.off('enabled', reRender)
}
}, [reRender])
return enabledCount
}
const { AlertActionButton, AlertModal, Text } = Design

View file

@ -14,15 +14,15 @@ import { useWindowDimensions, View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import RevengeIcon from '~assets/RevengeIcon'
import { InstalledPluginCard } from '../components/PluginCard'
import PluginStatesProvider from '../components/PluginStateProvider'
import {
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
resetTooltips,
} from '../components/TooltipProvider'
import { useUpdateOnPluginStatesChange } from '../definitions/RevengePluginsSetting'
import type { RouteProp } from '@react-navigation/core'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet'
import type { RouteNames, Setting } from '../constants'
@ -34,13 +34,15 @@ export default function RevengePluginsSettingScreen() {
return (
<LayerScope>
<ClickOutsideProvider>
<Page spacing={16}>
<EssentialPluginTooltipProvider>
<EnablePluginTooltipProvider>
<Screen />
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page>
<PluginStatesProvider>
<Page spacing={16}>
<EssentialPluginTooltipProvider>
<EnablePluginTooltipProvider>
<Screen />
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page>
</PluginStatesProvider>
</ClickOutsideProvider>
</LayerScope>
)
@ -63,7 +65,8 @@ const Filters: FilterAndSortActionSheetProps['filters'] = {
},
Essential: {
icon: getAssetIdByName('StarIcon')!,
filter: (_, iflags) => Boolean(iflags & InternalPluginFlags.Essential),
filter: (_, meta) =>
Boolean(meta.iflags & InternalPluginFlags.Essential),
},
} satisfies FilterAndSortActionSheetProps['filters']
const DefaultFilters: FilterAndSortActionSheetProps['filter'] = []
@ -82,8 +85,6 @@ const Sorts = {
} satisfies FilterAndSortActionSheetProps['sorts']
function Screen() {
useUpdateOnPluginStatesChange()
const navigation = ReactNavigationNative.useNavigation()
const route =
ReactNavigationNative.useRoute<
@ -108,18 +109,18 @@ function Screen() {
const allPlugins = useMemo(
() =>
[...pList.values()].map(
plugin => [plugin, pMetadata.get(plugin)!.iflags] as const,
plugin => [plugin, pMetadata.get(plugin)!] as const,
),
[],
)
const plugins = allPlugins
.filter(([plugin, iflags]) => {
.filter(([plugin, meta]) => {
if (filter.length === 0) return true
if (matchAll)
return filter.every(f => Filters[f].filter(plugin, iflags))
return filter.every(f => Filters[f].filter(plugin, meta))
return filter.some(f => Filters[f].filter(plugin, iflags))
return filter.some(f => Filters[f].filter(plugin, meta))
})
.filter(([plugin]) => {
const { name, description, author } = plugin.manifest
@ -132,8 +133,7 @@ function Screen() {
})
.sort(([a], [b]) => {
const result = Sorts[sort as keyof typeof Sorts][1](a, b)
if (reverse) return -result
return result
return reverse ? -result : result
})
return (
@ -190,7 +190,7 @@ function Screen() {
function PluginMasonryFlashList({
plugins,
}: {
plugins: (readonly [AnyPlugin, number])[]
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) {
const { width, height } = useWindowDimensions()
const numColumns = Math.floor((width - 16) / 448)
@ -205,12 +205,10 @@ function PluginMasonryFlashList({
estimatedItemSize={116}
numColumns={numColumns}
ListEmptyComponent={NoPlugins}
renderItem={({ item: [plugin, iflags], columnIndex }) => (
renderItem={({ item: [plugin, meta], columnIndex }) => (
<InstalledPluginCard
key={plugin.manifest.id}
enabled={Boolean(plugin.flags & PluginFlags.Enabled)}
iflags={iflags}
plugin={plugin}
meta={meta}
rightGap={columnIndex + 1 < numColumns}
/>
)}