Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9df20c7
chore(yarn): migrate to Yarn 4 + workspaces monorepo
oliverlaz May 12, 2026
28e5f43
chore(yarn): enable hardened mode and global hardlinks
oliverlaz May 12, 2026
24ddbda
ci: adopt Yarn 4 immutable install and consolidate example deps
oliverlaz May 12, 2026
9a7a5d1
docs: document Yarn 4 + workspaces setup
oliverlaz May 12, 2026
aff9d32
chore(deps): pin versions that drifted during Yarn 4 lockfile regen
oliverlaz May 12, 2026
cc13e52
chore(example/vite): drop stream-chat resolve.alias and tsconfig paths
oliverlaz May 12, 2026
c59f2d8
chore(yarn): align with stream-video-js#2236 hardening settings
oliverlaz May 12, 2026
57290be
test(MessageText): refresh useId snapshots for React 19.2
oliverlaz May 12, 2026
32df11a
fix(ChannelListItem): guard channel.getClient() against disconnected …
oliverlaz May 12, 2026
291de06
chore(deps): unpin tooling/runtime deps and sync manifest to resolved
oliverlaz May 12, 2026
9a3ffae
chore(deps): sync remaining manifest ranges to resolved versions
oliverlaz May 12, 2026
b9b4341
chore(deps): downgrade i18next-cli and typescript-eslint past the age…
oliverlaz May 12, 2026
f512b9e
chore: add build:all script and sync example workspace deps
oliverlaz May 12, 2026
bf9adbe
chore(deps): upgrade typescript to ^6.0.3
oliverlaz May 12, 2026
80db7b6
chore(examples): use the SDK's eslint config; drop per-example tooling
oliverlaz May 12, 2026
757fb0f
chore(claude): port the env-file worktree-sync script from video-js#2236
oliverlaz May 12, 2026
9cf92cc
chore: husky 9, vite 8.0.12 alignment, tutorial type-check, dead-scri…
oliverlaz May 12, 2026
45076f1
chore(deps): bump toolchain deps and drop unused tslib
oliverlaz May 12, 2026
5ee82ca
chore(tsconfig): align module with target es2020
oliverlaz May 12, 2026
40a64b6
ci(setup-node): use setup-node's built-in yarn cache
oliverlaz May 13, 2026
22e30d7
fix(ChannelListItem): source currentUserId from ChatContext, drop dis…
oliverlaz May 13, 2026
6900122
Merge remote-tracking branch 'origin/master' into chore/yarn4-workspa…
oliverlaz May 13, 2026
99222de
test(Gallery): expect canonicalized URL form after sanitize-url v7
oliverlaz May 13, 2026
ee95cfa
chore(scripts): rename example:* dev scripts to start:*
oliverlaz May 13, 2026
965eb1c
fix(vite): externalize deep subpath imports to avoid CJS require in E…
oliverlaz May 13, 2026
10a2807
Merge remote-tracking branch 'origin/master' into chore/yarn4-workspa…
oliverlaz May 25, 2026
8ca8da0
chore(deps): bump yarn to 4.15.0 and refresh dev dependencies
oliverlaz May 25, 2026
48832fd
fix(compat): make React 17/18 usages crash-safe
oliverlaz May 25, 2026
0213c86
refactor(utils): extend existing useStableId with React.useId
oliverlaz May 25, 2026
cc5e934
docs(CLAUDE): fix useStableId path after consolidation
oliverlaz May 25, 2026
207a093
fix(useStableId): strip React 19.1 guillemet wrappers from useId output
oliverlaz May 26, 2026
9e5eb76
Merge branch 'master' into chore/react-17-18-19-compat
oliverlaz May 27, 2026
d6c2c67
chore(tests): update snapshot
oliverlaz May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<api>.',
},
{
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/**'],
Expand Down
2 changes: 1 addition & 1 deletion src/components/Attachment/LinkPreview/CardAudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className='str-chat__message-attachment-card-audio-widget--first-row'>
Expand Down
4 changes: 2 additions & 2 deletions src/components/ChatView/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,7 +71,7 @@ const useChatViewA11yContext = () => useContext(ChatViewA11yContext);

export const ChatView = ({ children }: PropsWithChildren) => {
const [activeChatView, setActiveChatView] = useState<ChatView>('channels');
const chatViewId = useId().replace(/:/g, '');
const chatViewId = useStableId();

const { theme } = useChatContext();

Expand Down
5 changes: 3 additions & 2 deletions src/components/Form/SwitchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentProps<'input'>>,
Expand Down Expand Up @@ -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<HTMLInputElement | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const AudioRecordingPlayback = ({
};
}, [audioPlayer]);

if (!audioPlayer) return;
if (!audioPlayer) return null;

return (
<div
Expand Down
7 changes: 4 additions & 3 deletions src/components/Message/MessageText.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useId, useMemo } from 'react';
import React, { useMemo } from 'react';
import { messageHasAttachments, messageTextHasEmojisOnly } from './utils';
import { useStableId } from '../UtilityComponents/useStableId';

import type { MessageContextValue } from '../../context';
import { useMessageContext, useTranslationContext } from '../../context';
Expand Down Expand Up @@ -41,8 +42,8 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {
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'
Expand Down
25 changes: 15 additions & 10 deletions src/components/MessageActions/QuickMessageActionButton.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Button
appearance='ghost'
circular
className={clsx('str-chat__message-actions-box-button', className)}
size='sm'
variant='secondary'
{...props}
/>
export const QuickMessageActionsButton = forwardRef<HTMLButtonElement, ButtonProps>(
function QuickMessageActionsButton({ className, ...props }, ref) {
return (
<Button
appearance='ghost'
circular
className={clsx('str-chat__message-actions-box-button', className)}
ref={ref}
size='sm'
variant='secondary'
{...props}
/>
);
},
);
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useSyncExternalStore } from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim';

const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';

Expand Down
2 changes: 1 addition & 1 deletion src/components/Poll/PollHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const PollHeader = () => {
return '';
}, [is_closed, enforce_unique_vote, max_votes_allowed, options.length, t]);

if (!name) return;
if (!name) return null;

return (
<div className='str-chat__poll-header'>
Expand Down
3 changes: 2 additions & 1 deletion src/components/Search/SearchBar/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';

import { useStableId } from '../../UtilityComponents/useStableId';
import { useSearchContext } from '../SearchContext';
import { useSearchQueriesInProgress } from '../hooks';
import { useTranslationContext } from '../../../context';
Expand All @@ -25,7 +26,7 @@ export const SearchBar = () => {
} = useSearchContext();
const queriesInProgress = useSearchQueriesInProgress(searchController);
const clearButtonRef = React.useRef<HTMLButtonElement | null>(null);
const searchInputId = React.useId();
const searchInputId = useStableId();

const [input, setInput] = useState<HTMLInputElement | null>(null);
const { isActive, searchQuery } = useStateStore(
Expand Down
2 changes: 1 addition & 1 deletion src/components/Search/SearchResults/SearchResultItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const MessageSearchResultItem = ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const getLatestMessagePreview = useCallback(() => item.text!, [item]);

if (!channel) return;
if (!channel) return null;

return (
<ChannelListItem
Expand Down
39 changes: 39 additions & 0 deletions src/components/UtilityComponents/__tests__/useStableId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { renderHook } from '@testing-library/react';

import { stripUseIdWrappers, useStableId } from '../useStableId';

describe('useStableId', () => {
it('returns a non-empty string', () => {
const { result } = renderHook(() => useStableId());
expect(typeof result.current).toBe('string');
expect(result.current.length).toBeGreaterThan(0);
});

it('is stable across re-renders', () => {
const { rerender, result } = renderHook(() => useStableId());
const initial = result.current;
rerender();
rerender();
expect(result.current).toBe(initial);
});

it('returns distinct values from two hook calls in the same component', () => {
const { result } = renderHook(() => [useStableId(), useStableId()] as const);
expect(result.current[0]).not.toBe(result.current[1]);
});

it('never contains a colon (so it is safe as an HTML id)', () => {
const { result } = renderHook(() => useStableId());
expect(result.current).not.toContain(':');
});
});

describe('stripUseIdWrappers', () => {
it('strips colons from React 19.0 format (:r0:)', () => {
expect(stripUseIdWrappers(':r0:')).toBe('r0');
});

it('strips guillemets from React 19.1 format («r0»)', () => {
expect(stripUseIdWrappers('«r0»')).toBe('r0');
});
});
25 changes: 17 additions & 8 deletions src/components/UtilityComponents/useStableId.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { nanoid } from 'nanoid';
import { useMemo } from 'react';
import React, { useMemo } from 'react';

const reactUseId = React.useId as (() => string) | undefined;

// Strips React.useId() wrapper characters so the value is safe as an HTML id:
// from React 19.0 (`:r0:` -> `r0`) and 19.1 (`«r0»` -> `r0`);
// 19.2+ uses `_r_1_` which is safe and doesn't need stripping
export const stripUseIdWrappers = (id: string): string => id.replace(/[:«»]/g, '');

/**
* The ID is generated using the `nanoid` library and is memoized to ensure
* that it remains the same across renders unless the key changes.
* Returns a stable, unique string id.
*
* On React 18+ this delegates to `React.useId()` (with the surrounding wrapper
* characters stripped so the value is safe to use anywhere an HTML id is expected)
* and is SSR-stable. On React 17, it falls back to a client-only id generated via `nanoid`.
*/
export const useStableId = (key?: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const id = useMemo(() => nanoid(), [key]);

return id;
export const useStableId = () => {
if (reactUseId) return stripUseIdWrappers(reactUseId());
// eslint-disable-next-line react-hooks/rules-of-hooks
return useMemo(() => nanoid(), []);
};