diff --git a/.eslintrc.js b/.eslintrc.js index aace373b21..636e5b7e85 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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': 'error', + 'react-compiler/react-compiler': ['error', reactCompilerConfig], 'no-restricted-imports': [ 'error', { diff --git a/babel.config.js b/babel.config.js index ac872648fe..26278e42a4 100644 --- a/babel.config.js +++ b/babel.config.js @@ -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', { diff --git a/react-compiler.config.js b/react-compiler.config.js new file mode 100644 index 0000000000..ef53610f91 --- /dev/null +++ b/react-compiler.config.js @@ -0,0 +1,9 @@ +'use strict' + +module.exports = { + target: '18', + environment: { + enableTreatRefLikeIdentifiersAsRefs: true, + validateRefAccessDuringRender: false, // TODO: Make it `true`. + }, +} diff --git a/src/Splash.tsx b/src/Splash.tsx index a52b8837d1..0d25f01390 100644 --- a/src/Splash.tsx +++ b/src/Splash.tsx @@ -56,7 +56,6 @@ type Props = { const AnimatedLogo = Animated.createAnimatedComponent(Logo) export function Splash(props: React.PropsWithChildren) { - 'use no memo' const insets = useSafeAreaInsets() const intro = useSharedValue(0) const outroLogo = useSharedValue(0) diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index b479bc7f06..c7909ae889 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -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]) diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 701ae84e69..cafd91848f 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -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]) diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx index 67aef67b46..b0d8800156 100644 --- a/src/components/moderation/ContentHider.tsx +++ b/src/components/moderation/ContentHider.tsx @@ -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 diff --git a/src/lib/hooks/useTimer.ts b/src/lib/hooks/useTimer.ts deleted file mode 100644 index b14a9f24fd..0000000000 --- a/src/lib/hooks/useTimer.ts +++ /dev/null @@ -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) - - // 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] -} diff --git a/src/lib/hooks/useWebScrollRestoration.ts b/src/lib/hooks/useWebScrollRestoration.ts index 36484ba0f1..3f619788aa 100644 --- a/src/lib/hooks/useWebScrollRestoration.ts +++ b/src/lib/hooks/useWebScrollRestoration.ts @@ -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 } diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 1857981a0f..b394191a2a 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -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) } diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 483de99e49..110c1b9130 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -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 diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index acc0467715..6fa2d6e892 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -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) diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index 2ade04246f..40eaa0ab38 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -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(null) const cacheRef = React.useRef({ 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 ( diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index ab3352bf3a..594926f434 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -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, diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx index d64e9984f5..5a8eadba65 100644 --- a/src/state/shell/progress-guide.tsx +++ b/src/state/shell/progress-guide.tsx @@ -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] = diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index fa742d2587..0b1a00e0b0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -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]) diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 4ba056eb0a..34c74ca69d 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -87,7 +87,6 @@ export default function ImageViewRoot({ onPressSave: (uri: string) => void onPressShare: (uri: string) => void }) { - 'use no memo' const ref = useAnimatedRef() const [activeLightbox, setActiveLightbox] = useState(nextLightbox) const openProgress = useSharedValue(0) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 489fbc59cf..7a29d9573d 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -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 : {title || 'link'}} ) @@ -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) { diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 1f030b408c..26c2aafc46 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -96,6 +96,8 @@ export const FlatList_INTERNAL = React.forwardRef(function FlatListImpl( 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( // 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( style={style} contentOffset={contentOffset} {...props} + // @ts-ignore web only -prf + dataSet={dataSet} /> ) })