Remove jotai from twenty-ui (#21937)

twenty-ui no longer depends on jotai, so its components work without a
consumer-provided jotai store (better practice for a shared UI library).
twenty-front keeps jotai; this is scoped to the library.

- **Avatar**: tracks image-load failure in local `useState` instead of a
global atom.
- **Icons**: the icon registry moved from a jotai atom to a React
Context. `IconsProvider` and `useIcons` keep identical signatures; the
context itself stays internal.
- Removed the unused `createState` helper, the `invalidAvatarUrlsAtomV2`
/ `iconsState` atoms, and `JotaiRootDecorator`; regenerated barrels and
dropped the `jotai` dependency.

No other package needs changes: nothing imports the removed symbols, and
`twenty-sdk` (which re-exports twenty-ui via `export *`) simply stops
surfacing the two leaked atoms on its next publish.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21937?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-06-22 13:33:32 +02:00
committed by GitHub
parent 003ad62f66
commit e0fadfee7c
22 changed files with 39 additions and 86 deletions
-1
View File
@@ -76,7 +76,6 @@
"date-fns": "^4.4.0",
"framer-motion": "^11.18.0",
"glob": "^11.1.0",
"jotai": "^2.17.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-responsive": "^9.0.2",
@@ -1,10 +1,8 @@
import { isNonEmptyString, isNull, isUndefined } from '@sniptt/guards';
import { clsx } from 'clsx';
import { useAtom } from 'jotai';
import { useContext } from 'react';
import { useContext, useState } from 'react';
import { handleClickableElementKeyDown } from '@ui/accessibility/utils/handleClickableElementKeyDown';
import { invalidAvatarUrlsAtomV2 } from '@ui/data-display/Avatar/states/invalidAvatarUrlsAtomV2';
import { type AvatarSize } from '@ui/data-display/Avatar/types/AvatarSize';
import { type AvatarType } from '@ui/data-display/Avatar/types/AvatarType';
import { type IconComponent } from '@ui/icon/types/IconComponent';
@@ -47,9 +45,9 @@ export const Avatar = ({
}: AvatarProps) => {
const { theme } = useContext(ThemeContext);
const [invalidAvatarUrls, setInvalidAvatarUrls] = useAtom(
invalidAvatarUrlsAtomV2,
);
const [erroredAvatarImageURI, setErroredAvatarImageURI] = useState<
string | null
>(null);
const avatarImageURI = isNonEmptyString(avatarUrl)
? getImageAbsoluteURI({
@@ -64,11 +62,11 @@ export const Avatar = ({
const placeholderChar = placeholderFirstChar?.toUpperCase() || '-';
const showPlaceholder =
isNull(avatarImageURI) || invalidAvatarUrls.includes(avatarImageURI);
isNull(avatarImageURI) || erroredAvatarImageURI === avatarImageURI;
const handleImageError = () => {
if (isNonEmptyString(avatarImageURI)) {
setInvalidAvatarUrls((prev) => [...prev, avatarImageURI]);
setErroredAvatarImageURI(avatarImageURI);
}
};
@@ -1,17 +1,13 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import {
AVATAR_URL_MOCK,
ComponentDecorator,
JotaiRootDecorator,
} from '@ui/testing';
import { AVATAR_URL_MOCK, ComponentDecorator } from '@ui/testing';
import { Avatar } from '@ui/data-display/Avatar/Avatar';
const meta: Meta<typeof Avatar> = {
title: 'UI/Data Display/Avatar',
component: Avatar,
decorators: [ComponentDecorator, JotaiRootDecorator],
decorators: [ComponentDecorator],
args: {
avatarUrl: AVATAR_URL_MOCK,
size: 'md',
@@ -1,4 +0,0 @@
import { atom } from 'jotai';
export const invalidAvatarUrlsAtomV2 = atom<string[]>([]);
invalidAvatarUrlsAtomV2.debugLabel = 'invalidAvatarUrlsAtomV2';
@@ -8,7 +8,6 @@ import {
AVATAR_URL_MOCK,
CatalogDecorator,
ComponentDecorator,
JotaiRootDecorator,
} from '@ui/testing';
import {
@@ -43,7 +42,7 @@ export default meta;
type Story = StoryObj<typeof AvatarGroup>;
export const Default: Story = {
decorators: [ComponentDecorator, JotaiRootDecorator],
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
@@ -69,5 +68,5 @@ export const Catalog: Story = {
],
},
},
decorators: [CatalogDecorator, JotaiRootDecorator],
decorators: [CatalogDecorator],
};
@@ -1,16 +1,12 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { IconBuildingSkyscraper, IconUser } from '@ui/icon';
import {
AVATAR_URL_MOCK,
ComponentDecorator,
JotaiRootDecorator,
} from '@ui/testing';
import { AVATAR_URL_MOCK, ComponentDecorator } from '@ui/testing';
import { AvatarOrIcon } from '@ui/data-display/AvatarOrIcon/AvatarOrIcon';
const meta: Meta<typeof AvatarOrIcon> = {
title: 'UI/Data Display/AvatarOrIcon',
component: AvatarOrIcon,
decorators: [ComponentDecorator, JotaiRootDecorator],
decorators: [ComponentDecorator],
};
export default meta;
@@ -12,7 +12,6 @@ export { AnimatedCheckmark } from './AnimatedCheckmark/AnimatedCheckmark';
export type { AvatarProps } from './Avatar/Avatar';
export { Avatar } from './Avatar/Avatar';
export { AVATAR_PROPERTIES_BY_SIZE } from './Avatar/constants/AvatarPropertiesBySize';
export { invalidAvatarUrlsAtomV2 } from './Avatar/states/invalidAvatarUrlsAtomV2';
export type { AvatarSize } from './Avatar/types/AvatarSize';
export type { AvatarType } from './Avatar/types/AvatarType';
export type { AvatarGroupProps } from './AvatarGroup/AvatarGroup';
@@ -2,7 +2,6 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
import { IconsProvider } from '@ui/icon/providers/IconsProvider';
import { ComponentDecorator } from '@ui/testing/decorators/ComponentDecorator';
import { JotaiRootDecorator } from '@ui/testing/decorators/JotaiRootDecorator';
import { Icon } from '../Icon';
@@ -16,7 +15,6 @@ const meta: Meta<typeof Icon> = {
<Story />
</IconsProvider>
),
JotaiRootDecorator,
],
};
@@ -1,11 +1,12 @@
import { renderHook } from '@testing-library/react';
import { Provider } from 'jotai';
import { type ReactNode } from 'react';
import {
Icon123,
IconBuildingSkyscraper,
IconUser,
} from '@ui/icon/components/TablerIcons';
import { IconsContext } from '@ui/icon/internal/IconsContext';
import { useIcons } from '@ui/icon/hooks/useIcons';
const mockedStateIcons = {
@@ -14,14 +15,15 @@ const mockedStateIcons = {
IconBuildingSkyscraper,
};
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: () => mockedStateIcons,
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<IconsContext.Provider value={mockedStateIcons}>
{children}
</IconsContext.Provider>
);
describe('useIcons', () => {
const { result } = renderHook(() => useIcons(), {
wrapper: Provider,
wrapper: Wrapper,
});
it('returns default icon when no icon key is provided', () => {
@@ -1,10 +1,10 @@
import { useAtomValue } from 'jotai';
import { useContext } from 'react';
import { Icon123 } from '@ui/icon/components/TablerIcons';
import { iconsState } from '@ui/icon/states/iconsState';
import { IconsContext } from '@ui/icon/internal/IconsContext';
export const useIcons = () => {
const icons = useAtomValue(iconsState);
const icons = useContext(IconsContext);
const defaultIcon = Icon123;
const getIcons = () => {
-1
View File
@@ -489,5 +489,4 @@ export {
export { ThinkingOrbitLoaderIcon } from './components/ThinkingOrbitLoaderIcon';
export { useIcons } from './hooks/useIcons';
export { IconsProvider } from './providers/IconsProvider';
export { iconsState } from './states/iconsState';
export type { IconComponentProps, IconComponent } from './types/IconComponent';
@@ -0,0 +1,5 @@
import { createContext } from 'react';
import { type IconComponent } from '@ui/icon/types/IconComponent';
export const IconsContext = createContext<Record<string, IconComponent>>({});
@@ -1,20 +1,22 @@
import { useSetAtom } from 'jotai';
import { type JSX, useEffect } from 'react';
import { type JSX, useEffect, useState } from 'react';
import { iconsState } from '@ui/icon/states/iconsState';
import { IconsContext } from '@ui/icon/internal/IconsContext';
import { type IconComponent } from '@ui/icon/types/IconComponent';
type IconsProviderProps = {
children: JSX.Element;
};
export const IconsProvider = ({ children }: IconsProviderProps) => {
const setIcons = useSetAtom(iconsState);
const [icons, setIcons] = useState<Record<string, IconComponent>>({});
useEffect(() => {
import('./internal/AllIcons').then(({ ALL_ICONS }) => {
setIcons(ALL_ICONS);
});
}, [setIcons]);
}, []);
return children;
return (
<IconsContext.Provider value={icons}>{children}</IconsContext.Provider>
);
};
@@ -1,6 +0,0 @@
import { atom } from 'jotai';
import { type IconComponent } from '@ui/icon/types/IconComponent';
export const iconsState = atom<Record<string, IconComponent>>({});
iconsState.debugLabel = 'iconsState';
@@ -15,7 +15,6 @@ import {
CatalogDecorator,
type CatalogStory,
ComponentWithRouterDecorator,
JotaiRootDecorator,
} from '@ui/testing';
import { type ReactNode } from 'react';
@@ -28,7 +27,7 @@ const TabContainer = ({ children }: { children?: ReactNode }) => {
const meta: Meta<typeof TabButton> = {
title: 'UI/Input/Button/TabButton',
component: TabButton,
decorators: [ComponentWithRouterDecorator, JotaiRootDecorator],
decorators: [ComponentWithRouterDecorator],
args: {
id: 'tab-button',
title: 'Tab Title',
@@ -9,7 +9,6 @@ import {
type CatalogOptions,
type CatalogStory,
ComponentDecorator,
JotaiRootDecorator,
} from '@ui/testing';
import { MenuItemMultiSelectAvatar } from '@ui/navigation/MenuItemMultiSelectAvatar/MenuItemMultiSelectAvatar';
@@ -29,7 +28,7 @@ export const Default: Story = {
contextualText: 'Contextual text',
avatar: <Avatar avatarUrl={AVATAR_URL_MOCK} placeholder="L" />,
},
decorators: [ComponentDecorator, JotaiRootDecorator],
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof MenuItemMultiSelectAvatar> = {
@@ -84,5 +83,5 @@ export const Catalog: CatalogStory<Story, typeof MenuItemMultiSelectAvatar> = {
} as CatalogOptions,
},
},
decorators: [CatalogDecorator, JotaiRootDecorator],
decorators: [CatalogDecorator],
};
@@ -9,7 +9,6 @@ import {
type CatalogOptions,
type CatalogStory,
ComponentDecorator,
JotaiRootDecorator,
} from '@ui/testing';
import { MenuItemSelectAvatar } from '@ui/navigation/MenuItemSelectAvatar/MenuItemSelectAvatar';
@@ -38,7 +37,6 @@ export const Default: Story = {
</div>
),
ComponentDecorator,
JotaiRootDecorator,
],
};
@@ -107,6 +105,5 @@ export const Catalog: CatalogStory<Story, typeof MenuItemSelectAvatar> = {
</div>
),
CatalogDecorator,
JotaiRootDecorator,
],
};
@@ -1,9 +0,0 @@
import { type Decorator } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
export const JotaiRootDecorator: Decorator = (Story) => (
<JotaiProvider>
<Story />
</JotaiProvider>
);
-1
View File
@@ -21,7 +21,6 @@ export {
computeLocation,
ComponentWithRouterDecorator,
} from './decorators/ComponentWithRouterDecorator';
export { JotaiRootDecorator } from './decorators/JotaiRootDecorator';
export { RouterDecorator } from './decorators/RouterDecorator';
export { AVATAR_URL_MOCK } from './mocks/avatarUrlMock';
export type { CatalogStory } from './types/CatalogStory';
@@ -24,7 +24,6 @@ export { isNavigationModifierPressed } from './navigation/isNavigationModifierPr
export type { TriggerEventType } from './navigation/types/trigger-event.type';
export { useIsMobile } from './responsive/hooks/useIsMobile';
export { useScreenSize } from './screen-size/hooks/useScreenSize';
export { createState } from './state/utils/createState';
export type { ClickOutsideAttributes } from './types/ClickOutsideAttributes';
export type { Nullable } from './types/Nullable';
export { getDisplayValueByUrlType } from './utils/getDisplayValueByUrlType';
@@ -1,13 +0,0 @@
import { atom, type PrimitiveAtom } from 'jotai';
export const createState = <ValueType>({
key,
defaultValue,
}: {
key: string;
defaultValue: ValueType;
}): PrimitiveAtom<ValueType> => {
const jotaiAtom = atom<ValueType>(defaultValue);
jotaiAtom.debugLabel = key;
return jotaiAtom;
};
-1
View File
@@ -54395,7 +54395,6 @@ __metadata:
glob: "npm:^11.1.0"
jest: "npm:29.7.0"
jest-environment-jsdom: "npm:30.0.0-beta.3"
jotai: "npm:^2.17.1"
prettier: "npm:^3.1.1"
react: "npm:^19.2.0"
react-dom: "npm:^19.2.0"