From 39311ca5951880b68cdcae54a9a0337cb38cd45c Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 25 Sep 2026 12:49:16 +0200 Subject: [PATCH] fix(EmojiPicker): drop @emoji-mart/react peer dependency Ports GetStream/stream-chat-react#3255 onto v15. `@emoji-mart/react` does not declare React 19 in its peer dependencies, so integrators on React 19 needed `package.json` overrides to install it. Its React wrapper around the emoji-mart `Picker` custom element is now vendored as `src/plugins/Emojis/Picker.tsx` (MIT, Copyright (c) Missive), and the package is removed from the peer, optional-peer and dev dependencies. `Picker` stays internal: `stream-chat-react/emojis` exports the same API. Beyond #3255: `EmojiPicker` no longer unwraps the CJS default export of `@emoji-mart/react` (#3199), since that import is gone. `AI.md` no longer tells integrators to install the package or to add React 19 overrides for it, and points to `package.json` for dependency versions instead of listing them, which had left `stream-chat` at `^9.27.2`. Co-Authored-By: Claude Opus 5.5 --- AI.md | 17 ++- package.json | 5 - src/plugins/Emojis/EmojiPicker.tsx | 12 +-- src/plugins/Emojis/Picker.tsx | 32 ++++++ src/plugins/Emojis/__tests__/Picker.test.tsx | 105 +++++++++++++++++++ yarn.lock | 14 --- 6 files changed, 145 insertions(+), 40 deletions(-) create mode 100644 src/plugins/Emojis/Picker.tsx create mode 100644 src/plugins/Emojis/__tests__/Picker.test.tsx diff --git a/AI.md b/AI.md index 5d2804238..b05489250 100644 --- a/AI.md +++ b/AI.md @@ -243,7 +243,7 @@ const CustomMessage = () => { **Steps**: -1. Install emoji packages: `npm install emoji-mart @emoji-mart/react @emoji-mart/data` +1. Install emoji packages: `npm install emoji-mart @emoji-mart/data` 2. Initialize emoji data: `init({ data })` from `emoji-mart` 3. Import `EmojiPicker` from `stream-chat-react/emojis` 4. Pass `EmojiPicker` and `emojiSearchIndex={SearchIndex}` to `Channel` @@ -260,8 +260,6 @@ init({ data }); ; ``` -**Note**: For React 19, may need package.json overrides for `@emoji-mart/react` - **Reference**: See `examples/tutorial/src/6-emoji-picker/` ## TypeScript Setup @@ -401,14 +399,11 @@ interacts with `` / `` request-handler props, and why there is ## Package Information - **Package Name**: `stream-chat-react` -- **Peer Dependencies**: - - `react`: ^19.0.0 || ^18.0.0 || ^17.0.0 - - `react-dom`: ^19.0.0 || ^18.0.0 || ^17.0.0 - - `stream-chat`: ^9.27.2 -- **Optional Dependencies** (for emoji support): - - `emoji-mart`: ^5.4.0 - - `@emoji-mart/react`: ^1.1.0 - - `@emoji-mart/data`: ^1.1.0 +- **Peer Dependencies**: `react`, `react-dom`, `stream-chat` +- **Optional Peer Dependencies** (for emoji support): `emoji-mart`, `@emoji-mart/data` +- **Versions**: read the supported ranges from `peerDependencies` in the package's `package.json` + (`node_modules/stream-chat-react/package.json` in an app). They change with every release, so + they are not repeated here. ## Best Practices diff --git a/package.json b/package.json index cd3ad1ff7..6f9dcb85e 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,6 @@ "peerDependencies": { "@breezystack/lamejs": "^1.2.7", "@emoji-mart/data": "^1.1.0", - "@emoji-mart/react": "^1.1.0", "emoji-mart": "^5.4.0", "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", @@ -140,9 +139,6 @@ "@emoji-mart/data": { "optional": true }, - "@emoji-mart/react": { - "optional": true - }, "emoji-mart": { "optional": true }, @@ -161,7 +157,6 @@ "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", "@emoji-mart/data": "^1.2.1", - "@emoji-mart/react": "^1.1.1", "@eslint/js": "^9.39.4", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 0271764fe..de812298c 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import PickerImport from '@emoji-mart/react'; +import { Picker, type PickerProps } from './Picker'; import { useComponentContextIcons, @@ -14,14 +14,6 @@ import { import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive'; -// @emoji-mart/react ships as CJS with the component on `exports.default`. Under -// spec-strict ESM interop (e.g. Vite 8 / Rolldown, native Node ESM) a default -// import yields the module namespace `{ default }` instead of the component, -// which makes React throw "Element type is invalid ... got: object". Unwrap the -// default defensively so it works regardless of interop. -const Picker = - (PickerImport as unknown as { default?: typeof PickerImport }).default ?? PickerImport; - const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; export type EmojiPickerProps = { @@ -34,7 +26,7 @@ export type EmojiPickerProps = { * Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.5.2#options--props) to be * passed down to the [emoji-mart `Picker`](https://github.com/missive/emoji-mart/tree/v5.5.2#-picker) component */ - pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & Record>; + pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & PickerProps>; /** * Floating UI placement (default: 'top-end') for the picker popover */ diff --git a/src/plugins/Emojis/Picker.tsx b/src/plugins/Emojis/Picker.tsx new file mode 100644 index 000000000..97e09ae7e --- /dev/null +++ b/src/plugins/Emojis/Picker.tsx @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react'; +import { Picker as EmojiMartPicker } from 'emoji-mart'; + +/** + * Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.5.2#options--props) forwarded + * to the emoji-mart `Picker` custom element. + */ +export type PickerProps = Record; + +// React wrapper around the emoji-mart `Picker` custom element. Taken and adjusted from +// @emoji-mart/react (MIT, Copyright (c) Missive): +// https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/packages/emoji-mart-react/react.tsx +// +// Vendored rather than depended upon because @emoji-mart/react does not declare React 19 in its +// peer dependencies, which forces consumers into `package.json` overrides. +export const Picker = (props: PickerProps) => { + const ref = useRef(null); + const instance = useRef(null); + if (instance.current) { + instance.current.update(props); + } + + useEffect(() => { + instance.current = new EmojiMartPicker({ ...props, ref }); + return () => { + instance.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +}; diff --git a/src/plugins/Emojis/__tests__/Picker.test.tsx b/src/plugins/Emojis/__tests__/Picker.test.tsx new file mode 100644 index 000000000..17d7ab146 --- /dev/null +++ b/src/plugins/Emojis/__tests__/Picker.test.tsx @@ -0,0 +1,105 @@ +import React, { StrictMode } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { Picker } from '../Picker'; + +// Minimal payload in the shape emoji-mart expects, so the picker can initialize without +// pulling in the full @emoji-mart/data set. +const data = { + aliases: {}, + categories: [{ emojis: ['grinning'], id: 'people' }], + emojis: { + grinning: { + id: 'grinning', + keywords: ['face', 'smile'], + name: 'Grinning Face', + skins: [{ native: '😀', unified: '1f600' }], + version: 1, + }, + }, + sheet: { cols: 60, rows: 60 }, +}; + +const pickerElements = (container: HTMLElement) => + container.querySelectorAll('em-emoji-picker'); + +const getRenderedPicker = async (container: HTMLElement) => { + await waitFor(() => expect(pickerElements(container)).toHaveLength(1)); + const element = container.querySelector('em-emoji-picker'); + // emoji-mart renders into a shadow root from an async `connectedCallback`, so wait for + // the UI itself rather than just the custom element wrapper. + await waitFor(() => + expect(element?.shadowRoot?.querySelector('input[type="search"]')).toBeTruthy(), + ); + return element; +}; + +describe('Emojis/Picker', () => { + const OriginalIntersectionObserver = globalThis.IntersectionObserver; + + beforeEach(() => { + // emoji-mart observes emoji category rows to lazy-render them; jsdom has no + // IntersectionObserver, and without a stub the picker's componentDidMount rejects. + // @ts-expect-error intersection observer stubs + globalThis.IntersectionObserver = class MockIntersectionObserver implements IntersectionObserver { + root = null; + rootMargin = ''; + thresholds = []; + disconnect = vi.fn(); + observe = vi.fn(); + takeRecords = vi.fn(() => []); + unobserve = vi.fn(); + }; + }); + + afterEach(() => { + globalThis.IntersectionObserver = OriginalIntersectionObserver; + }); + + it('mounts exactly one emoji-mart picker element', async () => { + const { container } = render(); + await getRenderedPicker(container); + }); + + it('mounts exactly one emoji-mart picker element under StrictMode', async () => { + // StrictMode double-invokes effects (mount -> cleanup -> mount), so the wrapper + // constructs a second emoji-mart Picker against the same container. It stays at one + // element only because emoji-mart clears the container (`ref.innerHTML = ''`) before + // appending. If that ever changes upstream, this catches the duplicated picker. + const { container } = render( + + + , + ); + + await getRenderedPicker(container); + }); + + it('updates the existing instance on re-render instead of remounting it', async () => { + const { container, rerender } = render(); + + const element = await getRenderedPicker(container); + expect(element?.shadowRoot?.querySelector('#root')).toHaveAttribute( + 'data-theme', + 'light', + ); + + rerender(); + + await waitFor(() => + expect(element?.shadowRoot?.querySelector('#root')).toHaveAttribute( + 'data-theme', + 'dark', + ), + ); + // the same custom element instance was updated in place, not torn down and rebuilt + expect(pickerElements(container)).toHaveLength(1); + expect(container.querySelector('em-emoji-picker')).toBe(element); + }); + + it('removes the picker element on unmount', async () => { + const { container, unmount } = render(); + await getRenderedPicker(container); + unmount(); + expect(pickerElements(container)).toHaveLength(0); + }); +}); diff --git a/yarn.lock b/yarn.lock index 587ce0cf2..8c85afe9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -605,16 +605,6 @@ __metadata: languageName: node linkType: hard -"@emoji-mart/react@npm:^1.1.1": - version: 1.1.1 - resolution: "@emoji-mart/react@npm:1.1.1" - peerDependencies: - emoji-mart: ^5.2 - react: ^16.8 || ^17 || ^18 - checksum: 10c0/88a9c8c24bbc5695f0ed2458734c9982c965a16db1999bc731c7cce77f9bf228f1871e899744f9a3f9fdd36a11db7ad6c0e049d710cb91c66c69a2cd4d2ee40a - languageName: node - linkType: hard - "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -9487,7 +9477,6 @@ __metadata: "@commitlint/cli": "npm:^21.0.1" "@commitlint/config-conventional": "npm:^21.0.1" "@emoji-mart/data": "npm:^1.2.1" - "@emoji-mart/react": "npm:^1.1.1" "@eslint/js": "npm:^9.39.4" "@floating-ui/react": "npm:^0.27.19" "@react-aria/focus": "npm:^3.22.0" @@ -9564,7 +9553,6 @@ __metadata: peerDependencies: "@breezystack/lamejs": ^1.2.7 "@emoji-mart/data": ^1.1.0 - "@emoji-mart/react": ^1.1.0 emoji-mart: ^5.4.0 modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 @@ -9586,8 +9574,6 @@ __metadata: optional: true "@emoji-mart/data": optional: true - "@emoji-mart/react": - optional: true emoji-mart: optional: true modern-normalize: