diff --git a/bob-esbuild.config.ts b/bob-esbuild.config.ts index eb2a2ba2a..94d1eb59e 100644 --- a/bob-esbuild.config.ts +++ b/bob-esbuild.config.ts @@ -8,6 +8,60 @@ export const config: import('bob-esbuild').BobConfig = { dirs: ['packages/*'], }, verbose: true, + manualRewritePackageJson: { + // Keep the expo/metro-friendly condition order when the package.json is + // rewritten for publication. Otherwise the generated manifest puts the ESM + // entry first and native apps pull the web bundle. + '@gqty/react': (pkg) => { + const normalizePath = (value?: string) => + value?.replace('./dist/', './'); + + const deriveNativePath = (value?: string) => + normalizePath(value)?.replace(/(\.m?js)$/u, '.native$1'); + + const preserveOrder = ( + entry: Record | string | undefined + ) => { + if (entry == null || typeof entry === 'string') return entry; + + const candidate = + (entry.require as string | undefined) ?? + (entry.import as string | undefined); + + const nativePath = deriveNativePath(candidate); + + const orderedEntries: [string, unknown][] = []; + + if (nativePath) orderedEntries.push(['react-native', nativePath]); + + for (const [key, value] of Object.entries(entry)) { + if (key === 'react-native') continue; + orderedEntries.push([key, value]); + } + + return Object.fromEntries(orderedEntries); + }; + + const rewrittenExports = + pkg.exports && + Object.fromEntries( + Object.entries(pkg.exports).map(([key, value]) => [ + key, + preserveOrder(value as any), + ]) + ); + + const nativeMain = deriveNativePath(pkg.main); + + return nativeMain || rewrittenExports + ? { + ...pkg, + ...(nativeMain ? { 'react-native': nativeMain } : null), + ...(rewrittenExports ? { exports: rewrittenExports } : null), + } + : pkg; + }, + }, esbuildPluginOptions: isCLIPackage ? { // Check https://github.com/evanw/esbuild/issues/1146 diff --git a/packages/react/README.md b/packages/react/README.md index 4112c3951..e3d854ba5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -28,3 +28,15 @@ documentations!** Documentation, bug reports, pull requests, and other contributions are welcomed! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for more information. + +## React Native + +- The React bindings ship platform-specific entry points so Metro can resolve + React Native friendly implementations automatically; no `react-dom` dependency + is required. +- `prepareReactRender` is a no-op on native targets. Server-rendering helpers + remain web-only, and hydration defaults to cache snapshots from client + renders. +- Effects that previously relied on browser visibility and online events now + listen to `AppState` changes to refetch when the app returns to the + foreground. diff --git a/packages/react/native-types/react-native.d.ts b/packages/react/native-types/react-native.d.ts new file mode 100644 index 000000000..cc1e6d0df --- /dev/null +++ b/packages/react/native-types/react-native.d.ts @@ -0,0 +1,11 @@ +declare module 'react-native' { + export type AppStateStatus = 'active' | 'background' | 'inactive' | 'unknown'; + + export const AppState: { + currentState: AppStateStatus; + addEventListener( + type: 'change', + listener: (status: AppStateStatus) => void + ): { remove(): void }; + }; +} diff --git a/packages/react/package.json b/packages/react/package.json index 3a07d274e..a7a06a780 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -26,17 +26,26 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" + "react-native": "./dist/index.native.js", + "require": "./dist/index.js", + "import": "./dist/index.mjs" }, - "./*": { - "types": "./dist/*.d.ts", - "import": "./dist/*.mjs", - "require": "./dist/*.js" + "./client": { + "types": "./dist/client.d.ts", + "react-native": "./dist/client.native.js", + "require": "./dist/client.js", + "import": "./dist/client.mjs" + }, + "./ssr/ssr": { + "types": "./dist/ssr/ssr.d.ts", + "react-native": "./dist/ssr/ssr.native.js", + "require": "./dist/ssr/ssr.js", + "import": "./dist/ssr/ssr.mjs" } }, "main": "dist/index.js", "module": "dist/index.mjs", + "react-native": "dist/index.native.js", "typings": "dist/index.d.ts", "files": [ "dist", @@ -82,7 +91,6 @@ ] }, "dependencies": { - "@react-hookz/web": "^23.1.0", "multidict": "^1.0.9", "p-debounce": "^4.0.0", "p-defer": "^3.0.0", @@ -135,6 +143,9 @@ }, "graphql-ws": { "optional": true + }, + "react-dom": { + "optional": true } }, "engines": { diff --git a/packages/react/src/client.native.ts b/packages/react/src/client.native.ts new file mode 100644 index 000000000..8bec99eec --- /dev/null +++ b/packages/react/src/client.native.ts @@ -0,0 +1,145 @@ +import { + $meta, + type BaseGeneratedSchema, + type GQtyClient, + type RetryOptions, +} from 'gqty'; +import { getActivePromises } from 'gqty/Cache/query'; +import type { LegacyFetchPolicy } from './common'; +import { createUseMetaState, type UseMetaState } from './meta/useMetaState'; +import { createUseMutation, type UseMutation } from './mutation/useMutation'; +import { createGraphqlHOC, type GraphQLHOC } from './query/hoc'; +import { createPrepareQuery, type PrepareQuery } from './query/preparedQuery'; +import { + createUseLazyQuery, + type LazyFetchPolicy, + type UseLazyQuery, +} from './query/useLazyQuery'; +import { + createUsePaginatedQuery, + type PaginatedQueryFetchPolicy, + type UsePaginatedQuery, +} from './query/usePaginatedQuery'; +import { createUseQuery, type UseQuery } from './query/useQuery'; +import { createUseRefetch, type UseRefetch } from './query/useRefetch'; +import { + createUseTransactionQuery, + type UseTransactionQuery, +} from './query/useTransactionQuery'; +import { + createSSRHelpers, + type PrepareReactRender, + type UseHydrateCache, +} from './ssr/ssr.native'; +import { + createUseSubscription, + type UseSubscription, +} from './subscription/useSubscription'; +import type { ReactClientOptionsWithDefaults } from './utils'; + +export interface ReactClientDefaults { + initialLoadingState?: boolean; + suspense?: boolean; + lazyQuerySuspense?: boolean; + transactionQuerySuspense?: boolean; + mutationSuspense?: boolean; + preparedSuspense?: boolean; + paginatedQuerySuspense?: boolean; + transactionFetchPolicy?: LegacyFetchPolicy; + lazyFetchPolicy?: LazyFetchPolicy; + paginatedQueryFetchPolicy?: PaginatedQueryFetchPolicy; + staleWhileRevalidate?: boolean; + retry?: RetryOptions; + refetchAfterHydrate?: boolean; +} + +export interface CreateReactClientOptions { + defaults?: ReactClientDefaults; +} + +export interface ReactClient { + useQuery: UseQuery; + useRefetch: UseRefetch; + useLazyQuery: UseLazyQuery; + useTransactionQuery: UseTransactionQuery; + usePaginatedQuery: UsePaginatedQuery; + useMutation: UseMutation; + graphql: GraphQLHOC; + state: { isLoading: boolean }; + prepareReactRender: PrepareReactRender; + useHydrateCache: UseHydrateCache; + useMetaState: UseMetaState; + useSubscription: UseSubscription; + prepareQuery: PrepareQuery; +} + +export function createReactClient( + client: GQtyClient, + { + defaults: { suspense = false } = {}, + defaults: { + initialLoadingState = false, + transactionFetchPolicy = 'cache-first', + lazyFetchPolicy = 'network-only', + staleWhileRevalidate = false, + retry = true, + lazyQuerySuspense = false, + transactionQuerySuspense = suspense, + mutationSuspense = false, + preparedSuspense = suspense, + refetchAfterHydrate = false, + paginatedQueryFetchPolicy = 'cache-first', + paginatedQuerySuspense = suspense, + } = {}, + ...options + }: CreateReactClientOptions = {} +): ReactClient { + const opts: ReactClientOptionsWithDefaults = { + ...options, + defaults: { + initialLoadingState, + lazyFetchPolicy, + lazyQuerySuspense, + mutationSuspense, + paginatedQueryFetchPolicy, + paginatedQuerySuspense, + preparedSuspense, + refetchAfterHydrate, + retry, + staleWhileRevalidate, + suspense, + transactionFetchPolicy, + transactionQuerySuspense, + }, + }; + + const { prepareReactRender, useHydrateCache } = createSSRHelpers( + client, + opts + ); + + const useQuery = createUseQuery(client, opts); + + return { + useQuery, + useRefetch: createUseRefetch(client, opts), + useLazyQuery: createUseLazyQuery(client, opts), + useTransactionQuery: createUseTransactionQuery(useQuery, opts), + usePaginatedQuery: createUsePaginatedQuery(client, opts), + useMutation: createUseMutation(client, opts), + graphql: createGraphqlHOC(client, opts), + state: { + get isLoading() { + const cache = $meta(client.schema.query)?.context.cache; + const promises = cache && getActivePromises(cache); + + return !!promises?.length; + }, + }, + prepareReactRender, + useHydrateCache, + useMetaState: createUseMetaState(), + useSubscription: createUseSubscription(client), + prepareQuery: createPrepareQuery(client, opts), + }; +} diff --git a/packages/react/src/common.ts b/packages/react/src/common.ts index 712a91a4b..5e1f4e2d7 100644 --- a/packages/react/src/common.ts +++ b/packages/react/src/common.ts @@ -12,7 +12,9 @@ import { } from 'gqty'; import * as React from 'react'; -export const IS_BROWSER = typeof window !== 'undefined'; +export const IS_BROWSER = + typeof window !== 'undefined' || + globalThis.navigator?.product === 'ReactNative'; export const useIsomorphicLayoutEffect = IS_BROWSER ? React.useLayoutEffect diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts new file mode 100644 index 000000000..5155e6141 --- /dev/null +++ b/packages/react/src/hooks.ts @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { DependencyList, EffectCallback } from 'react'; + +export function useRerender() { + const [, setState] = useState(0); + + return useCallback(() => { + setState((value) => (value + 1) % Number.MAX_SAFE_INTEGER); + }, []); +} + +export function useSyncedRef(value: T) { + const ref = useRef(value); + ref.current = value; + + return useMemo( + () => + Object.freeze({ + get current() { + return ref.current; + }, + }), + [] + ); +} + +export function useUnmountEffect(effect: () => void) { + const effectRef = useSyncedRef(effect); + + useEffect( + () => () => { + effectRef.current(); + }, + [] + ); +} + +export function useThrottledCallback any>( + callback: T, + deps: DependencyList, + delay: number, + noTrailing = false +) { + const timeout = useRef>(); + const lastCall = useRef<{ + args: Parameters; + this: ThisParameterType; + }>(); + + useUnmountEffect(() => { + if (timeout.current !== undefined) { + clearTimeout(timeout.current); + timeout.current = undefined; + } + }); + + return useMemo(() => { + const execute = (context: ThisParameterType, args: Parameters) => { + lastCall.current = undefined; + callback.apply(context, args); + timeout.current = setTimeout(() => { + timeout.current = undefined; + + if (!noTrailing && lastCall.current) { + execute(lastCall.current.this, lastCall.current.args); + } + }, delay); + }; + + const wrapped = function ( + this: ThisParameterType, + ...args: Parameters + ) { + if (timeout.current !== undefined) { + lastCall.current = { args, this: this }; + return; + } + + execute(this, args); + } as T; + + Object.defineProperties(wrapped, { + length: { value: callback.length }, + name: { value: `${callback.name || 'anonymous'}__throttled__${delay}` }, + }); + + return wrapped; + }, [callback, delay, noTrailing, ...deps]); +} + +export function useIntervalEffect(callback: () => void, ms?: number) { + const cbRef = useSyncedRef(callback); + + useEffect(() => { + if (!ms && ms !== 0) return; + + const id = setInterval(() => { + cbRef.current(); + }, ms); + + return () => { + clearInterval(id); + }; + }, [ms]); +} + +export function usePrevious(value: T) { + const prev = useRef(); + + useEffect(() => { + prev.current = value; + }); + + return prev.current; +} + +export function useUpdateEffect(effect: EffectCallback, deps?: DependencyList) { + const isFirstMount = useRef(true); + + useEffect(() => { + if (isFirstMount.current) { + isFirstMount.current = false; + return; + } + + return effect(); + }, deps); +} diff --git a/packages/react/src/index.native.tsx b/packages/react/src/index.native.tsx new file mode 100644 index 000000000..a416d94ae --- /dev/null +++ b/packages/react/src/index.native.tsx @@ -0,0 +1,52 @@ +export * from './client.native'; +export { coreHelpers, sortBy, uniqBy } from './common'; +export type { LegacyFetchPolicy, OnErrorHandler } from './common'; +export type { + MetaState, + UseMetaState, + UseMetaStateOptions, +} from './meta/useMetaState'; +export type { + UseMutation, + UseMutationOptions, + UseMutationState, +} from './mutation/useMutation'; +export type { GraphQLHOC, GraphQLHOCOptions } from './query/hoc'; +export type { + PrepareQuery, + PreparedQuery, + UsePreparedQueryOptions, +} from './query/preparedQuery'; +export type { + LazyFetchPolicy, + UseLazyQuery, + UseLazyQueryOptions, + UseLazyQueryState, +} from './query/useLazyQuery'; +export type { + FetchMoreCallbackArgs, + PaginatedQueryFetchPolicy, + UsePaginatedQuery, + UsePaginatedQueryData, + UsePaginatedQueryMergeParams, + UsePaginatedQueryOptions, +} from './query/usePaginatedQuery'; +export type { + UseQuery, + UseQueryOptions, + UseQueryReturnValue, + UseQueryState, +} from './query/useQuery'; +export type { UseRefetch, UseRefetchOptions } from './query/useRefetch'; +export type { + UseTransactionQuery, + UseTransactionQueryOptions, + UseTransactionQueryState, +} from './query/useTransactionQuery'; +export type { + PrepareReactRender, + PropsWithServerCache, + UseHydrateCache, + UseHydrateCacheOptions, +} from './ssr/ssr.native'; +export type { UseSubscription } from './subscription/useSubscription'; diff --git a/packages/react/src/meta/useMetaState.ts b/packages/react/src/meta/useMetaState.ts index 581054b3d..1157e528c 100644 --- a/packages/react/src/meta/useMetaState.ts +++ b/packages/react/src/meta/useMetaState.ts @@ -1,4 +1,4 @@ -import { useRerender } from '@react-hookz/web'; +import { useRerender } from '../hooks'; import { GQtyError, Selection, useMetaStateHack } from 'gqty'; import * as React from 'react'; import { useExtractedSelections, type SelectionsOrProxy } from '../common'; diff --git a/packages/react/src/query/hoc.tsx b/packages/react/src/query/hoc.tsx index fe87a7d10..80c526ecd 100644 --- a/packages/react/src/query/hoc.tsx +++ b/packages/react/src/query/hoc.tsx @@ -1,4 +1,4 @@ -import { useRerender } from '@react-hookz/web'; +import { useRerender } from '../hooks'; import type { BaseGeneratedSchema, GQtyClient } from 'gqty'; import * as React from 'react'; import type { OnErrorHandler } from '../common'; diff --git a/packages/react/src/query/useQuery.ts b/packages/react/src/query/useQuery.ts index a72a8f2f2..a31a85d30 100644 --- a/packages/react/src/query/useQuery.ts +++ b/packages/react/src/query/useQuery.ts @@ -3,7 +3,7 @@ import { usePrevious, useRerender, useUpdateEffect, -} from '@react-hookz/web'; +} from '../hooks'; import { GQtyError, prepass, diff --git a/packages/react/src/query/useTransactionQuery.ts b/packages/react/src/query/useTransactionQuery.ts index 7890a28ff..a006d59b9 100644 --- a/packages/react/src/query/useTransactionQuery.ts +++ b/packages/react/src/query/useTransactionQuery.ts @@ -1,4 +1,4 @@ -import { useUpdateEffect } from '@react-hookz/web'; +import { useUpdateEffect } from '../hooks'; import { type BaseGeneratedSchema, type GQtyError, diff --git a/packages/react/src/ssr/ssr.native.ts b/packages/react/src/ssr/ssr.native.ts new file mode 100644 index 000000000..4f0388d54 --- /dev/null +++ b/packages/react/src/ssr/ssr.native.ts @@ -0,0 +1,86 @@ +import { + type BaseGeneratedSchema, + type GQtyClient, + type LegacyHydrateCacheOptions, +} from 'gqty'; +import { type ReactNode, useEffect, useMemo } from 'react'; +import type { ReactClientOptionsWithDefaults } from '../utils'; + +export interface UseHydrateCacheOptions + extends Partial { + /** + * Cache snapshot, returned from `prepareReactRender` + */ + cacheSnapshot: string | undefined; + /** + * If it should refetch everything after the component is mounted + * + * @default + * false + */ + shouldRefetch?: boolean; +} + +/** + * Props with `cacheSnapshot` that would be returned from `prepareReactRender` + */ +export type PropsWithServerCache< + T extends Record = Record, +> = { + /** + * Cache snapshot, returned from `prepareReactRender` + */ + cacheSnapshot?: string; +} & T; + +export interface UseHydrateCache { + ({ cacheSnapshot, shouldRefetch }: UseHydrateCacheOptions): void; +} + +export interface PrepareReactRender { + (element: ReactNode): Promise<{ + cacheSnapshot: string; + }>; +} + +const IS_SERVER = false; +let hasWarnedAboutSSR = false; + +export function createSSRHelpers( + { hydrateCache, prepareRender, query, refetch }: GQtyClient, + { defaults: { refetchAfterHydrate } }: ReactClientOptionsWithDefaults +) { + const prepareReactRender: PrepareReactRender = + async function prepareReactRenderRN(_element: ReactNode) { + if (!hasWarnedAboutSSR && process.env.NODE_ENV !== 'production') { + hasWarnedAboutSSR = true; + console.warn( + '[GQty] prepareReactRender is not supported on React Native environments.' + ); + } + + return prepareRender(async () => {}); + }; + + const useHydrateCache: UseHydrateCache = function useHydrateCache({ + cacheSnapshot, + shouldRefetch = refetchAfterHydrate, + }: UseHydrateCacheOptions) { + useMemo(() => { + if (!IS_SERVER && cacheSnapshot) { + hydrateCache({ cacheSnapshot, shouldRefetch: false }); + } + }, [cacheSnapshot]); + + useEffect(() => { + if (!IS_SERVER && shouldRefetch) { + refetch(query).catch(console.error); + } + }, [shouldRefetch]); + }; + + return { + useHydrateCache, + prepareReactRender, + }; +} diff --git a/packages/react/src/ssr/ssr.ts b/packages/react/src/ssr/ssr.ts index df6c95b10..8f24eaad3 100644 --- a/packages/react/src/ssr/ssr.ts +++ b/packages/react/src/ssr/ssr.ts @@ -44,7 +44,9 @@ export interface PrepareReactRender { }>; } -const IS_SERVER = typeof window === 'undefined'; +const IS_SERVER = + typeof window === 'undefined' && + globalThis.navigator?.product !== 'ReactNative'; export function createSSRHelpers( { hydrateCache, prepareRender, query, refetch }: GQtyClient, diff --git a/packages/react/src/subscription/useSubscription.ts b/packages/react/src/subscription/useSubscription.ts index 07b669cf1..c09e24532 100644 --- a/packages/react/src/subscription/useSubscription.ts +++ b/packages/react/src/subscription/useSubscription.ts @@ -1,4 +1,4 @@ -import { useRerender, useThrottledCallback } from '@react-hookz/web'; +import { useRerender, useThrottledCallback } from '../hooks'; import { GQtyError, type BaseGeneratedSchema, type GQtyClient } from 'gqty'; import { useEffect, useMemo, useState } from 'react'; diff --git a/packages/react/src/useOnlineEffect.native.ts b/packages/react/src/useOnlineEffect.native.ts new file mode 100644 index 000000000..a981903a5 --- /dev/null +++ b/packages/react/src/useOnlineEffect.native.ts @@ -0,0 +1,28 @@ +import { useEffect, type DependencyList } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; + +export const useOnlineEffect = ( + fn: (...args: unknown[]) => unknown, + deps?: DependencyList +) => { + useEffect(() => { + let previousState: AppStateStatus = AppState.currentState; + + const handleChange = (nextState: AppStateStatus) => { + if ( + previousState.match(/inactive|background/) && + nextState === 'active' + ) { + fn(); + } + + previousState = nextState; + }; + + const subscription = AppState.addEventListener('change', handleChange); + + return () => { + subscription.remove(); + }; + }, deps); +}; diff --git a/packages/react/src/useThrottledAsync.ts b/packages/react/src/useThrottledAsync.ts index 1a17eb6bf..e887adcef 100644 --- a/packages/react/src/useThrottledAsync.ts +++ b/packages/react/src/useThrottledAsync.ts @@ -1,4 +1,4 @@ -import { useSyncedRef } from '@react-hookz/web'; +import { useSyncedRef } from './hooks'; import { useMemo, useRef, useState } from 'react'; export type AsyncStatus = 'loading' | 'success' | 'error' | 'not-executed'; diff --git a/packages/react/src/useWindowFocusEffect.native.ts b/packages/react/src/useWindowFocusEffect.native.ts new file mode 100644 index 000000000..b7b6c53de --- /dev/null +++ b/packages/react/src/useWindowFocusEffect.native.ts @@ -0,0 +1,38 @@ +import { useEffect, type DependencyList } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; + +export type UseFocusChangeEffectOptions = { + enabled?: boolean; +}; + +export const useWindowFocusEffect = ( + fn: (...args: unknown[]) => unknown, + deps: DependencyList = [], + { enabled = true }: UseFocusChangeEffectOptions = {} +) => { + useEffect( + () => { + if (!enabled) return; + + let previousState: AppStateStatus = AppState.currentState; + + const onChange = (nextState: AppStateStatus) => { + if ( + previousState.match(/inactive|background/) && + nextState === 'active' + ) { + fn(); + } + + previousState = nextState; + }; + + const subscription = AppState.addEventListener('change', onChange); + + return () => { + subscription.remove(); + }; + }, + deps.concat(fn, enabled) + ); +}; diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 4082f16a5..800b8b885 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "../../tsconfig.json" + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["./native-types/react-native"] + }, + "include": ["src", "native-types"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5516ae97..211fa0eed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -652,9 +652,6 @@ importers: packages/react: dependencies: - '@react-hookz/web': - specifier: ^23.1.0 - version: 23.1.0(js-cookie@3.0.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) multidict: specifier: ^1.0.9 version: 1.0.9 @@ -3157,16 +3154,6 @@ packages: js-cookie: optional: true - '@react-hookz/web@23.1.0': - resolution: {integrity: sha512-fvbURdsa1ukttbLR1ASE/XmqXP09vZ1PiCYppYeR1sNMzCrdkG0iBnjxniFSVjJ8gIw2fRs6nqMTbeBz2uAkuA==} - peerDependencies: - js-cookie: ^3.0.5 - react: ^16.8 || ^17 || ^18 - react-dom: ^16.8 || ^17 || ^18 - peerDependenciesMeta: - js-cookie: - optional: true - '@react-hookz/web@24.0.4': resolution: {integrity: sha512-DcIM6JiZklDyHF6CRD1FTXzuggAkQ+3Ncq2Wln7Kdih8GV6ZIeN9JfS6ZaQxpQUxan8/4n0J2V/R7nMeiSrb2Q==} engines: {node: '>=18.0.0'} @@ -10906,14 +10893,6 @@ snapshots: optionalDependencies: js-cookie: 3.0.5 - '@react-hookz/web@23.1.0(js-cookie@3.0.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@react-hookz/deep-equal': 1.0.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - js-cookie: 3.0.5 - '@react-hookz/web@24.0.4(js-cookie@3.0.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@react-hookz/deep-equal': 1.0.4