diff --git a/packages/jsapi-bootstrap/src/useApi.test.ts b/packages/jsapi-bootstrap/src/useApi.test.ts new file mode 100644 index 0000000000..289a0e700f --- /dev/null +++ b/packages/jsapi-bootstrap/src/useApi.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react-hooks'; +import dh from '@deephaven/jsapi-shim'; +import { useContext } from 'react'; +import { TestUtils } from '@deephaven/utils'; +import { useApi } from './useApi'; + +const { asMock } = TestUtils; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); + + asMock(useContext).mockName('useContext'); +}); + +describe('useApi', () => { + it('should return API context value', () => { + asMock(useContext).mockReturnValue(dh); + + const { result } = renderHook(() => useApi()); + expect(result.current).toBe(dh); + }); + + it('should throw if context is null', () => { + asMock(useContext).mockReturnValue(null); + + const { result } = renderHook(() => useApi()); + expect(result.error).toEqual( + new Error( + 'No API available in useApi. Was code wrapped in ApiBootstrap or ApiContext.Provider?' + ) + ); + }); +}); diff --git a/packages/jsapi-bootstrap/src/useApi.ts b/packages/jsapi-bootstrap/src/useApi.ts index 665e1cb51f..b17e7f431c 100644 --- a/packages/jsapi-bootstrap/src/useApi.ts +++ b/packages/jsapi-bootstrap/src/useApi.ts @@ -2,6 +2,10 @@ import type { dh as DhType } from '@deephaven/jsapi-types'; import { useContextOrThrow } from '@deephaven/react-hooks'; import { ApiContext } from './ApiBootstrap'; +/** + * Retrieve the API for the current context. + * @returns The API instance from the nearest ApiContext.Provider, or throws if none is set + */ export function useApi(): DhType { return useContextOrThrow( ApiContext, diff --git a/packages/jsapi-bootstrap/src/useDeferredApi.test.ts b/packages/jsapi-bootstrap/src/useDeferredApi.test.ts new file mode 100644 index 0000000000..6bdd72b4a3 --- /dev/null +++ b/packages/jsapi-bootstrap/src/useDeferredApi.test.ts @@ -0,0 +1,69 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { useContext } from 'react'; +import { TestUtils } from '@deephaven/utils'; +import { DeferredApiOptions, useDeferredApi } from './useDeferredApi'; + +const { asMock, createMockProxy, flushPromises } = TestUtils; + +const dh1 = createMockProxy(); +const dh2 = createMockProxy(); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + asMock(useContext).mockName('useContext'); +}); + +describe('useApi', () => { + it('should return API directly if a value is provided from useContext, whatever the options are', () => { + asMock(useContext).mockReturnValue(dh1); + + const { result } = renderHook(() => useDeferredApi()); + expect(result.current).toEqual([dh1, null]); + + const { result: result2 } = renderHook(() => + useDeferredApi({ foo: 'bar' }) + ); + expect(result2.current).toEqual([dh1, null]); + }); + + it('should resolve to the API value when it is provided from the function', async () => { + asMock(useContext).mockReturnValue(async (options?: DeferredApiOptions) => { + switch (options?.id) { + case '1': + return dh1; + case '2': + return dh2; + default: + throw new Error('Invalid id'); + } + }); + + const { rerender, result } = renderHook( + (options?: DeferredApiOptions) => useDeferredApi(options), + { initialProps: { id: '1' } } + ); + await act(flushPromises); + expect(result.current).toEqual([dh1, null]); + + rerender({ id: '2' }); + await act(flushPromises); + expect(result.current).toEqual([dh2, null]); + + rerender({ id: '3' }); + await act(flushPromises); + expect(result.current).toEqual([null, expect.any(Error)]); + }); + + it('returns an error if the context is null', async () => { + asMock(useContext).mockReturnValue(null); + + const { result } = renderHook(() => useDeferredApi()); + expect(result.current).toEqual([null, expect.any(Error)]); + }); +}); diff --git a/packages/jsapi-bootstrap/src/useDeferredApi.ts b/packages/jsapi-bootstrap/src/useDeferredApi.ts new file mode 100644 index 0000000000..0d1a8f4346 --- /dev/null +++ b/packages/jsapi-bootstrap/src/useDeferredApi.ts @@ -0,0 +1,75 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import type { dh as DhType } from '@deephaven/jsapi-types'; +import { ApiContext } from './ApiBootstrap'; + +/** Options for retrieving the deferred */ +export type DeferredApiOptions = Record; + +export type DeferredApiFetcher = ( + options?: DeferredApiOptions +) => Promise; + +export const DeferredApiContext = createContext< + DhType | DeferredApiFetcher | null +>(null); + +/** + * Retrieve the API for the current context, given the metadata provided. + * The API may need to be loaded, and will return `null` until it is ready. + * @returns A tuple with the API instance, and an error if one occurred. + */ +export function useDeferredApi( + options?: Record +): [DhType | null, unknown | null] { + const [api, setApi] = useState(null); + const [error, setError] = useState(null); + const deferredApi = useContext(DeferredApiContext); + const contextApi = useContext(ApiContext); + + useEffect(() => { + if (deferredApi == null) { + if (contextApi != null) { + setApi(contextApi); + setError(null); + return; + } + setApi(null); + setError( + new Error( + 'No API available in useDeferredApi. Was code wrapped in ApiBootstrap or DeferredApiContext.Provider?' + ) + ); + return; + } + let isCancelled = false; + + async function loadApi() { + if (typeof deferredApi === 'function') { + try { + const newApi = await deferredApi(options); + if (!isCancelled) { + setApi(newApi); + setError(null); + } + } catch (e) { + if (!isCancelled) { + setApi(null); + setError(e); + } + } + } else { + setApi(deferredApi); + } + } + + loadApi(); + + return () => { + isCancelled = true; + }; + }, [contextApi, deferredApi, options]); + + return [api, error]; +} + +export default useDeferredApi;