From fb8d7144a4d6beea51ff649173cdc34fdc883871 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 11 Mar 2026 14:28:11 +0200 Subject: [PATCH 1/5] refactor(shared): tighten ts-strict typing helpers --- .../containers/InfiniteScrolling.tsx | 6 +-- .../components/feeds/FeedSettings/types.ts | 5 +- .../src/components/modals/ShareModal.tsx | 12 +++-- packages/shared/src/graphql/types.ts | 3 +- packages/shared/src/hooks/useEventListener.ts | 53 +++++++++---------- packages/shared/src/lib/form.ts | 9 ++-- packages/shared/src/lib/func.ts | 3 +- 7 files changed, 43 insertions(+), 48 deletions(-) diff --git a/packages/shared/src/components/containers/InfiniteScrolling.tsx b/packages/shared/src/components/containers/InfiniteScrolling.tsx index ffbc3e21b8c..3775b9e5bb4 100644 --- a/packages/shared/src/components/containers/InfiniteScrolling.tsx +++ b/packages/shared/src/components/containers/InfiniteScrolling.tsx @@ -22,10 +22,8 @@ export type InfiniteScrollingQueryProps = Pick< 'canFetchMore' | 'isFetchingNextPage' | 'fetchNextPage' | 'placeholder' >; -export const checkFetchMore = ( - // (Specific since we don't know inferred type) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - queryResult: UseInfiniteQueryResult>, +export const checkFetchMore = ( + queryResult: UseInfiniteQueryResult>, ): boolean => !queryResult.isLoading && !queryResult.isFetchingNextPage && diff --git a/packages/shared/src/components/feeds/FeedSettings/types.ts b/packages/shared/src/components/feeds/FeedSettings/types.ts index 0ab6da3eddb..a0379c6925b 100644 --- a/packages/shared/src/components/feeds/FeedSettings/types.ts +++ b/packages/shared/src/components/feeds/FeedSettings/types.ts @@ -25,10 +25,7 @@ export type FeedSettingsEditContextValue = { onDiscard: ({ activeView }?: { activeView?: string }) => Promise; isDirty: boolean; onBackToFeed: ({ action }: { action: 'discard' | 'save' }) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - editFeedSettings: any>( - callback?: T, - ) => ReturnType; + editFeedSettings: (callback?: () => TResult) => TResult | undefined; isNewFeed: boolean; }; diff --git a/packages/shared/src/components/modals/ShareModal.tsx b/packages/shared/src/components/modals/ShareModal.tsx index 3351dec41e9..29fe46816be 100644 --- a/packages/shared/src/components/modals/ShareModal.tsx +++ b/packages/shared/src/components/modals/ShareModal.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React, { useContext, useEffect, useState } from 'react'; import { useSwipeable } from 'react-swipeable'; +import type { SwipeEventData } from 'react-swipeable'; import { SocialShare } from '../widgets/SocialShare'; import { useLogContext } from '../../contexts/LogContext'; import { postLogEvent } from '../../lib/feed'; @@ -52,9 +53,14 @@ export default function ShareModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onSwipedDown = (e: any) => { - const { scrollTop } = e.event.currentTarget; + const onSwipedDown = (e: SwipeEventData) => { + const currentTarget = e.event.currentTarget as HTMLElement | null; + + if (!currentTarget) { + return; + } + + const { scrollTop } = currentTarget; if (scrollTop === 0) { onRequestClose(e); diff --git a/packages/shared/src/graphql/types.ts b/packages/shared/src/graphql/types.ts index 87a6d59c700..7f590e8af78 100644 --- a/packages/shared/src/graphql/types.ts +++ b/packages/shared/src/graphql/types.ts @@ -1,7 +1,6 @@ declare module 'graphql-request/dist/types' { interface GraphQLError { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extensions?: Record; + extensions?: Record; message?: string; } } diff --git a/packages/shared/src/hooks/useEventListener.ts b/packages/shared/src/hooks/useEventListener.ts index 6aef7401996..752d6409574 100644 --- a/packages/shared/src/hooks/useEventListener.ts +++ b/packages/shared/src/hooks/useEventListener.ts @@ -61,8 +61,7 @@ export type DOMEventMapDefinitions = [ ]; type MapDefinitionToEventMap = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [K in keyof D]: D[K] extends [any, any] + [K in keyof D]: D[K] extends [unknown, unknown] ? T extends D[K][0] ? D[K][1] : never @@ -94,6 +93,9 @@ export interface MessageEventData { eventKey?: string; } +type DOMEvent> = + MapEventMapsToEvent[number]; + const useEventListener = < T extends EventTarget, K extends MapEventMapsToKeys[number] & string, @@ -101,47 +103,42 @@ const useEventListener = < >( target: RefObject | T | null | undefined, eventType: K, - listener: GenericEventListener[number]>, + listener: GenericEventListener>, options?: TUseEventListenerOptions, ): void => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handlerRef = useRef(listener); + const handlerRef = useRef(listener); handlerRef.current = listener; const { once, passive, signal }: AddEventListenerOptions = typeof options === 'object' ? options : {}; - let eventOptions: boolean | AddEventListenerOptions | undefined = - useMemo(() => { - const computedOptions: AddEventListenerOptions = {}; + const eventOptions = useMemo(() => { + if (typeof options === 'boolean') { + return options; + } - if (once !== undefined) { - computedOptions.once = once; - } + const computedOptions: AddEventListenerOptions = {}; - if (passive !== undefined) { - computedOptions.passive = passive; - } + if (once !== undefined) { + computedOptions.once = once; + } - if (signal !== undefined) { - computedOptions.signal = signal; - } + if (passive !== undefined) { + computedOptions.passive = passive; + } - return Object.keys(computedOptions).length > 0 - ? computedOptions - : undefined; - }, [once, passive, signal]); + if (signal !== undefined) { + computedOptions.signal = signal; + } - if (typeof options === 'boolean') { - eventOptions = options; - } + return Object.keys(computedOptions).length > 0 ? computedOptions : undefined; + }, [once, options, passive, signal]); useEffect(() => { - const eventListener = (...args) => { - return handlerRef.current(...args); + const eventListener: EventListener = (event) => { + handlerRef.current(event as DOMEvent); }; - const targetElement = - target && 'current' in target ? target.current : (target as T); + const targetElement = target && 'current' in target ? target.current : target; if (targetElement && eventType && eventListener) { targetElement.addEventListener(eventType, eventListener, eventOptions); diff --git a/packages/shared/src/lib/form.ts b/packages/shared/src/lib/form.ts index 93728701ef4..15927fbb439 100644 --- a/packages/shared/src/lib/form.ts +++ b/packages/shared/src/lib/form.ts @@ -1,4 +1,4 @@ -import type { UseFormSetError } from 'react-hook-form'; +import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'; import type { GraphQLError } from './errors'; import type { ApiResponseError, ApiZodErrorExtension } from '../graphql/common'; import { ApiError } from '../graphql/common'; @@ -71,13 +71,12 @@ export function formToJson>( return values as unknown as T; } -export const applyZodErrorsToForm = ({ +export const applyZodErrorsToForm = ({ error: originalError, setError, }: { error: GraphQLError; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setError: UseFormSetError; + setError: UseFormSetError; }) => { if ( originalError.response?.errors?.[0]?.extensions?.code === @@ -88,7 +87,7 @@ export const applyZodErrorsToForm = ({ apiError.extensions.issues.forEach((issue) => { if (issue.path?.length) { - setError(issue.path.join('.'), { + setError(issue.path.join('.') as Path, { type: issue.code, message: issue.message, }); diff --git a/packages/shared/src/lib/func.ts b/packages/shared/src/lib/func.ts index 7217697788f..4b7acb79767 100644 --- a/packages/shared/src/lib/func.ts +++ b/packages/shared/src/lib/func.ts @@ -229,8 +229,7 @@ export const broadcastMessage = ( channel.close(); }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const promisifyEventListener = ( +export const promisifyEventListener = ( type: string, listener: (event: CustomEvent) => T | Promise, options?: { once?: boolean }, From c69f2f379fe6feb615f0356614383b005350337f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:59:42 +0000 Subject: [PATCH 2/5] fix(shared): stabilize useEventListener useMemo deps to avoid spurious re-subscriptions Extract boolean options to a primitive before useMemo to prevent object reference changes from triggering unnecessary re-runs of the effect. Co-authored-by: Chris Bongers --- packages/shared/src/hooks/useEventListener.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/hooks/useEventListener.ts b/packages/shared/src/hooks/useEventListener.ts index 752d6409574..80d64ad8d06 100644 --- a/packages/shared/src/hooks/useEventListener.ts +++ b/packages/shared/src/hooks/useEventListener.ts @@ -93,8 +93,11 @@ export interface MessageEventData { eventKey?: string; } -type DOMEvent> = - MapEventMapsToEvent[number]; +type DOMEvent< + T, + K extends string, + M extends GetDOMEventMaps, +> = MapEventMapsToEvent[number]; const useEventListener = < T extends EventTarget, @@ -112,9 +115,11 @@ const useEventListener = < const { once, passive, signal }: AddEventListenerOptions = typeof options === 'object' ? options : {}; + const booleanOptions = typeof options === 'boolean' ? options : undefined; + const eventOptions = useMemo(() => { - if (typeof options === 'boolean') { - return options; + if (booleanOptions !== undefined) { + return booleanOptions; } const computedOptions: AddEventListenerOptions = {}; @@ -131,14 +136,17 @@ const useEventListener = < computedOptions.signal = signal; } - return Object.keys(computedOptions).length > 0 ? computedOptions : undefined; - }, [once, options, passive, signal]); + return Object.keys(computedOptions).length > 0 + ? computedOptions + : undefined; + }, [booleanOptions, once, passive, signal]); useEffect(() => { const eventListener: EventListener = (event) => { handlerRef.current(event as DOMEvent); }; - const targetElement = target && 'current' in target ? target.current : target; + const targetElement = + target && 'current' in target ? target.current : target; if (targetElement && eventType && eventListener) { targetElement.addEventListener(eventType, eventListener, eventOptions); From 14ec08513b90fab0af2fb127da36528331789c54 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:03:55 +0000 Subject: [PATCH 3/5] fix(shared): cast swipe event to MouseEvent in ShareModal onRequestClose Co-authored-by: Chris Bongers --- packages/shared/src/components/modals/ShareModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/modals/ShareModal.tsx b/packages/shared/src/components/modals/ShareModal.tsx index 29fe46816be..1c7afe4468b 100644 --- a/packages/shared/src/components/modals/ShareModal.tsx +++ b/packages/shared/src/components/modals/ShareModal.tsx @@ -63,7 +63,7 @@ export default function ShareModal({ const { scrollTop } = currentTarget; if (scrollTop === 0) { - onRequestClose(e); + onRequestClose(e.event as React.MouseEvent); } }; From 8d9dd5e4d546482b923169f0c629e82be41d81a3 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:15:32 +0000 Subject: [PATCH 4/5] fix(shared): explicit type params for promisifyEventListener in iosNativeAuth E = unknown default no longer infers T as any; must be explicit. Co-authored-by: Chris Bongers --- packages/shared/src/lib/auth.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/lib/auth.ts b/packages/shared/src/lib/auth.ts index 447ca7a1262..0b3013c79f6 100644 --- a/packages/shared/src/lib/auth.ts +++ b/packages/shared/src/lib/auth.ts @@ -217,10 +217,10 @@ export type NativeAuthResponse = { export const iosNativeAuth = async ( provider: string, ): Promise => { - const promise = promisifyEventListener( - 'native-auth', - (event) => event.detail, - ); + const promise = promisifyEventListener< + NativeAuthResponse | undefined, + NativeAuthResponse | undefined + >('native-auth', (event) => event.detail); postWebKitMessage(WebKitMessageHandlers.NativeAuth, provider); return promise; }; From 30066e5bc6e8e4dacf3eb8e260df5f880511ad4b Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 11 Mar 2026 15:31:35 +0200 Subject: [PATCH 5/5] fix(shared): narrow event listener targets --- packages/shared/src/hooks/useEventListener.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/hooks/useEventListener.ts b/packages/shared/src/hooks/useEventListener.ts index 80d64ad8d06..fc7ab612cbd 100644 --- a/packages/shared/src/hooks/useEventListener.ts +++ b/packages/shared/src/hooks/useEventListener.ts @@ -99,6 +99,11 @@ type DOMEvent< M extends GetDOMEventMaps, > = MapEventMapsToEvent[number]; +const isRefObject = ( + value: RefObject | T | null | undefined, +): value is RefObject => + typeof value === 'object' && value !== null && 'current' in value; + const useEventListener = < T extends EventTarget, K extends MapEventMapsToKeys[number] & string, @@ -145,8 +150,7 @@ const useEventListener = < const eventListener: EventListener = (event) => { handlerRef.current(event as DOMEvent); }; - const targetElement = - target && 'current' in target ? target.current : target; + const targetElement = isRefObject(target) ? target.current : target; if (targetElement && eventType && eventListener) { targetElement.addEventListener(eventType, eventListener, eventOptions);