diff --git a/CLAUDE.md b/CLAUDE.md index 047fb641d9..8f431c1dd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,6 +153,17 @@ Messages are processed in order: - Threads: `state.threads[parentId]` (keyed by parent message ID) - **Invariant:** Messages in threads MUST also exist in main channel state +### React Version Compatibility + +SDK supports **React 17, 18, 19**. + +**Forbidden in `src/`** (enforced by the `react-compat` block in `eslint.config.mjs`): + +- `useId` from `react` → use `useStableId` from `src/components/UtilityComponents/useStableId` +- `useSyncExternalStore` from `react` → use the shim from `use-sync-external-store/shim` +- `useEffectEvent`, `use()` → not allowed (React 19-only) +- `ref` declared in a prop type or destructured from props → use `forwardRef` (React 17/18 only deliver `ref` to forwardRef'd components) + ### Context Dependency Gotcha ```ts diff --git a/eslint.config.mjs b/eslint.config.mjs index f9c2a364f2..5cc6500037 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -122,6 +122,55 @@ export default tseslint.config( 'react-hooks/exhaustive-deps': 'error', }, }, + { + // Forbid patterns that silently break on React 17 or 18. Approved alternatives: + // - useId → src/utils/useStableId.ts + // - useSyncExternalStore → use-sync-external-store/shim + // - useEffectEvent, use() → not allowed; SDK supports React 17+. + // - ref as a regular prop → wrap the component with React.forwardRef + name: 'react-compat', + files: ['src/**/*.{ts,tsx}'], + ignores: [ + 'src/components/UtilityComponents/useStableId.ts', + 'src/**/__tests__/**', + 'src/mock-builders/**', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react', + importNames: ['useId', 'useSyncExternalStore', 'useEffectEvent', 'use'], + message: + 'React 18+/19-only API. Use useStableId from src/utils/useStableId, useSyncExternalStore from use-sync-external-store/shim. useEffectEvent and use() are not allowed: SDK supports React 17+.', + }, + ], + }, + ], + 'no-restricted-syntax': [ + 'error', + { + selector: + "MemberExpression[object.name='React'][property.name=/^(useId|useSyncExternalStore|useEffectEvent|use)$/]", + message: + 'React 18+/19-only API. Use useStableId / use-sync-external-store/shim instead of React..', + }, + { + selector: "TSPropertySignature[key.name='ref']", + message: + '`ref` declared as a regular prop. React 17 and 18 only deliver `ref` to components wrapped in `React.forwardRef` — declare the component with `forwardRef` instead of putting `ref` in the prop type.', + }, + { + selector: + ":matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression) > ObjectPattern > Property[key.name='ref']", + message: + '`ref` destructured from props. React 17 and 18 only deliver `ref` to components wrapped in `React.forwardRef` — use `React.forwardRef((props, ref) => …)` and take `ref` as the second parameter instead.', + }, + ], + }, + }, { name: 'vitest', files: ['src/**/__tests__/**', 'src/mock-builders/**'], diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx index cd39577843..538edbe448 100644 --- a/src/components/Attachment/LinkPreview/CardAudio.tsx +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -68,7 +68,7 @@ const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => { const { durationSeconds, isPlaying, progress, secondsElapsed } = useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; - if (!audioPlayer) return; + if (!audioPlayer) return null; return (
diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 975d257ed4..ced50b375f 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -4,10 +4,10 @@ import React, { createContext, useContext, useEffect, - useId, useMemo, useState, } from 'react'; +import { useStableId } from '../UtilityComponents/useStableId'; import { Button, type ButtonProps } from '../Button'; import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; @@ -71,7 +71,7 @@ const useChatViewA11yContext = () => useContext(ChatViewA11yContext); export const ChatView = ({ children }: PropsWithChildren) => { const [activeChatView, setActiveChatView] = useState('channels'); - const chatViewId = useId().replace(/:/g, ''); + const chatViewId = useStableId(); const { theme } = useChatContext(); diff --git a/src/components/Form/SwitchField.tsx b/src/components/Form/SwitchField.tsx index 73e6db1077..522091e774 100644 --- a/src/components/Form/SwitchField.tsx +++ b/src/components/Form/SwitchField.tsx @@ -7,7 +7,8 @@ import type { PropsWithChildren, ReactNode, } from 'react'; -import React, { isValidElement, useId, useRef, useState } from 'react'; +import React, { isValidElement, useRef, useState } from 'react'; +import { useStableId } from '../UtilityComponents/useStableId'; export type SwitchFieldProps = Omit< PropsWithChildren>, @@ -41,7 +42,7 @@ export const SwitchField = ({ onKeyDown, ...rest } = props; - const generatedSwitchId = useId(); + const generatedSwitchId = useStableId(); const switchId = id ?? `str-chat__switch-field-${generatedSwitchId}`; const switchLabelId = `${switchId}-label`; const inputRef = useRef(null); diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx index 9fd727e5d6..a4fcc6e627 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx @@ -47,7 +47,7 @@ export const AudioRecordingPlayback = ({ }; }, [audioPlayer]); - if (!audioPlayer) return; + if (!audioPlayer) return null; return (
{ const { t, userLanguage } = useTranslationContext('MessageText'); const message = propMessage || contextMessage; const hasAttachment = messageHasAttachments(message); - const messageContextId = useId(); - const messageTextId = useId(); + const messageContextId = useStableId(); + const messageTextId = useStableId(); const messageTextToRender = translationView === 'original' diff --git a/src/components/MessageActions/QuickMessageActionButton.tsx b/src/components/MessageActions/QuickMessageActionButton.tsx index ae2565e55b..4f25b3503a 100644 --- a/src/components/MessageActions/QuickMessageActionButton.tsx +++ b/src/components/MessageActions/QuickMessageActionButton.tsx @@ -1,14 +1,19 @@ import { Button, type ButtonProps } from '../Button'; import clsx from 'clsx'; -import React from 'react'; +import React, { forwardRef } from 'react'; -export const QuickMessageActionsButton = ({ className, ...props }: ButtonProps) => ( -