Skip to content

fix(compat): restore React 17/18 compatibility in certain components#3197

Merged
oliverlaz merged 33 commits into
masterfrom
chore/react-17-18-19-compat
May 27, 2026
Merged

fix(compat): restore React 17/18 compatibility in certain components#3197
oliverlaz merged 33 commits into
masterfrom
chore/react-17-18-19-compat

Conversation

@oliverlaz
Copy link
Copy Markdown
Member

@oliverlaz oliverlaz commented May 25, 2026

🎯 Goal

The SDK declares react: ^17 || ^18 || ^19 as a peer dependency, but two patterns in master silently break on React 17 and 18:

  • useId is imported from react in four components — it doesn't exist on React 17, so those components crash on mount.
  • useReducedMotionPreference imports useSyncExternalStore directly from react instead of from the use-sync-external-store/shim that the rest of the codebase already uses for the React 17 fallback.

There is also one ref-forwarding bug: QuickMessageActionsButton is consumed as <QuickMessageActionsButton ref={…} /> in MessageActions.defaults, but the component itself isn't forwardRef-wrapped, so the ref is silently dropped on React 17 and 18.

Beyond fixing those, the goal is to prevent future regressions of the same class via ESLint.

🛠 Implementation details

Runtime fixes

  • New src/utils/useStableId.ts — wraps React.useId() with a nanoid-backed fallback for React 17. SSR mismatch is unavoidable on React 17 (every React-17-compatible lib has the same constraint); consumers needing stable SSR ids on 17 must pass an explicit id prop. Four call sites migrated: ChatView, SwitchField, MessageText, SearchBar.
  • useReducedMotionPreference.ts — switched to useSyncExternalStore from use-sync-external-store/shim so it matches useStateStore and useSelectedChannelState.
  • QuickMessageActionsButton — wrapped in forwardRef so <QuickMessageActionsButton ref={…} /> works on every supported React.

ESLint guardrails (react-compat block in eslint.config.mjs)

