Skip to content

Commit beeb215

Browse files
chore(lint): enforce react/refs (#25116)
Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent 6a3a460 commit beeb215

9 files changed

Lines changed: 61 additions & 30 deletions

File tree

‎apps/mobile/src/components/ui/Menu.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function Menu(props: MenuProps) {
5151
let children = menuProps.children;
5252
if (props.shouldOpenOnLongPress && isValidElement(children)) {
5353
const child = children as ReactElement<Pick<PressableProps, "onTouchStart" | "onPress">>;
54+
/* oxlint-disable react/refs -- Cloning the trigger installs event callbacks; the refs are accessed when those callbacks run. */
5455
children = cloneElement(child, {
5556
onTouchStart: (event) => {
5657
// onPressIn can repeat within one gesture when a finger leaves and re-enters the row.
@@ -74,6 +75,7 @@ export function Menu(props: MenuProps) {
7475
child.props.onPress?.(event);
7576
},
7677
});
78+
/* oxlint-enable react/refs */
7779
menuProps.onMenuInteractionStart = () => {
7880
menuPress.current.isPreparing = true;
7981
props.onMenuInteractionStart?.();

‎apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ function NativeReviewDiffContent(
387387
return null;
388388
}
389389

390+
/* oxlint-disable react/refs -- The native view receives this ref; only event callbacks access its current value. */
390391
return createElement(RawNativeView, {
391392
...nativeProps,
392393
refreshEnabled: props.onPullToRefresh !== undefined,
@@ -399,6 +400,7 @@ function NativeReviewDiffContent(
399400
},
400401
ref: nativeRef,
401402
});
403+
/* oxlint-enable react/refs */
402404
}
403405

404406
export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {

‎apps/mobile/src/features/home/thread-swipe-actions.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,11 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) {
506506
rightThreshold={actionsWidth * 0.42}
507507
simultaneousWith={props.simultaneousWith}
508508
>
509-
{props.children(close)}
509+
{
510+
/* oxlint-disable react/refs -- The render callback receives a close/open handler; only invoking that handler accesses the ref. */
511+
props.children(close)
512+
/* oxlint-enable react/refs */
513+
}
510514
</ReanimatedSwipeable>
511515
</View>
512516
</Animated.View>

‎apps/mobile/src/features/threads/ThreadArrangementSheet.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ function DragHandle(props: {
102102
canMoveDown: boolean;
103103
}) {
104104
const latest = useRef(props);
105+
/* oxlint-disable react/refs -- The pan gesture captures its callbacks once per `disabled` change; they read the latest handlers when native gesture events fire. */
105106
latest.current = props;
106107
const gesture = useMemo(
107108
() =>
@@ -117,6 +118,7 @@ function DragHandle(props: {
117118
.onFinalize((_, success) => latest.current.onEnd(!success)),
118119
[props.disabled, latest],
119120
);
121+
/* oxlint-enable react/refs */
120122
return (
121123
<GestureDetector gesture={gesture}>
122124
<View
@@ -271,8 +273,9 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
271273
const drag = useRef<Drag | null>(null);
272274
const frame = useRef<number | null>(null);
273275
const [preview, setPreview] = useState<Drag | null>(null);
274-
const translateY = useRef(new Animated.Value(0)).current;
276+
const [translateY] = useState(() => new Animated.Value(0));
275277
const latest = useRef({ rows, planners, moveThread });
278+
// oxlint-disable-next-line react/refs -- The autoscroll rAF loop started at drag start reads the current rows and planners on every frame.
276279
latest.current = { rows, planners, moveThread };
277280

278281
function stop() {

‎apps/mobile/src/features/voice-input/VoiceInputProvider.tsx‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
AudioModule,
44
RecordingPresets,
55
requestRecordingPermissionsAsync,
6-
type RecorderState,
76
type RecordingStatus,
87
} from "expo-audio";
98
import { File } from "expo-file-system";
@@ -35,7 +34,7 @@ import {
3534
voiceInputBlocksSubmission,
3635
type VoiceInputState,
3736
} from "@t3tools/client-runtime/voice-input";
38-
import { createLazyVoiceRecorder, type LazyVoiceRecorder } from "./lazyVoiceRecorder";
37+
import { createLazyVoiceRecorder } from "./lazyVoiceRecorder";
3938
import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
4039
import { VoiceInputSession } from "./voiceInputSession";
4140
import {
@@ -93,17 +92,18 @@ function useVoiceInputRuntime() {
9392
const keepAwakeId = useId();
9493
const keepAwakeSessionRef = useRef(0);
9594
const elapsedSecondsRef = useRef(0);
96-
const audioLevelsRef = useRef(Array<number>(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0));
97-
const audioLevels = useSharedValue(audioLevelsRef.current);
98-
const sessionRef = useRef<VoiceInputSession | null>(null);
99-
const recorderRef = useRef<LazyVoiceRecorder<RecorderState> | null>(null);
95+
const [initialLevels] = useState(() => Array<number>(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0));
96+
const audioLevelsRef = useRef(initialLevels);
97+
const audioLevels = useSharedValue(initialLevels);
10098
const audioToken = useRef<ReturnType<typeof acquireAudioSession> | null>(null);
10199

102-
if (!sessionRef.current || !recorderRef.current) {
100+
/* oxlint-disable react/refs -- The initializer captures the audio-session ref for later recording callbacks; it does not read it. */
101+
const [{ session, recorder }] = useState(() => {
102+
let session: VoiceInputSession | null = null;
103103
const recorder = createLazyVoiceRecorder({
104104
create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS),
105105
onStatus: (status: RecordingStatus) => {
106-
sessionRef.current?.controller.handleRecorderStatus({
106+
session?.controller.handleRecorderStatus({
107107
isFinished: status.isFinished,
108108
hasError: status.hasError || status.mediaServicesDidReset === true,
109109
error: status.error,
@@ -126,8 +126,7 @@ function useVoiceInputRuntime() {
126126
return preferredMicrophoneInput(inputs, order);
127127
},
128128
});
129-
recorderRef.current = recorder;
130-
sessionRef.current = new VoiceInputSession({
129+
session = new VoiceInputSession({
131130
recorder,
132131
getTranscriber: getLocalVoiceTranscriber,
133132
requestPermission: async () => {
@@ -152,15 +151,15 @@ function useVoiceInputRuntime() {
152151
onStateChange: (nextState) =>
153152
setState({
154153
state: nextState,
155-
ownerKey: sessionRef.current?.ownerKey ?? null,
156-
label: sessionRef.current?.label ?? null,
154+
ownerKey: session?.ownerKey ?? null,
155+
label: session?.label ?? null,
157156
}),
158157
});
159-
}
158+
return { session, recorder };
159+
});
160+
/* oxlint-enable react/refs */
160161

161-
const session = sessionRef.current;
162162
const controller = session.controller;
163-
const recorder = recorderRef.current;
164163

165164
useEffect(() => {
166165
const subscription = AppState.addEventListener("change", (nextState) => {

‎apps/mobile/src/features/voice-input/useVoiceInputController.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function useVoiceInputController(input: {
2424
const global = useGlobalVoiceInput();
2525
const { setOwnerFocused, session } = global;
2626
const latestInput = useRef(input);
27+
// oxlint-disable-next-line react/refs -- Transcription finishes asynchronously and must reach the composer's current selection setter, not the one captured at start.
2728
latestInput.current = input;
2829
const mounted = useRef(true);
2930
useEffect(() => {

‎apps/mobile/src/native/T3ComposerEditor.ios.tsx‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,19 @@ export function ComposerEditor({
139139
const [, forceNativeEventRender] = useState(0);
140140
// Start history empty so restored drafts or recycled views receive a non-echo controlled payload.
141141
const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
142-
const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
142+
// Tokens advance from the previous committed value; render-phase state keeps
143+
// an abandoned render from leaking a trailing token into the next one.
144+
const [confirmedTokens, setConfirmedTokens] = useState(() => ({
145+
value: props.value,
146+
tokens: collectComposerInlineTokens(props.value),
147+
}));
148+
let tokens = confirmedTokens.tokens;
149+
if (confirmedTokens.value !== props.value) {
150+
tokens = collectComposerInlineTokens(props.value, {
151+
preserveTrailingFrom: confirmedTokens.tokens,
152+
});
153+
setConfirmedTokens({ value: props.value, tokens });
154+
}
143155
const bodyText = useScaledTextRole("body");
144156
const theme = useUniwindTheme();
145157
const fontFamily = FONT_FAMILIES.regular;
@@ -158,10 +170,6 @@ export function ComposerEditor({
158170
const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]);
159171
const chipTextColor = createNativeComposerTheme(theme).text;
160172
const tokensJson = useMemo(() => {
161-
const tokens = collectComposerInlineTokens(props.value, {
162-
preserveTrailingFrom: confirmedTokensRef.current,
163-
});
164-
confirmedTokensRef.current = tokens;
165173
return JSON.stringify(
166174
composerContextEditorTokens(props.value, tokens, skillLabels).map((token) => {
167175
const record =
@@ -194,11 +202,12 @@ export function ComposerEditor({
194202
};
195203
}),
196204
);
197-
}, [props.value, props.context, skillLabels, theme, chipTextColor, confirmedTokensRef]);
205+
}, [props.value, tokens, props.context, skillLabels, theme, chipTextColor]);
198206
// Every render resolves against the snapshot history, so a render whose
199207
// (value, selection) lags the acknowledged native state is stamped behind
200208
// the native revision and rejected by the editor instead of re-applying a
201209
// stale caret or stale text mid-typing.
210+
/* oxlint-disable react/refs -- Native controlled edits must reconcile against the last acknowledged native event before sending the next payload. */
202211
const controlledEventCount = resolveComposerControlledEventCount(
203212
props.value,
204213
selection ?? null,
@@ -211,6 +220,7 @@ export function ComposerEditor({
211220
mostRecentEventCount,
212221
nativeEventSnapshotsRef.current,
213222
);
223+
/* oxlint-enable react/refs */
214224
const isNativeEcho =
215225
controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
216226
const controlledDocumentJson = JSON.stringify({

‎apps/mobile/src/native/T3ComposerEditor.native.tsx‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,19 @@ export function ComposerEditor({
137137
const [, forceNativeEventRender] = useState(0);
138138
// Start history empty so restored drafts or recycled views receive a non-echo controlled payload.
139139
const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
140-
const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));
141-
const confirmedTokensRef = useRef(initialConfirmedTokens);
140+
// Tokens advance from the previous committed value; render-phase state keeps
141+
// an abandoned render from leaking a trailing token into the next one.
142+
const [confirmedTokens, setConfirmedTokens] = useState(() => ({
143+
value: props.value,
144+
tokens: collectComposerInlineTokens(props.value),
145+
}));
146+
let tokens = confirmedTokens.tokens;
147+
if (confirmedTokens.value !== props.value) {
148+
tokens = collectComposerInlineTokens(props.value, {
149+
preserveTrailingFrom: confirmedTokens.tokens,
150+
});
151+
setConfirmedTokens({ value: props.value, tokens });
152+
}
142153
const theme = useUniwindTheme();
143154
const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));
144155

@@ -156,10 +167,6 @@ export function ComposerEditor({
156167
const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]);
157168
const chipTextColor = createNativeComposerTheme(theme).text;
158169
const tokensJson = useMemo(() => {
159-
const tokens = collectComposerInlineTokens(props.value, {
160-
preserveTrailingFrom: confirmedTokensRef.current,
161-
});
162-
confirmedTokensRef.current = tokens;
163170
return JSON.stringify(
164171
composerContextEditorTokens(props.value, tokens, skillLabels).map((token) => {
165172
const record =
@@ -192,11 +199,12 @@ export function ComposerEditor({
192199
};
193200
}),
194201
);
195-
}, [props.value, props.context, skillLabels, theme, chipTextColor, confirmedTokensRef]);
202+
}, [props.value, tokens, props.context, skillLabels, theme, chipTextColor]);
196203
// Every render resolves against the snapshot history, so a render whose
197204
// (value, selection) lags the acknowledged native state is stamped behind
198205
// the native revision and rejected by the editor instead of re-applying a
199206
// stale caret or stale text mid-typing.
207+
/* oxlint-disable react/refs -- Native controlled edits must reconcile against the last acknowledged native event before sending the next payload. */
200208
const controlledEventCount = resolveComposerControlledEventCount(
201209
props.value,
202210
selection ?? null,
@@ -209,6 +217,7 @@ export function ComposerEditor({
209217
mostRecentEventCount,
210218
nativeEventSnapshotsRef.current,
211219
);
220+
/* oxlint-enable react/refs */
212221
const isNativeEcho =
213222
controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
214223
const controlledDocumentJson = JSON.stringify({

‎vite.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export default defineConfig({
228228
"react/jsx-no-constructed-context-values": "off",
229229
"react/memo-dependencies": "error",
230230
"react/purity": "error",
231+
"react/refs": "error",
231232
"react/immutability": "error",
232233
"react/no-array-index-key": "error",
233234
"react/no-unstable-nested-components": "error",

0 commit comments

Comments
 (0)