Fix React Rules violations, enable strict lint

This commit is contained in:
Dan Abramov
2024-11-17 20:40:51 +00:00
parent c2ee0ee910
commit 6e4646e1a9
19 changed files with 141 additions and 128 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
const reactCompilerConfig = require('./react-compiler.config.js')
module.exports = { module.exports = {
root: true, root: true,
extends: [ extends: [
@@ -79,7 +81,7 @@ module.exports = {
}, },
], ],
'simple-import-sort/exports': 'error', 'simple-import-sort/exports': 'error',
'react-compiler/react-compiler': 'error', 'react-compiler/react-compiler': ['error', reactCompilerConfig],
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ {
+3 -1
View File
@@ -1,3 +1,5 @@
const reactCompilerConfig = require('./react-compiler.config.js')
module.exports = function (api) { module.exports = function (api) {
api.cache(true) api.cache(true)
const isTestEnv = process.env.NODE_ENV === 'test' const isTestEnv = process.env.NODE_ENV === 'test'
@@ -17,7 +19,7 @@ module.exports = function (api) {
], ],
plugins: [ plugins: [
'macros', 'macros',
['babel-plugin-react-compiler', {target: '18'}], ['babel-plugin-react-compiler', reactCompilerConfig],
[ [
'module:react-native-dotenv', 'module:react-native-dotenv',
{ {
+9
View File
@@ -0,0 +1,9 @@
'use strict'
module.exports = {
target: '18',
environment: {
enableTreatRefLikeIdentifiersAsRefs: true,
validateRefAccessDuringRender: false, // TODO: Make it `true`.
},
}
-1
View File
@@ -56,7 +56,6 @@ type Props = {
const AnimatedLogo = Animated.createAnimatedComponent(Logo) const AnimatedLogo = Animated.createAnimatedComponent(Logo)
export function Splash(props: React.PropsWithChildren<Props>) { export function Splash(props: React.PropsWithChildren<Props>) {
'use no memo'
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const intro = useSharedValue(0) const intro = useSharedValue(0)
const outroLogo = useSharedValue(0) const outroLogo = useSharedValue(0)
+3 -3
View File
@@ -29,10 +29,10 @@ export function useDialogControl(): DialogOuterProps['control'] {
const {activeDialogs} = useDialogStateContext() const {activeDialogs} = useDialogStateContext()
React.useEffect(() => { React.useEffect(() => {
activeDialogs.current.set(id, control) const map = activeDialogs.current
map.set(id, control)
return () => { return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps map.delete(id)
activeDialogs.current.delete(id)
} }
}, [id, activeDialogs]) }, [id, activeDialogs])
+1
View File
@@ -100,6 +100,7 @@ function Inner({
if (IS_DEV && typeof window !== 'undefined') { if (IS_DEV && typeof window !== 'undefined') {
// @ts-ignore // @ts-ignore
// eslint-disable-next-line react-compiler/react-compiler
window.clearNuxDialog = (id: Nux) => { window.clearNuxDialog = (id: Nux) => {
if (!IS_DEV || !id) return if (!IS_DEV || !id) return
removeNuxs([id]) removeNuxs([id])
@@ -68,6 +68,8 @@ export function ContentHider({
if (hasAdultContentLabel) { if (hasAdultContentLabel) {
return false return false
} }
// https://github.com/facebook/react/issues/31569
// eslint-disable-next-line react-compiler/react-compiler
hasAdultContentLabel = true hasAdultContentLabel = true
} }
return true return true
-32
View File
@@ -1,32 +0,0 @@
import * as React from 'react'
/**
* Helper hook to run persistent timers on views
*/
export function useTimer(time: number, handler: () => void) {
const timer = React.useRef<undefined | NodeJS.Timeout>(undefined)
// function to restart the timer
const reset = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
}
timer.current = setTimeout(handler, time)
}, [time, timer, handler])
// function to cancel the timer
const cancel = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = undefined
}
}, [timer])
// start the timer immediately
React.useEffect(() => {
reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return [reset, cancel]
}
+11 -7
View File
@@ -9,20 +9,24 @@ if ('scrollRestoration' in history) {
function createInitialScrollState() { function createInitialScrollState() {
return { return {
// Not used for rendering.
// Treat it as a ref so that we can mutate it without upsetting the compiler.
current: {
scrollYs: new Map(), scrollYs: new Map(),
focusedKey: null as string | null, focusedKey: null as string | null,
},
} }
} }
export function useWebScrollRestoration() { export function useWebScrollRestoration() {
const [state] = useState(createInitialScrollState) const [ref] = useState(createInitialScrollState)
const navigation = useNavigation() const navigation = useNavigation()
useEffect(() => { useEffect(() => {
function onDispatch() { function onDispatch() {
if (state.focusedKey) { if (ref.current.focusedKey) {
// Remember where we were for later. // Remember where we were for later.
state.scrollYs.set(state.focusedKey, window.scrollY) ref.current.scrollYs.set(ref.current.focusedKey, window.scrollY)
// TODO: Strictly speaking, this is a leak. We never clean up. // TODO: Strictly speaking, this is a leak. We never clean up.
// This is because I'm not sure when it's appropriate to clean it up. // This is because I'm not sure when it's appropriate to clean it up.
// It doesn't seem like popstate is enough because it can still Forward-Back again. // It doesn't seem like popstate is enough because it can still Forward-Back again.
@@ -36,17 +40,17 @@ export function useWebScrollRestoration() {
return () => { return () => {
navigation.removeListener('__unsafe_action__' as any, onDispatch) navigation.removeListener('__unsafe_action__' as any, onDispatch)
} }
}, [state, navigation]) }, [ref, navigation])
const screenListeners = useMemo( const screenListeners = useMemo(
() => ({ () => ({
focus(e: EventArg<'focus', boolean | undefined, unknown>) { focus(e: EventArg<'focus', boolean | undefined, unknown>) {
const scrollY = state.scrollYs.get(e.target) ?? 0 const scrollY = ref.current.scrollYs.get(e.target) ?? 0
window.scrollTo(0, scrollY) window.scrollTo(0, scrollY)
state.focusedKey = e.target ?? null ref.current.focusedKey = e.target ?? null
}, },
}), }),
[state], [ref],
) )
return screenListeners return screenListeners
} }
+2
View File
@@ -80,6 +80,8 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
React.useEffect(() => { React.useEffect(() => {
if (state.pendingSubmit) { if (state.pendingSubmit) {
if (!state.pendingSubmit.mutableProcessed) { if (!state.pendingSubmit.mutableProcessed) {
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
state.pendingSubmit.mutableProcessed = true state.pendingSubmit.mutableProcessed = true
submit(state, dispatch) submit(state, dispatch)
} }
+2
View File
@@ -173,6 +173,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return wasActive return wasActive
}) })
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
unstable__openModal = openModal unstable__openModal = openModal
unstable__closeModal = closeModal unstable__closeModal = closeModal
+2 -2
View File
@@ -18,14 +18,14 @@ const RQKEY_ROOT = 'actor-autocomplete'
export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix] export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix]
export function useActorAutocompleteQuery( export function useActorAutocompleteQuery(
prefix: string, rawPrefix: string,
maintainData?: boolean, maintainData?: boolean,
limit?: number, limit?: number,
) { ) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const agent = useAgent() const agent = useAgent()
prefix = prefix.toLowerCase().trim() let prefix = rawPrefix.toLowerCase().trim()
if (prefix.endsWith('.')) { if (prefix.endsWith('.')) {
// Going from "foo" to "foo." should not clear matches. // Going from "foo" to "foo." should not clear matches.
prefix = prefix.slice(0, -1) prefix = prefix.slice(0, -1)
+16 -15
View File
@@ -8,6 +8,7 @@ import {useQueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3' import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast' import BroadcastChannel from '#/lib/broadcast'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {resetBadgeCount} from '#/lib/notifications/notifications' import {resetBadgeCount} from '#/lib/notifications/notifications'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@@ -50,7 +51,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const [numUnread, setNumUnread] = React.useState('') const [numUnread, setNumUnread] = React.useState('')
const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
const cacheRef = React.useRef<CachedFeedPage>({ const cacheRef = React.useRef<CachedFeedPage>({
usableInFeed: false, usableInFeed: false,
syncedAt: new Date(), syncedAt: new Date(),
@@ -70,19 +70,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
}, []) }, [])
// periodic sync
React.useEffect(() => {
if (!hasSession || !checkUnreadRef.current) {
return
}
checkUnreadRef.current() // fire on init
const interval = setInterval(
() => checkUnreadRef.current?.({isPoll: true}),
UPDATE_INTERVAL,
)
return () => clearInterval(interval)
}, [hasSession])
// listen for broadcasts // listen for broadcasts
React.useEffect(() => { React.useEffect(() => {
const listener = ({data}: MessageEvent) => { const listener = ({data}: MessageEvent) => {
@@ -190,7 +177,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, },
} }
}, [setNumUnread, queryClient, moderationOpts, agent]) }, [setNumUnread, queryClient, moderationOpts, agent])
checkUnreadRef.current = api.checkUnread
const checkUnread = useNonReactiveCallback(api.checkUnread)
// periodic sync
React.useEffect(() => {
if (!hasSession) {
return
}
checkUnread() // fire on init
const interval = setInterval(
() => checkUnread({isPoll: true}),
UPDATE_INTERVAL,
)
return () => clearInterval(interval)
}, [hasSession, checkUnread])
return ( return (
<stateContext.Provider value={numUnread}> <stateContext.Provider value={numUnread}>
+2
View File
@@ -187,6 +187,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
React.useEffect(() => { React.useEffect(() => {
if (state.needsPersist) { if (state.needsPersist) {
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
state.needsPersist = false state.needsPersist = false
const persistedData = { const persistedData = {
accounts: state.accounts, accounts: state.accounts,
+6 -3
View File
@@ -61,14 +61,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const {mutateAsync, variables, isPending} = const {mutateAsync, variables, isPending} =
useSetActiveProgressGuideMutation() useSetActiveProgressGuideMutation()
const activeProgressGuide = ( let activeProgressGuide = (
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
) as ProgressGuide ) as ProgressGuide
// ensure the unspecced attributes have the correct types // ensure the unspecced attributes have the correct types
if (activeProgressGuide?.guide === 'like-10-and-follow-7') { if (activeProgressGuide?.guide === 'like-10-and-follow-7') {
activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0 activeProgressGuide = {
activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0 ...activeProgressGuide,
numLikes: Number(activeProgressGuide.numLikes) || 0,
numFollows: Number(activeProgressGuide.numFollows) || 0,
}
} }
const [localGuideState, setLocalGuideState] = const [localGuideState, setLocalGuideState] =
@@ -303,6 +303,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
let node = editor?.view.dom let node = editor?.view.dom
if (node) { if (node) {
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
node.style.minHeight = webForceMinHeight ? '140px' : '' node.style.minHeight = webForceMinHeight ? '140px' : ''
} }
}, [editor, webForceMinHeight]) }, [editor, webForceMinHeight])
@@ -87,7 +87,6 @@ export default function ImageViewRoot({
onPressSave: (uri: string) => void onPressSave: (uri: string) => void
onPressShare: (uri: string) => void onPressShare: (uri: string) => void
}) { }) {
'use no memo'
const ref = useAnimatedRef<View>() const ref = useAnimatedRef<View>()
const [activeLightbox, setActiveLightbox] = useState(nextLightbox) const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
const openProgress = useSharedValue(0) const openProgress = useSharedValue(0)
+23 -12
View File
@@ -129,15 +129,15 @@ export const Link = memo(function Link({
) )
} }
let dataSet = props.dataSet
if (anchorNoUnderline) { if (anchorNoUnderline) {
// @ts-ignore web only -prf // @ts-ignore web only -prf
props.dataSet = props.dataSet || {} dataSet = {...dataSet, noUnderline: 1}
// @ts-ignore web only -prf
props.dataSet.noUnderline = 1
} }
if (title && !props.accessibilityLabel) { let accessibilityLabel = props.accessibilityLabel
props.accessibilityLabel = title if (title && !accessibilityLabel) {
accessibilityLabel = title
} }
const Com = props.hoverStyle ? PressableWithHover : Pressable const Com = props.hoverStyle ? PressableWithHover : Pressable
@@ -150,7 +150,11 @@ export const Link = memo(function Link({
accessibilityRole="link" accessibilityRole="link"
// @ts-ignore web only -prf // @ts-ignore web only -prf
href={anchorHref} href={anchorHref}
{...props}> {...props}
// @ts-ignore web only
dataSet={dataSet}
accessibilityLabel={accessibilityLabel}
accessibilityHint={props.accessibilityHint}>
{children ? children : <Text>{title || 'link'}</Text>} {children ? children : <Text>{title || 'link'}</Text>}
</Com> </Com>
) )
@@ -164,7 +168,7 @@ export const TextLink = memo(function TextLink({
text, text,
numberOfLines, numberOfLines,
lineHeight, lineHeight,
dataSet, dataSet: rawDataSet,
title, title,
onPress, onPress,
onBeforePress, onBeforePress,
@@ -187,7 +191,7 @@ export const TextLink = memo(function TextLink({
anchorNoUnderline?: boolean anchorNoUnderline?: boolean
onBeforePress?: () => void onBeforePress?: () => void
} & TextProps) { } & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)}) let {...props} = useLinkProps({to: sanitizeUrl(href)})
const navigation = useNavigationDeduped() const navigation = useNavigationDeduped()
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink() const openLink = useOpenLink()
@@ -196,12 +200,17 @@ export const TextLink = memo(function TextLink({
console.error('Unable to detect mismatching label') console.error('Unable to detect mismatching label')
} }
let dataSet = rawDataSet
if (anchorNoUnderline) { if (anchorNoUnderline) {
dataSet = dataSet ?? {} dataSet = {
dataSet.noUnderline = 1 ...dataSet,
noUnderline: 1,
}
} }
props.onPress = React.useCallback( props = {
...props,
onPress: React.useCallback(
(e?: Event) => { (e?: Event) => {
const requiresWarning = const requiresWarning =
!disableMismatchWarning && !disableMismatchWarning &&
@@ -250,7 +259,9 @@ export const TextLink = memo(function TextLink({
navigationAction, navigationAction,
openLink, openLink,
], ],
) ),
}
const hrefAttrs = useMemo(() => { const hrefAttrs = useMemo(() => {
const isExternal = isExternalUrl(href) const isExternal = isExternalUrl(href)
if (isExternal) { if (isExternal) {
+8 -4
View File
@@ -96,6 +96,8 @@ export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
paddingTop: Math.abs(contentOffset.y), paddingTop: Math.abs(contentOffset.y),
}) })
} }
// @ts-ignore web only -prf
let dataSet = props.dataSet
if (desktopFixedHeight) { if (desktopFixedHeight) {
if (typeof desktopFixedHeight === 'number') { if (typeof desktopFixedHeight === 'number') {
// @ts-ignore Web only -prf // @ts-ignore Web only -prf
@@ -114,10 +116,10 @@ export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
// around this, we set data-stable-gutters which can then be // around this, we set data-stable-gutters which can then be
// styled in our external CSS. // styled in our external CSS.
// -prf // -prf
// @ts-ignore web only -prf dataSet = {
props.dataSet = props.dataSet || {} ...dataSet,
// @ts-ignore web only -prf stableGutters: '1',
props.dataSet.stableGutters = '1' }
} }
} }
return ( return (
@@ -131,6 +133,8 @@ export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
style={style} style={style}
contentOffset={contentOffset} contentOffset={contentOffset}
{...props} {...props}
// @ts-ignore web only -prf
dataSet={dataSet}
/> />
) )
}) })