Scoped to src/**/*.{ts,tsx}, with src/utils/useStableId.ts, src/**/__tests__/**, and src/mock-builders/** exempted. Forbids:

  • useId, useSyncExternalStore, useEffectEvent, use imported from react (and the React.<api> member-access form via no-restricted-syntax).
  • ref declared in a TS prop type (TSPropertySignature[key.name='ref']).
  • ref destructured from a function-component's props.

🎨 UI Changes

None — lint rules, internal refactors, and a small shim hook. No user-visible change.

oliverlaz added 28 commits May 12, 2026 17:04
Convert the project from Yarn Classic (1.22.21) to Yarn 4.14.1 with the
examples folder collapsed into the monorepo as workspaces.

Changes:
- Commit the Yarn 4.14.1 binary to .yarn/releases/ and point .yarnrc.yml
  at it via yarnPath (no Corepack — relies on any globally installed
  yarn classic as a launcher).
- Add base .yarnrc.yml with nodeLinker: node-modules and telemetry off.
  Hardening (hardlinks-global, hardened mode, scripts disabled) is added
  in a follow-up commit.
- Add workspaces field "examples/*" and example convenience scripts
  (example:tutorial, example:vite, examples:build) to root package.json.
- Bump packageManager to yarn@4.14.1.
- Rename examples to scoped names @stream-io/stream-chat-react-tutorial
  and @stream-io/stream-chat-react-vite; switch their stream-chat-react
  reference from link:../../ to workspace:^.
- Drop the link:../../node_modules/<pkg> hacks from example deps and
  declare normal version-pinned entries instead — hoisting now dedupes.
- Add @babel/core devDependency to the vite example to satisfy
  vite-plugin-babel's peer requirement (was previously resolved by
  Yarn 1's looser peer handling).
- Add dependenciesMeta whitelist for build scripts that the SDK relies
  on at runtime: @parcel/watcher, @swc/core, esbuild. Yarn 4 disables
  dependency build scripts by default.
- Regenerate yarn.lock in Berry format. Delete per-example yarn.lock
  files since installs are now consolidated at the root.
- Update .gitignore to keep .yarn/releases tracked while ignoring the
  Berry cache and PnP artifacts.
Layer the supply-chain and worktree-performance improvements on top of
the base Yarn 4 migration:
- nmMode: hardlinks-global — share package content across worktrees via
  hardlinks for near-instant secondary installs.
- enableHardenedMode: true — strict integrity validation during install.

Rename the husky setup script from prepare to postinstall: in Yarn Berry,
prepare only runs on pack/publish, not on yarn install, so hooks would
not get wired up after a fresh clone otherwise.

Note: enableScripts: false was evaluated but rejected — Yarn 4 already
disables dependency build scripts by default (whitelisted explicitly via
dependenciesMeta), and setting the flag globally also blocks the root
postinstall, which would break husky setup with no clean workaround.
- setup-node composite action: switch from --frozen-lockfile to
  --immutable and cache .yarn/cache + .yarn/install-state.gz instead of
  node_modules. Yarn 4 is bootstrapped via the GH-Actions-preinstalled
  yarn 1 launcher delegating to .yarn/releases/yarn-4.14.1.cjs through
  yarnPath; no Corepack step needed.
- ci.yml deploy-vite-example: drop the per-example "yarn install" step.
  The example's dependencies are now installed by the root yarn install
  (which the setup-node action already runs) via workspaces.
- Add examples/vite/vercel.json with explicit install/build commands
  that pivot up to the workspace root, so Vercel resolves dependencies
  through the monorepo's single yarn.lock rather than expecting a
  standalone install in examples/vite.

Note: the Vercel project may need its "Root Directory" or framework
preset re-verified on the first preview deploy — flagged in the PR for
manual confirmation.
- CLAUDE.md: note that Yarn 4 is committed via yarnPath (no Corepack);
  add example workspace commands (example:tutorial, example:vite,
  examples:build) and switch the test description from Jest to Vitest.
- AGENTS.md: update Tech & Toolchain to call out Yarn 4 + workspaces,
  fix the testing entry (Jest → Vitest), and extend the runbook with
  example commands.
- examples/tutorial/README.md: replace the per-example install
  instructions with the root-workspace flow and link the new
  example:tutorial root script.
Regenerating the lockfile in Berry format resolved several deps to newer
versions within their semver ranges, which broke either source/type
checks or snapshot tests. Pinning to the pre-migration versions keeps
this PR scoped to "tooling migration only"; the real upgrades can land
in follow-up dependency PRs.

Pinned to exact versions:
- react / react-dom: 19.0.0 (^19.0.0 picked up 19.2.6 — useId format
  changed from :r1a: to _r_1a_, breaking aria-labelledby snapshots).
- typescript: 5.4.5 (5.9.x tightened Int8Array/ArrayBuffer typing in
  src/plugins/encoders/mp3.ts).
- dayjs: 1.10.4 (1.11.x changed the .duration() type signature, causing
  TS errors in src/i18n/utils.ts).
- prettier: 3.5.3 (3.8.x reformatted 3 unrelated files).
- jsdom: 24.1.1 (pinned alongside cssstyle for consistency).
- react-virtuoso: 2.16.5 (kept on the same version; its
  @virtuoso.dev/{react-urx,urx} transitive bumped to 0.2.13).

Pinned via resolutions:
- @virtuoso.dev/react-urx, @virtuoso.dev/urx: 0.2.12.
- cssstyle: 4.0.1 — newer cssstyle (4.6.x, transitive of jsdom) retains
  the -webkit-overflow-scrolling: touch inline style react-virtuoso
  emits, breaking VirtualizedMessageList snapshots.

Also: ignore `.yarn` and `examples` in eslint.config.mjs so `yarn lint`
doesn't try to parse the committed yarn binary or the example workspaces
(each example has its own eslint config).
The explicit alias and tsconfig "paths" overrides for stream-chat used
to be needed because each example had its own standalone install with
its own node_modules. Now that examples are workspaces of the root
monorepo, dependency hoisting puts stream-chat in the root node_modules
and standard Node.js/TS resolution finds it without any override.

Verified by rebuilding @stream-io/stream-chat-react-vite — output bundle
hashes match the pre-removal build.
Bring our .yarnrc.yml in line with the reference hardening from
stream-video-js#2236, plus our preferred additions:

- enableScripts: false — disables all dependency install scripts by
  default. Adding the explicit flag tightens what was already Yarn 4's
  practical default (deps need dependenciesMeta opt-in to run build
  scripts); the root workspace's own postinstall is still allowed to
  run, so husky setup still works.
- npmMinimalAgeGate: 3d — refuses to install packages younger than 3
  days. Real-world supply-chain protection against freshly published
  malicious typosquats or compromised releases.
- npmPublishProvenance: true — produces an npm sigstore attestation
  during publish so consumers can verify the artifact came from our
  GitHub Actions release workflow. The release workflow already has
  the required id-token: write permission.
- initScope: stream-io — minor: defaults the @stream-io scope when
  running yarn init from this repo.

Also: whitelist husky in dependenciesMeta. While the root postinstall
calling `husky install` works regardless (Yarn 4 lets the workspace's
own scripts through), declaring husky here makes the intent explicit
and is consistent with the supply-chain whitelist for esbuild/swc/
@parcel/watcher.

Bump react/react-dom devDependency ranges back to ^19.2.6 (was pinned
to 19.0.0 in the prior commit) to consume the latest minor.
React 19.2.x changed the useId default format from colon-wrapped
(":r1a:") to underscore-wrapped ("_r_1a_"), which appears in
aria-labelledby and id attributes throughout these snapshots.

No source change — purely a snapshot refresh against the bumped
react/react-dom devDependency.
…client

React 19.2 schedules passive-mount effects slightly later than 19.0, so
a re-render after a "channel.deleted" event (which triggers
client.disconnect) can fire a useEffect that calls channel.getClient()
on a now-disconnected client. The SDK throws "You can't use a channel
after client.disconnect() was called" and the render crashes.

Extract a getCurrentUserId(channel) helper in ChannelListItem/utils that
wraps the call in try/catch and returns undefined on failure, then use
it from getLatestMessagePreview, getChannelDisplayImage, and the
useChannelDisplayName hook. Degrading to "no current user" is the right
fallback for these display helpers — the channel-row is about to
unmount anyway, so showing it without ownership-specific framing for one
extra paint is harmless.

Fixes the previously failing test
"ChannelList > Event handling > channel.deleted >
 should unset activeChannel if it was deleted"
under react/react-dom ^19.2.6.
Revert the dependency pinning from aff9d32 and instead align the
manifest ranges to the versions Yarn 4 actually resolves, so the
package.json isn't misleading anymore (e.g. "typescript": "^5.4.5" was
in the manifest but the lockfile resolved 5.9.3).

Changes to package.json:
- Drop the exact-version pins (dayjs, react-virtuoso, jsdom, prettier,
  typescript). All move back to caret ranges.
- Bump caret ranges to track resolved minors for 39 deps across
  dependencies and devDependencies (eg. dayjs ^1.10.4 -> ^1.11.20,
  typescript ^5.4.5 -> ^5.9.3, react-virtuoso ^2.16.5 -> ^2.19.1,
  prettier ^3.5.3 -> ^3.8.3, jsdom ^24.1.1 -> ^24.1.3, etc.).
- Drop the resolutions for @virtuoso.dev/* and cssstyle that were
  workarounds for snapshot drift.
- Two exceptions left at their previous ranges because
  npmMinimalAgeGate: 3d quarantines their latest releases: i18next-cli
  stays at ^1.31.0, typescript-eslint stays at ^8.17.0. Once those
  versions age past 3 days they can be bumped naturally.

Source adjustments required by the resolved newer dep behavior:
- src/plugins/encoders/mp3.ts: TS 5.9 narrowed Int8Array<T> typing so
  Int8Array<ArrayBufferLike>[] no longer assigns to BlobPart[]; cast at
  the Blob constructor site (runtime is unchanged).
- src/i18n/utils.ts: dayjs 1.11 narrowed .duration()'s parameter type to
  string; cast the numeric value through unknown (runtime still accepts
  numbers).
- VirtualizedMessageList snapshot: cssstyle 4.6 (transitive via jsdom)
  now preserves vendor-prefixed inline styles, so the snapshot picks up
  react-virtuoso's -webkit-overflow-scrolling: touch.
- Three files reformatted by prettier 3.8 (no semantic change):
  examples/vite/.../triggerNotificationUtils.ts,
  src/@types/vitest-axe.d.ts,
  src/components/MessageComposer/styling/CommandsMenu.scss.
The earlier range-sync (291de06) missed any entry where the lockfile
keyed a package across multiple comma-separated ranges (yarn merges
overlapping requesters into a single block). Re-run with a parser that
splits on each comma-separated descriptor and strips the package prefix
before matching.

Eight additional entries bumped to their resolved minors:
- @semantic-release/github  ^12.0.6 -> ^12.0.8
- @types/react              ^19.0.7 -> ^19.2.14
- i18next-cli               ^1.31.0 -> ^1.56.12
- nanoid                    ^3.3.4  -> ^3.3.12
- sass                      ^1.97.2 -> ^1.99.0
- tslib                     ^2.6.2  -> ^2.8.1
- typescript-eslint         ^8.17.0 -> ^8.59.3
- vite                      ^7.3.1  -> ^7.3.3

i18next-cli@1.56.12 and typescript-eslint@8.59.3 are younger than the
npmMinimalAgeGate: 3d threshold, so they're explicitly allowed via the
new npmPreapprovedPackages allowlist in .yarnrc.yml. The age gate stays
in effect for everything else.
… gate

Drop the npmPreapprovedPackages allowlist from .yarnrc.yml — supply-chain
hygiene is better served by aging deps past the gate naturally than by
maintaining an exception list. Downgrade the two packages that needed
the exception:

- i18next-cli      ^1.56.12 -> ^1.56.11  (released 2026-05-07, 5d old)
- typescript-eslint ^8.59.3 -> ^8.59.2   (released 2026-05-04, 8d old)

Also align the tutorial example workspace to typescript-eslint ^8.59.2
(was ^8.57.2 which yarn was resolving to the quarantined 8.59.3). The
lockfile is now consistent with the gate: a fresh install from scratch
will resolve to gate-compliant versions without needing any exception.
- Add a top-level `build:all` script that builds every workspace
  topologically (yarn workspaces foreach -A -tpv run build). The SDK
  builds first because the examples consume it via workspace:^; the
  two examples then build in parallel.
- Tighten example workspace dep ranges to track their resolved versions
  (react/react-dom 19.0 -> 19.2.6, @types/react 19.0.7 -> 19.2.14,
  @typescript-eslint/* 7.2.0 -> 7.18.0, vite 7.3.0 -> 7.3.3 for the
  vite example and 8.0.3 -> 8.0.11 for the tutorial, several others).
- Tighten root-workspace devDep ranges that were missed in 9a3ffae:
  @types/use-sync-external-store ^0.0.6 -> ^1.5.0,
  @vitest/coverage-v8 ~4.0.18 -> ~4.1.5,
  vitest ~4.0.18 -> ~4.1.5,
  eslint-plugin-react-hooks ^5.2.0 -> ^7.1.1,
  eslint-plugin-sort-destructure-keys ^2.0.0 -> ^3.0.0.
- Add `installConfig.hoistingLimits: "workspaces"` to the tutorial
  example. Without it, Yarn 4 + nmMode: hardlinks-global was hoisting
  enough of the tutorial's deps that it never created the
  examples/tutorial/node_modules/stream-chat-react workspace symlink,
  which broke vite 8's postcss-import resolution of
  `@import "stream-chat-react/dist/css/index.css"`. Limiting hoisting
  to the tutorial workspace itself forces yarn to write the symlink.
  Disk impact is negligible because hardlinks-global is still in effect.
Bump typescript across all three workspaces from ^5.9.3 to ^6.0.3 (the
latest stable, 25 days old — past the npmMinimalAgeGate threshold).

tsconfig modernization required by TS 6 deprecations:
- tsconfig.lib.json: moduleResolution "node" → "bundler", module
  "es2020" → "esnext" (the old "node10"/"node" resolution mode is
  deprecated and will be removed in TS 7).
- tsconfig.test.json: same module/moduleResolution upgrade.
- examples/vite/tsconfig.json: drop the now-orphaned `baseUrl: "."`
  (TS 6 deprecates baseUrl unless paths is also used; we removed paths
  earlier alongside the workspaces migration).

Source adjustments forced by the stricter moduleResolution: "bundler":
- Drop deep imports into the dist layout of two packages that don't
  declare those subpaths in their `exports` field:
  - `react-markdown/lib` Options → re-import from `react-markdown` (the
    top-level package re-exports Options).
  - `hast-util-find-and-replace/lib` Nodes → not part of the package's
    public surface; derive it via `Parameters<typeof findAndReplace>[0]`
    (or `Parameters<typeof visit>[0]` in the unist-util-visit case).
- Streami18n: TS 6 surfaced a "type cannot be named portably" diagnostic
  for `i18nInstance = i18n.createInstance()` and its getter, because the
  inferred type pointed into node_modules/i18next. Add an explicit
  `i18n as I18n` import and annotate both sites.
- examples/vite/src/vite-env.d.ts: add the standard
  `/// <reference types="vite/client" />` so the tsc step in the
  example's build script picks up vite's ambient declarations for
  side-effect imports of `.css`/`.scss` files (newly stricter under
  bundler resolution).
Consolidate linting under the repo root. Each example workspace
previously carried its own eslint config plus a duplicate set of eslint
+ typescript-eslint + react-hooks/refresh devDependencies; now they
inherit the SDK's flat config in eslint.config.mjs.

Root eslint.config.mjs:
- Stop ignoring `examples/` globally; keep example-internal `dist/`,
  `node_modules/`, and `docs-playwright/` ignored.
- Extend the default block's `files` to include
  `examples/*/src/**/*.{js,ts,jsx,tsx}` so examples get the same
  plugins, extends (js+ts+react recommended), and recommended rule set
  as `src/`.
- Add a new `examples` block that opts out of four cosmetic rules
  (`sort-keys`, `sort-destructure-keys/sort-destructure-keys`,
  `react/jsx-sort-props`, `@typescript-eslint/no-non-null-assertion`)
  for example code. Real-bug rules (react-hooks deps,
  react/no-unknown-property, import/no-extraneous-dependencies,
  no-unescaped-entities, ban-ts-comment, etc.) still apply.

Examples cleanup:
- Delete `examples/tutorial/eslint.config.js` and
  `examples/vite/.eslintrc.cjs`.
- Drop from `examples/tutorial/package.json`: @eslint/js, eslint,
  eslint-plugin-react-hooks, eslint-plugin-react-refresh, globals,
  typescript-eslint, and the local `lint` script (root `yarn lint`
  covers it now).
- Drop from `examples/vite/package.json`: @typescript-eslint/*, eslint,
  eslint-plugin-react-hooks, eslint-plugin-react-refresh.
- Declare deps that examples were importing transitively but not
  listing (import/no-extraneous-dependencies is now enforced): the vite
  example gets @emoji-mart/data, clsx, emoji-mart, stream-chat.

Source fixes to satisfy the inherited rules (real-bug class only):
- examples/vite/src/App.tsx: rewrite useUser so useMemo/useCallback
  dependency arrays include searchParams/environment/userId — the
  previous arrays were stale (react-hooks/exhaustive-deps); replace
  `@ts-ignore` with `@ts-expect-error` (ban-ts-comment); replace `any`
  in the Streami18n call with `NonNullable<ConstructorParameters<…>[0]>
  ['language']` (no-explicit-any); drop an unused eslint-disable
  directive.
- examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx:
  escape literal quotes with `&ldquo;`/`&rdquo;`
  (react/no-unescaped-entities).
- examples/vite/src/PublicChannelOverlay/PublicChannelOverlay.tsx:
  escape `'` with `&apos;`.
