diff --git a/src-web/components/CookieDropdown.tsx b/src-web/components/CookieDropdown.tsx index 124b5aab..360452f6 100644 --- a/src-web/components/CookieDropdown.tsx +++ b/src-web/components/CookieDropdown.tsx @@ -1,18 +1,19 @@ +import { memo, useCallback } from 'react'; import { useActiveCookieJar } from '../hooks/useActiveCookieJar'; -import { useCookieJars } from '../hooks/useCookieJars'; +import { cookieJarsAtom } from '../hooks/useCookieJars'; import { useCreateCookieJar } from '../hooks/useCreateCookieJar'; import { useDeleteCookieJar } from '../hooks/useDeleteCookieJar'; +import { useDialog } from '../hooks/useDialog'; import { usePrompt } from '../hooks/usePrompt'; import { useUpdateCookieJar } from '../hooks/useUpdateCookieJar'; +import { jotaiStore } from '../lib/jotai'; import { CookieDialog } from './CookieDialog'; import { Dropdown, type DropdownItem } from './core/Dropdown'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; import { InlineCode } from './core/InlineCode'; -import { useDialog } from '../hooks/useDialog'; -export function CookieDropdown() { - const cookieJars = useCookieJars() ?? []; +export const CookieDropdown = memo(function CookieDropdown() { const [activeCookieJar, setActiveCookieJarId] = useActiveCookieJar(); const updateCookieJar = useUpdateCookieJar(activeCookieJar?.id ?? null); const deleteCookieJar = useDeleteCookieJar(activeCookieJar ?? null); @@ -20,77 +21,88 @@ export function CookieDropdown() { const dialog = useDialog(); const prompt = usePrompt(); + const getItems = useCallback((): DropdownItem[] => { + const cookieJars = jotaiStore.get(cookieJarsAtom) ?? []; + return [ + ...cookieJars.map((j) => ({ + key: j.id, + label: j.name, + leftSlot: , + onSelect: () => setActiveCookieJarId(j.id), + })), + ...((cookieJars.length > 0 && activeCookieJar != null + ? [ + { type: 'separator', label: activeCookieJar.name }, + { + key: 'manage', + label: 'Manage Cookies', + leftSlot: , + onSelect: () => { + if (activeCookieJar == null) return; + dialog.show({ + id: 'cookies', + title: 'Manage Cookies', + size: 'full', + render: () => , + }); + }, + }, + { + key: 'rename', + label: 'Rename', + leftSlot: , + onSelect: async () => { + const name = await prompt({ + id: 'rename-cookie-jar', + title: 'Rename Cookie Jar', + description: ( + <> + Enter a new name for {activeCookieJar?.name} + + ), + label: 'Name', + confirmText: 'Save', + placeholder: 'New name', + defaultValue: activeCookieJar?.name, + }); + if (name == null) return; + updateCookieJar.mutate({ name }); + }, + }, + ...((cookieJars.length > 1 // Never delete the last one + ? [ + { + key: 'delete', + label: 'Delete', + leftSlot: , + variant: 'danger', + onSelect: () => deleteCookieJar.mutateAsync(), + }, + ] + : []) as DropdownItem[]), + ] + : []) as DropdownItem[]), + { type: 'separator' }, + { + key: 'create-cookie-jar', + label: 'New Cookie Jar', + leftSlot: , + onSelect: () => createCookieJar.mutate(), + }, + ]; + }, [ + activeCookieJar, + createCookieJar, + deleteCookieJar, + dialog, + prompt, + setActiveCookieJarId, + updateCookieJar, + ]); + return ( - ({ - key: j.id, - label: j.name, - leftSlot: , - onSelect: () => setActiveCookieJarId(j.id), - })), - ...((cookieJars.length > 0 && activeCookieJar != null - ? [ - { type: 'separator', label: activeCookieJar.name }, - { - key: 'manage', - label: 'Manage Cookies', - leftSlot: , - onSelect: () => { - if (activeCookieJar == null) return; - dialog.show({ - id: 'cookies', - title: 'Manage Cookies', - size: 'full', - render: () => , - }); - }, - }, - { - key: 'rename', - label: 'Rename', - leftSlot: , - onSelect: async () => { - const name = await prompt({ - id: 'rename-cookie-jar', - title: 'Rename Cookie Jar', - description: ( - <> - Enter a new name for {activeCookieJar?.name} - - ), - label: 'Name', - confirmText: 'Save', - placeholder: 'New name', - defaultValue: activeCookieJar?.name, - }); - if (name == null) return; - updateCookieJar.mutate({ name }); - }, - }, - ...((cookieJars.length > 1 // Never delete the last one - ? [ - { - key: 'delete', - label: 'Delete', - leftSlot: , - variant: 'danger', - onSelect: () => deleteCookieJar.mutateAsync(), - }, - ] - : []) as DropdownItem[]), - ] - : []) as DropdownItem[]), - { type: 'separator' }, - { - key: 'create-cookie-jar', - label: 'New Cookie Jar', - leftSlot: , - onSelect: () => createCookieJar.mutate(), - }, - ]} - > + ); -} +}); diff --git a/src-web/components/CreateDropdown.tsx b/src-web/components/CreateDropdown.tsx index e9f89738..7241381d 100644 --- a/src-web/components/CreateDropdown.tsx +++ b/src-web/components/CreateDropdown.tsx @@ -1,4 +1,3 @@ -import { useActiveRequest } from '../hooks/useActiveRequest'; import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import type { DropdownProps } from './core/Dropdown'; import { Dropdown } from './core/Dropdown'; @@ -8,15 +7,14 @@ interface Props extends Omit { } export function CreateDropdown({ hideFolder, children, ...props }: Props) { - const activeRequest = useActiveRequest(); - const folderId = activeRequest?.folderId ?? null; - const items = useCreateDropdownItems({ + const getItems = useCreateDropdownItems({ hideFolder, hideIcons: true, - folderId, + folderId: 'active-folder', }); + return ( - + {children} ); diff --git a/src-web/components/RecentRequestsDropdown.tsx b/src-web/components/RecentRequestsDropdown.tsx index 35a1da17..b4244141 100644 --- a/src-web/components/RecentRequestsDropdown.tsx +++ b/src-web/components/RecentRequestsDropdown.tsx @@ -2,6 +2,7 @@ import { useNavigate } from '@tanstack/react-router'; import classNames from 'classnames'; import { useCallback, useMemo, useRef } from 'react'; import { useKeyPressEvent } from 'react-use'; +import { useActiveRequest } from '../hooks/useActiveRequest'; import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace'; import { grpcRequestsAtom } from '../hooks/useGrpcRequests'; import { useHotKey } from '../hooks/useHotKey'; @@ -15,12 +16,11 @@ import { Dropdown } from './core/Dropdown'; import { HttpMethodTag } from './core/HttpMethodTag'; interface Props { - activeRequestId: string | null; - activeRequestName: string; className?: string; } -export function RecentRequestsDropdown({ className, activeRequestId, activeRequestName }: Props) { +export function RecentRequestsDropdown({ className }: Props) { + const activeRequest = useActiveRequest(); const dropdownRef = useRef(null); const [allRecentRequestIds] = useRecentRequests(); const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]); @@ -93,10 +93,10 @@ export function RecentRequestsDropdown({ className, activeRequestId, activeReque className={classNames( className, 'truncate pointer-events-auto', - activeRequestId === null && 'text-text-subtlest italic', + activeRequest == null && 'text-text-subtlest italic', )} > - {activeRequestName} + {fallbackRequestName(activeRequest)} ); diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index bb9f3f12..5d3792bd 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -1,20 +1,24 @@ import type { HttpRequest } from '@yaakapp-internal/models'; import classNames from 'classnames'; +import { atom, useAtomValue } from 'jotai'; import type { CSSProperties } from 'react'; import React, { memo, useCallback, useMemo, useState } from 'react'; import { useLocalStorage } from 'react-use'; +import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders'; +import { grpcRequestsAtom } from '../hooks/useGrpcRequests'; +import { httpRequestsAtom } from '../hooks/useHttpRequests'; import { useImportCurl } from '../hooks/useImportCurl'; import { useImportQuerystring } from '../hooks/useImportQuerystring'; import { useIsResponseLoading } from '../hooks/useIsResponseLoading'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; -import { useRequests } from '../hooks/useRequests'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useToast } from '../hooks/useToast'; import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest'; +import { deepEqualAtom } from '../lib/atoms'; import { languageFromContentType } from '../lib/contentType'; import { tryFormatJson } from '../lib/formatters'; import { generateId } from '../lib/generateId'; @@ -67,15 +71,24 @@ const TAB_HEADERS = 'headers'; const TAB_AUTH = 'auth'; const TAB_DESCRIPTION = 'description'; +const nonActiveRequestUrlsAtom = atom((get) => { + const activeRequestId = get(activeRequestIdAtom); + const requests = [...get(httpRequestsAtom), ...get(grpcRequestsAtom)]; + return requests + .filter((r) => r.id !== activeRequestId) + .map((r): GenericCompletionOption => ({ type: 'constant', label: r.url })); +}); + +const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom); + export const RequestPane = memo(function RequestPane({ style, fullHeight, className, activeRequest, }: Props) { - const requests = useRequests(); const activeRequestId = activeRequest.id; - const updateRequest = useUpdateAnyHttpRequest(); + const { mutateAsync: updateRequestAsync, mutate: updateRequest } = useUpdateAnyHttpRequest(); const [activeTabs, setActiveTabs] = useLocalStorage>( 'requestPaneActiveTabs', {}, @@ -102,12 +115,12 @@ export const RequestPane = memo(function RequestPane({ id: generateId(), }); } - await updateRequest.mutateAsync({ id: activeRequest.id, update: { headers } }); + await updateRequestAsync({ id: activeRequest.id, update: { headers } }); // Force update header editor so any changed headers are reflected setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100); }, - [activeRequest, updateRequest], + [activeRequest, updateRequestAsync], ); const toast = useToast(); @@ -206,7 +219,7 @@ export const RequestPane = memo(function RequestPane({ showMethodToast(patch.method); } - await updateRequest.mutateAsync({ id: activeRequestId, update: patch }); + await updateRequestAsync({ id: activeRequestId, update: patch }); if (newContentType !== undefined) { await handleContentTypeChange(newContentType); @@ -247,7 +260,7 @@ export const RequestPane = memo(function RequestPane({ token: authentication.token ?? '', }; } - await updateRequest.mutateAsync({ + updateRequest({ id: activeRequestId, update: { authenticationType, authentication }, }); @@ -267,25 +280,26 @@ export const RequestPane = memo(function RequestPane({ numParams, toast, updateRequest, + updateRequestAsync, urlParameterPairs.length, ], ); - const sendRequest = useSendAnyHttpRequest(); - const { activeResponse } = usePinnedHttpResponse(activeRequestId); - const cancelResponse = useCancelHttpResponse(activeResponse?.id ?? null); const isLoading = useIsResponseLoading(activeRequestId); + const { mutate: sendRequest } = useSendAnyHttpRequest(); + const { activeResponse } = usePinnedHttpResponse(activeRequestId); + const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const { updateKey } = useRequestUpdateKey(activeRequestId); - const importCurl = useImportCurl(); - const importQuerystring = useImportQuerystring(activeRequestId); + const { mutate: importCurl } = useImportCurl(); + const { mutate: importQuerystring } = useImportQuerystring(activeRequestId); const handleBodyChange = useCallback( - (body: HttpRequest['body']) => updateRequest.mutate({ id: activeRequestId, update: { body } }), + (body: HttpRequest['body']) => updateRequest({ id: activeRequestId, update: { body } }), [activeRequestId, updateRequest], ); const handleBodyTextChange = useCallback( - (text: string) => updateRequest.mutate({ id: activeRequestId, update: { body: { text } } }), + (text: string) => updateRequest({ id: activeRequestId, update: { body: { text } } }), [activeRequestId, updateRequest], ); @@ -301,20 +315,48 @@ export const RequestPane = memo(function RequestPane({ setActiveTab(TAB_PARAMS); }); - const autocomplete: GenericCompletionConfig = { - minMatch: 3, - options: - requests.length > 0 - ? [ - ...requests - .filter((r) => r.id !== activeRequestId) - .map((r): GenericCompletionOption => ({ type: 'constant', label: r.url })), - ] - : [ - { label: 'http://', type: 'constant' }, - { label: 'https://', type: 'constant' }, - ], - }; + const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); + + const autocomplete: GenericCompletionConfig = useMemo( + () => ({ + minMatch: 3, + options: + autocompleteUrls.length > 0 + ? autocompleteUrls + : [ + { label: 'http://', type: 'constant' }, + { label: 'https://', type: 'constant' }, + ], + }), + [autocompleteUrls], + ); + + const handlePaste = useCallback( + (text: string) => { + if (text.startsWith('curl ')) { + importCurl({ overwriteRequestId: activeRequestId, command: text }); + } else { + // Only import query if pasted text contains entire querystring + importQuerystring(text); + } + }, + [activeRequestId, importCurl, importQuerystring], + ); + + const handleSend = useCallback( + () => sendRequest(activeRequest.id ?? null), + [activeRequest.id, sendRequest], + ); + + const handleMethodChange = useCallback( + (method: string) => updateRequest({ id: activeRequestId, update: { method } }), + [activeRequestId, updateRequest], + ); + + const handleUrlChange = useCallback( + (url: string) => updateRequest({ id: activeRequestId, update: { url } }), + [activeRequestId, updateRequest], + ); return (
{ - if (text.startsWith('curl ')) { - importCurl.mutate({ overwriteRequestId: activeRequestId, command: text }); - } else { - // Only import query if pasted text contains entire querystring - importQuerystring.mutate(text); - } - }} + onPasteOverwrite={handlePaste} autocomplete={autocomplete} - onSend={() => sendRequest.mutateAsync(activeRequest.id ?? null)} - onCancel={cancelResponse.mutate} - onMethodChange={(method) => - updateRequest.mutate({ id: activeRequestId, update: { method } }) - } - onUrlChange={(url: string) => - updateRequest.mutate({ id: activeRequestId, update: { url } }) - } + onSend={handleSend} + onCancel={cancelResponse} + onMethodChange={handleMethodChange} + onUrlChange={handleUrlChange} forceUpdateKey={updateKey} isLoading={isLoading} /> @@ -372,9 +403,7 @@ export const RequestPane = memo(function RequestPane({ - updateRequest.mutate({ id: activeRequestId, update: { headers } }) - } + onChange={(headers) => updateRequest({ id: activeRequestId, update: { headers } })} /> @@ -383,7 +412,7 @@ export const RequestPane = memo(function RequestPane({ forceUpdateKey={forceUpdateKey + urlParametersKey} pairs={urlParameterPairs} onChange={(urlParameters) => - updateRequest.mutate({ id: activeRequestId, update: { urlParameters } }) + updateRequest({ id: activeRequestId, update: { urlParameters } }) } /> @@ -437,9 +466,7 @@ export const RequestPane = memo(function RequestPane({ requestId={activeRequest.id} contentType={contentType} body={activeRequest.body} - onChange={(body) => - updateRequest.mutate({ id: activeRequestId, update: { body } }) - } + onChange={(body) => updateRequest({ id: activeRequestId, update: { body } })} onChangeContentType={handleContentTypeChange} /> ) : typeof activeRequest.bodyType === 'string' ? ( @@ -467,9 +494,7 @@ export const RequestPane = memo(function RequestPane({ className="font-sans !text-xl !px-0" containerClassName="border-0" placeholder={activeRequest.id} - onChange={(name) => - updateRequest.mutate({ id: activeRequestId, update: { name } }) - } + onChange={(name) => updateRequest({ id: activeRequestId, update: { name } })} /> - updateRequest.mutate({ id: activeRequestId, update: { description } }) + updateRequest({ id: activeRequestId, update: { description } }) } />
diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index 274ef962..c39f23b5 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -317,7 +317,7 @@ export function Sidebar({ className }: Props) { setShowMainContextMenu({ x: e.clientX, y: e.clientY }); }, []); - const mainContextMenuItems = useCreateDropdownItems(); + const mainContextMenuItems = useCreateDropdownItems({ folderId: null }); // Not ready to render yet if (tree == null) { diff --git a/src-web/components/SidebarAtoms.ts b/src-web/components/SidebarAtoms.ts index 4724613c..d706576c 100644 --- a/src-web/components/SidebarAtoms.ts +++ b/src-web/components/SidebarAtoms.ts @@ -1,13 +1,12 @@ -import deepEqual from '@gilbarbara/deep-equal'; import type { Folder, GrpcRequest, HttpRequest } from '@yaakapp-internal/models'; // This is an atom so we can use it in the child items to avoid re-rendering the entire list import { atom } from 'jotai'; -import { selectAtom } from 'jotai/utils'; import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace'; import { foldersAtom } from '../hooks/useFolders'; import { grpcRequestsAtom } from '../hooks/useGrpcRequests'; import { httpRequestsAtom } from '../hooks/useHttpRequests'; +import { deepEqualAtom } from '../lib/atoms'; import { fallbackRequestName } from '../lib/fallbackRequestName'; import type { SidebarTreeNode } from './Sidebar'; @@ -27,11 +26,7 @@ const allPotentialChildrenAtom = atom((get) => { })); }); -const memoAllPotentialChildrenAtom = selectAtom( - allPotentialChildrenAtom, - (v) => v, - (a, b) => deepEqual(a, b), -); +const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom); export const sidebarTreeAtom = atom<{ tree: SidebarTreeNode | null; diff --git a/src-web/components/SidebarItemContextMenu.tsx b/src-web/components/SidebarItemContextMenu.tsx index 05e56dd2..b2e92278 100644 --- a/src-web/components/SidebarItemContextMenu.tsx +++ b/src-web/components/SidebarItemContextMenu.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback } from 'react'; import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import { useDeleteFolder } from '../hooks/useDeleteFolder'; import { useDeleteRequest } from '../hooks/useDeleteRequest'; @@ -42,7 +42,7 @@ export function SidebarItemContextMenu({ child, show, close }: Props) { folderId: child.model === 'folder' ? child.id : null, }); - const items = useMemo(() => { + const items = useCallback((): DropdownItem[] => { if (child.model === 'folder') { return [ { @@ -77,7 +77,7 @@ export function SidebarItemContextMenu({ child, show, close }: Props) { onSelect: () => deleteFolder.mutate(), }, { type: 'separator' }, - ...createDropdownItems, + ...createDropdownItems(), ]; } else { const requestItems: DropdownItem[] = diff --git a/src-web/components/WorkspaceHeader.tsx b/src-web/components/WorkspaceHeader.tsx index 1a3fefd6..5494afbc 100644 --- a/src-web/components/WorkspaceHeader.tsx +++ b/src-web/components/WorkspaceHeader.tsx @@ -1,8 +1,6 @@ import classNames from 'classnames'; import React, { memo } from 'react'; -import { useActiveRequest } from '../hooks/useActiveRequest'; import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette'; -import { fallbackRequestName } from '../lib/fallbackRequestName'; import { CookieDropdown } from './CookieDropdown'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; @@ -21,7 +19,6 @@ interface Props { export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) { const togglePalette = useToggleCommandPalette(); - const activeRequest = useActiveRequest(); return (
- +
diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 79c098c8..0312577a 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -176,7 +176,7 @@ export const Dropdown = forwardRef(function Dropdown interface ContextMenuProps { triggerPosition: { x: number; y: number } | null; className?: string; - items: DropdownItem[]; + items: DropdownProps['items']; onClose: () => void; } @@ -201,7 +201,7 @@ export const ContextMenu = forwardRef(function Co isOpen={true} // Always open because we return null if not className={className} ref={ref} - items={items} + items={typeof items === 'function' ? items() : items} onClose={onClose} triggerShape={triggerShape} /> diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx index c5cce8ed..1996c781 100644 --- a/src-web/components/core/Tabs/Tabs.tsx +++ b/src-web/components/core/Tabs/Tabs.tsx @@ -67,7 +67,7 @@ export function Tabs({ ref={ref} className={classNames( className, - 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 overflow-x-hidden', + 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ', )} >
{ - const activeRequest = get(activeRequestAtom); - return fallbackRequestName(activeRequest); -}); - export function useActiveRequest( model?: T | undefined, ): TypeMap[T] | null { diff --git a/src-web/hooks/useCreateDropdownItems.tsx b/src-web/hooks/useCreateDropdownItems.tsx index 70901a43..47597297 100644 --- a/src-web/hooks/useCreateDropdownItems.tsx +++ b/src-web/hooks/useCreateDropdownItems.tsx @@ -1,8 +1,9 @@ -import { useMemo } from 'react'; +import { useCallback } from 'react'; import type { DropdownItem } from '../components/core/Dropdown'; import { Icon } from '../components/core/Icon'; import { generateId } from '../lib/generateId'; import { BODY_TYPE_GRAPHQL } from '../lib/model_util'; +import { getActiveRequest } from './useActiveRequest'; import { useCreateFolder } from './useCreateFolder'; import { useCreateGrpcRequest } from './useCreateGrpcRequest'; import { useCreateHttpRequest } from './useCreateHttpRequest'; @@ -14,19 +15,26 @@ export function useCreateDropdownItems({ }: { hideFolder?: boolean; hideIcons?: boolean; - folderId?: string | null; -} = {}): DropdownItem[] { + folderId?: string | null | 'active-folder'; +} = {}): () => DropdownItem[] { const { mutate: createHttpRequest } = useCreateHttpRequest(); const { mutate: createGrpcRequest } = useCreateGrpcRequest(); const { mutate: createFolder } = useCreateFolder(); - return useMemo( - () => [ + return useCallback( + (): DropdownItem[] => [ { key: 'create-http-request', label: 'HTTP Request', leftSlot: hideIcons ? undefined : , - onSelect: () => createHttpRequest({ folderId }), + onSelect: () => { + const args = { folderId }; + if (folderId === 'active-folder') { + const activeRequest = getActiveRequest(); + args.folderId = activeRequest?.folderId ?? undefined; + } + createHttpRequest(args); + }, }, { key: 'create-graphql-request', diff --git a/src-web/lib/atoms.ts b/src-web/lib/atoms.ts new file mode 100644 index 00000000..b2665a17 --- /dev/null +++ b/src-web/lib/atoms.ts @@ -0,0 +1,11 @@ +import deepEqual from '@gilbarbara/deep-equal'; +import type { Atom } from 'jotai'; +import { selectAtom } from 'jotai/utils'; + +export function deepEqualAtom(a: Atom) { + return selectAtom( + a, + (v) => v, + (a, b) => deepEqual(a, b), + ); +}