Compare commits

...

2 Commits

Author SHA1 Message Date
Dan Abramov 6e4646e1a9 Fix React Rules violations, enable strict lint 2024-11-17 20:40:51 +00:00
Dan Abramov c2ee0ee910 Make React rules violation an error 2024-11-17 19:03:42 +00:00
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 = {
root: true,
extends: [
@@ -79,7 +81,7 @@ module.exports = {
},
],
'simple-import-sort/exports': 'error',
'react-compiler/react-compiler': 'warn',
'react-compiler/react-compiler': ['error', reactCompilerConfig],
'no-restricted-imports': [
'error',
{
+3 -1
View File
@@ -1,3 +1,5 @@
const reactCompilerConfig = require('./react-compiler.config.js')
module.exports = function (api) {
api.cache(true)
const isTestEnv = process.env.NODE_ENV === 'test'
@@ -17,7 +19,7 @@ module.exports = function (api) {
],
plugins: [
'macros',
['babel-plugin-react-compiler', {target: '18'}],
['babel-plugin-react-compiler', reactCompilerConfig],
[
'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)
export function Splash(props: React.PropsWithChildren<Props>) {
'use no memo'
const insets = useSafeAreaInsets()
const intro = useSharedValue(0)
const outroLogo = useSharedValue(0)
+3 -3
View File
@@ -29,10 +29,10 @@ export function useDialogControl(): DialogOuterProps['control'] {
const {activeDialogs} = useDialogStateContext()
React.useEffect(() => {
activeDialogs.current.set(id, control)
const map = activeDialogs.current
map.set(id, control)
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
activeDialogs.current.delete(id)
map.delete(id)
}
}, [id, activeDialogs])
+1
View File
@@ -100,6 +100,7 @@ function Inner({
if (IS_DEV && typeof window !== 'undefined') {
// @ts-ignore
// eslint-disable-next-line react-compiler/react-compiler
window.clearNuxDialog = (id: Nux) => {
if (!IS_DEV || !id) return
removeNuxs([id])
@@ -68,6 +68,8 @@ export function ContentHider({
if (hasAdultContentLabel) {
return false
}
// https://github.com/facebook/react/issues/31569
// eslint-disable-next-line react-compiler/react-compiler
hasAdultContentLabel = 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]
}
+13 -9
View File
@@ -9,20 +9,24 @@ if ('scrollRestoration' in history) {
function createInitialScrollState() {
return {
scrollYs: new Map(),
focusedKey: null as string | null,
// Not used for rendering.
// Treat it as a ref so that we can mutate it without upsetting the compiler.
current: {
scrollYs: new Map(),
focusedKey: null as string | null,
},
}
}
export function useWebScrollRestoration() {
const [state] = useState(createInitialScrollState)
const [ref] = useState(createInitialScrollState)
const navigation = useNavigation()
useEffect(() => {
function onDispatch() {
if (state.focusedKey) {
if (ref.current.focusedKey) {
// 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.
// 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.
@@ -36,17 +40,17 @@ export function useWebScrollRestoration() {
return () => {
navigation.removeListener('__unsafe_action__' as any, onDispatch)
}
}, [state, navigation])
}, [ref, navigation])
const screenListeners = useMemo(
() => ({
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)
state.focusedKey = e.target ?? null
ref.current.focusedKey = e.target ?? null
},
}),
[state],
[ref],
)
return screenListeners
}
+2
View File
@@ -80,6 +80,8 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
React.useEffect(() => {
if (state.pendingSubmit) {
if (!state.pendingSubmit.mutableProcessed) {
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
state.pendingSubmit.mutableProcessed = true
submit(state, dispatch)
}
+2
View File
@@ -173,6 +173,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return wasActive
})
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
unstable__openModal = openModal
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 function useActorAutocompleteQuery(
prefix: string,
rawPrefix: string,
maintainData?: boolean,
limit?: number,
) {
const moderationOpts = useModerationOpts()
const agent = useAgent()
prefix = prefix.toLowerCase().trim()
let prefix = rawPrefix.toLowerCase().trim()
if (prefix.endsWith('.')) {
// Going from "foo" to "foo." should not clear matches.
prefix = prefix.slice(0, -1)
+16 -15
View File
@@ -8,6 +8,7 @@ import {useQueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {resetBadgeCount} from '#/lib/notifications/notifications'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
@@ -50,7 +51,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const [numUnread, setNumUnread] = React.useState('')
const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
const cacheRef = React.useRef<CachedFeedPage>({
usableInFeed: false,
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
React.useEffect(() => {
const listener = ({data}: MessageEvent) => {
@@ -190,7 +177,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
}
}, [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 (
<stateContext.Provider value={numUnread}>
+2
View File
@@ -187,6 +187,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
React.useEffect(() => {
if (state.needsPersist) {
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
state.needsPersist = false
const persistedData = {
accounts: state.accounts,
+6 -3
View File
@@ -61,14 +61,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const {mutateAsync, variables, isPending} =
useSetActiveProgressGuideMutation()
const activeProgressGuide = (
let activeProgressGuide = (
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
) as ProgressGuide
// ensure the unspecced attributes have the correct types
if (activeProgressGuide?.guide === 'like-10-and-follow-7') {
activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0
activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0
activeProgressGuide = {
...activeProgressGuide,
numLikes: Number(activeProgressGuide.numLikes) || 0,
numFollows: Number(activeProgressGuide.numFollows) || 0,
}
}
const [localGuideState, setLocalGuideState] =
@@ -303,6 +303,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
React.useLayoutEffect(() => {
let node = editor?.view.dom
if (node) {
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
node.style.minHeight = webForceMinHeight ? '140px' : ''
}
}, [editor, webForceMinHeight])
@@ -87,7 +87,6 @@ export default function ImageViewRoot({
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
'use no memo'
const ref = useAnimatedRef<View>()
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
const openProgress = useSharedValue(0)
+67 -56
View File
@@ -129,15 +129,15 @@ export const Link = memo(function Link({
)
}
let dataSet = props.dataSet
if (anchorNoUnderline) {
// @ts-ignore web only -prf
props.dataSet = props.dataSet || {}
// @ts-ignore web only -prf
props.dataSet.noUnderline = 1
dataSet = {...dataSet, noUnderline: 1}
}
if (title && !props.accessibilityLabel) {
props.accessibilityLabel = title
let accessibilityLabel = props.accessibilityLabel
if (title && !accessibilityLabel) {
accessibilityLabel = title
}
const Com = props.hoverStyle ? PressableWithHover : Pressable
@@ -150,7 +150,11 @@ export const Link = memo(function Link({
accessibilityRole="link"
// @ts-ignore web only -prf
href={anchorHref}
{...props}>
{...props}
// @ts-ignore web only
dataSet={dataSet}
accessibilityLabel={accessibilityLabel}
accessibilityHint={props.accessibilityHint}>
{children ? children : <Text>{title || 'link'}</Text>}
</Com>
)
@@ -164,7 +168,7 @@ export const TextLink = memo(function TextLink({
text,
numberOfLines,
lineHeight,
dataSet,
dataSet: rawDataSet,
title,
onPress,
onBeforePress,
@@ -187,7 +191,7 @@ export const TextLink = memo(function TextLink({
anchorNoUnderline?: boolean
onBeforePress?: () => void
} & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)})
let {...props} = useLinkProps({to: sanitizeUrl(href)})
const navigation = useNavigationDeduped()
const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink()
@@ -196,61 +200,68 @@ export const TextLink = memo(function TextLink({
console.error('Unable to detect mismatching label')
}
let dataSet = rawDataSet
if (anchorNoUnderline) {
dataSet = dataSet ?? {}
dataSet.noUnderline = 1
dataSet = {
...dataSet,
noUnderline: 1,
}
}
props.onPress = React.useCallback(
(e?: Event) => {
const requiresWarning =
!disableMismatchWarning &&
linkRequiresWarning(href, typeof text === 'string' ? text : '')
if (requiresWarning) {
e?.preventDefault?.()
openModal({
name: 'link-warning',
text: typeof text === 'string' ? text : '',
href,
})
}
if (
isWeb &&
href !== '#' &&
e != null &&
isModifiedEvent(e as React.MouseEvent)
) {
// Let the browser handle opening in new tab etc.
return
}
onBeforePress?.()
if (onPress) {
e?.preventDefault?.()
// @ts-ignore function signature differs by platform -prf
return onPress()
}
return onPressInner(
props = {
...props,
onPress: React.useCallback(
(e?: Event) => {
const requiresWarning =
!disableMismatchWarning &&
linkRequiresWarning(href, typeof text === 'string' ? text : '')
if (requiresWarning) {
e?.preventDefault?.()
openModal({
name: 'link-warning',
text: typeof text === 'string' ? text : '',
href,
})
}
if (
isWeb &&
href !== '#' &&
e != null &&
isModifiedEvent(e as React.MouseEvent)
) {
// Let the browser handle opening in new tab etc.
return
}
onBeforePress?.()
if (onPress) {
e?.preventDefault?.()
// @ts-ignore function signature differs by platform -prf
return onPress()
}
return onPressInner(
closeModal,
navigation,
sanitizeUrl(href),
navigationAction,
openLink,
e,
)
},
[
onBeforePress,
onPress,
closeModal,
openModal,
navigation,
sanitizeUrl(href),
href,
text,
disableMismatchWarning,
navigationAction,
openLink,
e,
)
},
[
onBeforePress,
onPress,
closeModal,
openModal,
navigation,
href,
text,
disableMismatchWarning,
navigationAction,
openLink,
],
)
],
),
}
const hrefAttrs = useMemo(() => {
const isExternal = isExternalUrl(href)
if (isExternal) {
+8 -4
View File
@@ -96,6 +96,8 @@ export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
paddingTop: Math.abs(contentOffset.y),
})
}
// @ts-ignore web only -prf
let dataSet = props.dataSet
if (desktopFixedHeight) {
if (typeof desktopFixedHeight === 'number') {
// @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
// styled in our external CSS.
// -prf
// @ts-ignore web only -prf
props.dataSet = props.dataSet || {}
// @ts-ignore web only -prf
props.dataSet.stableGutters = '1'
dataSet = {
...dataSet,
stableGutters: '1',
}
}
}
return (
@@ -131,6 +133,8 @@ export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl<ItemT>(
style={style}
contentOffset={contentOffset}
{...props}
// @ts-ignore web only -prf
dataSet={dataSet}
/>
)
})