- examples/vite/src/ChatLayout/Panels.tsx: replace `() => {}` with
  `() => undefined` (no-empty-function).
- examples/vite/src/CustomMessageUi/variants.tsx: drop unused
  `useComponentContext` import.
- Various files reformatted by prettier 3.8.

All of the auto-fix-driven reorderings of imports/JSX props/object keys
across the example sources are part of this commit's diff; functionally
no-ops.
Pairs naturally with the nmMode: hardlinks-global setting we already
added — that change makes secondary worktree installs near-instant, but
the examples have real (gitignored) .env files. Without a sync step,
every `claude -w <name>` or `git worktree add` requires manually
copying envs into the new worktree before the example dev servers can
boot.

- scripts/copy-env-from-main.sh: bash script that walks the main
  worktree's tracked-or-untracked .env / .env.* files (skipping
  .env.example / -example / .sample, plus node_modules, .next, dist,
  .git, .claude/worktrees), and copies any that don't already exist in
  the current worktree. No-ops if invoked from the main worktree.
  Uses `cp -Lp` (the CodeRabbit fix on the video-js PR) so symlinked
  envs are dereferenced into real files in the new worktree.
- .claude/settings.json: SessionStart hook that runs the script for
  every claude session startup, so `claude -w <name>` is enough — no
  manual copy step. Settings file is shared (gitignored counterpart is
  .claude/settings.local.json which is per-user).
- .gitignore: ignore .claude/worktrees/ so worktree dirs Claude creates
  aren't ever staged.
…pt cleanup

Bundles four small follow-ups from the post-migration audit:

1. Husky 8 -> 9 (^9.1.7)
   - Drop the .husky/_/husky.sh source line from .husky/pre-commit and
     .husky/commit-msg; v9 hooks are plain shell scripts and `husky`
     installs the necessary `.husky/_/` helpers itself.
   - postinstall script: `husky install` -> `husky` (v9 made `install`
     a no-op alias and will drop it in v10).
   - Switch the commit-msg hook from `npx commitlint` to
     `yarn commitlint` so it goes through the workspace's yarn binary
     and doesn't accidentally bootstrap a different package manager.

2. Align Vite to ^8.0.12 across all three workspaces (was 7.3.3 in
   the root SDK + vite example, 8.0.11 in tutorial).
   - Add `vite` to `npmPreapprovedPackages` in .yarnrc.yml so the
     freshly published 8.0.12 isn't quarantined by the 3-day age gate.
     This is a single targeted exception; everything else still passes
     through the gate.
   - vite.config.ts: rolldown 1 (Vite 8.0.12's bundler) expands the
     `[format]` output placeholder to "esm" instead of "es", which
     would have moved our build output from dist/es/ to dist/esm/ and
     broken package.json `exports` for consumers. Hardcode the dir per
     format ('es' / 'cjs') so the SDK keeps emitting to dist/es/.

3. Tutorial example: build now runs `tsc -b && vite build` (was just
   `vite build`), matching the vite example. Type errors in tutorial
   code now fail the build instead of only showing up in editors.

4. Drop the orphan `preversion: yarn install` script from root
   package.json — it was a leftover from the yarn 1 / npm-style
   `version` flow and isn't invoked by semantic-release.
- @braintree/sanitize-url ^6.0.4 → ^7.1.2
- @commitlint/{cli,config-conventional} ^18 → ^21
- concurrently ^8.2.2 → ^9.2.1
- conventional-changelog-conventionalcommits ^8 → ^9.3.1
- globals ^15.15.0 → ^17.6.0
- lint-staged ^15.5.2 → ^17.0.4
- examples/vite: @vitejs/plugin-react ^5 → ^6, @vitejs/plugin-react-swc ^3 → ^4
- drop tslib (no source/build references, importHelpers not set)
Both tsconfig.lib.json and tsconfig.test.json had module: "esnext"
while target was already "es2020". Set module to "es2020" in both so
the two options agree.

(tsconfig.test.json also had a Cyrillic homoglyph in this value —
"ес2020" with U+0435/U+0441 — which would have made tsc reject it.
Replaced with ASCII at the same time.)
Delegate the yarn cache to actions/setup-node@v6 via cache: 'yarn'
instead of a separate actions/cache step.
…connected-client guard

Address review feedback on #3188 (MartinCupela). The getCurrentUserId
try/catch helper was added in 32df11a to absorb a "You can't use a
channel after client.disconnect() was called" throw that surfaces under
React 19.2: a late passive-mount effect in useChannelDisplayName runs
after the channel.deleted handler tears the client down. The reviewer's
note "the channel is removed from the UI" is true in steady state but
not for that one frame.

Replace the helper by sourcing currentUserId from useChatContext's
client at the hook layer and threading it as a parameter into
computeChannelDisplayName and getChannelDisplayImage. Reading
client.userID off the React-held reference is safe -- only the
channel.getClient() method throws on a disconnected client.

getLatestMessagePreview's public prop signature is preserved; its
poll-branch lookup is left as channel.getClient().userID because that
code path isn't exercised by the failing test.

Test "ChannelList > channel.deleted > should unset activeChannel if it
was deleted" passes again under react/react-dom ^19.2.6.
…ces-migration

# Conflicts:
#	.github/workflows/ci.yml
@braintree/sanitize-url was bumped from ^6.0.4 to ^7.1.2 as part of
this branch's dep modernization. v7 runs URLs through new URL().href,
which canonicalizes hostname-only URLs by appending a trailing "/"
(WHATWG spec). BaseImage runs its src prop through sanitizeUrl, so
DOM src values now reflect the canonicalized form.

Update the 6 GalleryUI and 1 ModalGallery assertions that fed bare
hostname URLs through BaseImage to expect the trailing-slash form.
Drop yarn example:tutorial / example:vite in favor of yarn
start:tutorial / start:vite so the example dev servers live in the
same start:* namespace as start (SDK type watcher) and start:css
(styling watcher). examples:build (build-all-examples) is unchanged.

Updates CLAUDE.md, AGENTS.md, and examples/tutorial/README.md to
reflect the new names.
…SM output

The external regex only matched one subpath segment
(^${dep}(\/[\w-]+)?$), so dayjs/locale/de etc. fell through and got
bundled. Each locale's UMD glue contains a CJS `require("dayjs")`,
which Rolldown 1 (Vite 8) preserves via its __require shim instead of
converting; at runtime in a browser ESM context the shim throws

    Calling `require` for "dayjs" in an environment that doesn't
    expose the `require` function.

Loosen the regex to (\/.+)? so dependency subpaths at any depth are
externalized. All deep-subpath imports in src are dayjs/locale/* and
dayjs/plugin/* -- both intended to be resolved by the consumer.
…ces-migration

# Conflicts:
#	examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx
#	examples/vite/src/icons.tsx
#	package.json
#	yarn.lock
- Yarn 4.14.1 → 4.15.0 (.yarn/releases, .yarnrc.yml, packageManager)
- Runtime: linkifyjs ^4.3.3
- Root devDeps: @commitlint/{cli,config-conventional} 21.0.1, @types/react
  19.2.15, @vitest/coverage-v8 ^4.1.7, i18next-cli 1.58.0, lint-staged
  17.0.5, sass 1.100.0, typescript-eslint 8.59.4, vite 8.0.14, vitest
  ^4.1.7
- examples/vite: @playwright/test 1.60.0, @types/react 19.2.15,
  @vitejs/plugin-react 6.0.2, @vitejs/plugin-react-swc 4.3.1, sass
  1.100.0, vite 8.0.14, vite-plugin-babel 1.7.3
- examples/tutorial: @types/react 19.2.15, @vitejs/plugin-react 6.0.2,
  vite 8.0.14

All bumps stay within current majors and satisfy the 3-day
npmMinimalAgeGate.
Introduce ESLint guardrails to enforce the declared React 17/18/19
peer-dep range and fix the two existing breaks that violated it:

- src/utils/useStableId: new hook wrapping React 18+ useId with a
  nanoid-backed fallback for React 17. Migrate four call sites
  (ChatView, SwitchField, MessageText, SearchBar) off the direct
  useId import, which crashes on React 17.
- useReducedMotionPreference: import useSyncExternalStore from the
  use-sync-external-store/shim instead of from react (the shim is
  already a dependency and matches how the rest of the codebase
  does it).
- QuickMessageActionsButton: wrap in forwardRef so callers that
  pass ref={…} (e.g. the MessageActions.defaults dropdown toggle)
  work on React 17 and 18, not just 19.

Add a react-compat ESLint block forbidding:

- useId, useSyncExternalStore, useEffectEvent, use imported from
  react (and the React.<api> member-access form)
- ref declared in a TS prop type
- ref destructured from a function component's props

Also: widen useRef<T>(undefined) to useRef<T | undefined>(undefined)
where the call signature was already React-19-only, and convert bare
return; statements in early-return components to return null;
(React 17 JSX rejects undefined as an element).
@codecov
Copy link
Copy Markdown

codecov Bot commented May 25, 2026

Codecov Report

❌ Patch coverage is 88.23529% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.71%. Comparing base (20e554a) to head (d6c2c67).
⚠️ Report is 10 commits behind head on master.

Files with missing lines Patch % Lines
...rc/components/Attachment/LinkPreview/CardAudio.tsx 0.00% 1 Missing ⚠️
src/components/UtilityComponents/useStableId.ts 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3197      +/-   ##
==========================================
+ Coverage   83.68%   83.71%   +0.02%     
==========================================
  Files         435      435              
  Lines       13028    13127      +99     
  Branches     4202     4249      +47     
==========================================
+ Hits        10903    10989      +86     
- Misses       2125     2138      +13     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/utils/useStableId.ts Outdated
Consolidate on the existing src/components/UtilityComponents/useStableId
hook instead of the parallel one introduced in the previous commit. The
existing hook is extended to delegate to React.useId() on React 18+ (so
it is SSR-stable there) while keeping its nanoid fallback for React 17.

- src/utils/useStableId.ts and its test moved into UtilityComponents.
- The four call sites added in the previous commit (ChatView, SwitchField,
  MessageText, SearchBar) now import from UtilityComponents and drop the
  prefix argument.
- ESLint react-compat escape-hatch updated to the new path.
The previous commit moved useStableId from src/utils to
src/components/UtilityComponents but the CLAUDE.md note still pointed
to the old path. Update the note so the cookbook entry resolves.
@oliverlaz oliverlaz changed the title fix(compat): make React 17/18 usages crash-safe fix(compat): restore React 17/18 compatibility in certain components May 26, 2026
React 19.1 changed the default useId prefix from `:r0:` to `«r0»`, but the
existing regex only stripped colons — leaving guillemets in the returned id
and breaking HTML id usage on React 19.1. Extract the strip logic into a
`stripUseIdWrappers` helper covering both formats, and add unit tests.
# Conflicts:
#	.claude/settings.json
#	package.json
#	src/components/Message/renderText/rehypePlugins/emojiMarkdownPlugin.ts
#	src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts
#	yarn.lock
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 27, 2026

Size Change: 0 B

Total Size: 651 kB

ℹ️ View Unchanged
Filename Size
dist/cjs/audioProcessing.js 1.74 kB
dist/cjs/emojis.js 2.53 kB
dist/cjs/index.js 256 kB
dist/cjs/mp3-encoder.js 814 B
dist/cjs/useNotificationApi.js 46.2 kB
dist/css/emoji-picker.css 178 B
dist/css/emoji-replacement.css 456 B
dist/css/index.css 39.7 kB
dist/es/audioProcessing.mjs 1.65 kB
dist/es/emojis.mjs 2.45 kB
dist/es/index.mjs 253 kB
dist/es/mp3-encoder.mjs 768 B
dist/es/useNotificationApi.mjs 44.9 kB

compressed-size-action

@oliverlaz oliverlaz merged commit b513b69 into master May 27, 2026
5 checks passed
@oliverlaz oliverlaz deleted the chore/react-17-18-19-compat branch May 27, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants