From ca6c2a89914326fe985111e375b001b684a7cf5e Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 23 May 2024 15:22:33 -0400 Subject: [PATCH 1/8] feat: Add an ObjectFetchManager and useObjectFetch - Just allows for subscribing to an object, which will allow refetching when necessary --- .../src/components/ConnectionBootstrap.tsx | 27 ++++++- packages/jsapi-bootstrap/src/index.ts | 1 + .../src/useObjectFetch.test.ts | 48 +++++++++++ .../jsapi-bootstrap/src/useObjectFetch.ts | 80 +++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 packages/jsapi-bootstrap/src/useObjectFetch.test.ts create mode 100644 packages/jsapi-bootstrap/src/useObjectFetch.ts diff --git a/packages/app-utils/src/components/ConnectionBootstrap.tsx b/packages/app-utils/src/components/ConnectionBootstrap.tsx index c841a6debe..1566f1c36c 100644 --- a/packages/app-utils/src/components/ConnectionBootstrap.tsx +++ b/packages/app-utils/src/components/ConnectionBootstrap.tsx @@ -1,7 +1,9 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { LoadingOverlay } from '@deephaven/components'; import { ObjectFetcherContext, + ObjectFetchManager, + ObjectFetchManagerContext, sanitizeVariableDescriptor, useApi, useClient, @@ -31,6 +33,7 @@ export function ConnectionBootstrap({ const client = useClient(); const [error, setError] = useState(); const [connection, setConnection] = useState(); + useEffect( function initConnection() { let isCanceled = false; @@ -83,6 +86,24 @@ export function ConnectionBootstrap({ [connection] ); + /** We don't really need to do anything fancy in Core to manage an object, just fetch it */ + const objectManager: ObjectFetchManager = useMemo( + () => ({ + subscribe: (descriptor, onUpdate) => { + // We send an update with the fetch right away + onUpdate({ + fetch: () => objectFetcher(descriptor), + error: null, + }); + return () => { + // no-op + // For Core, if the server dies then we can't reconnect anyway, so no need to bother listening for subscription or cleaning up + }; + }, + }), + [objectFetcher] + ); + if (connection == null || error != null) { return ( - {children} + + {children} + ); diff --git a/packages/jsapi-bootstrap/src/index.ts b/packages/jsapi-bootstrap/src/index.ts index 23ce282865..a435367cc0 100644 --- a/packages/jsapi-bootstrap/src/index.ts +++ b/packages/jsapi-bootstrap/src/index.ts @@ -4,4 +4,5 @@ export * from './DeferredApiBootstrap'; export * from './useApi'; export * from './useClient'; export * from './useDeferredApi'; +export * from './useObjectFetch'; export * from './useObjectFetcher'; diff --git a/packages/jsapi-bootstrap/src/useObjectFetch.test.ts b/packages/jsapi-bootstrap/src/useObjectFetch.test.ts new file mode 100644 index 0000000000..1a80c0c59f --- /dev/null +++ b/packages/jsapi-bootstrap/src/useObjectFetch.test.ts @@ -0,0 +1,48 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useContext } from 'react'; +import { TestUtils } from '@deephaven/utils'; +import { useObjectFetch } from './useObjectFetch'; + +const { asMock, flushPromises } = TestUtils; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + asMock(useContext).mockName('useContext'); +}); + +it('should resolve the objectFetch when in the context', async () => { + const objectFetch = jest.fn(async () => undefined); + const unsubscribe = jest.fn(); + const descriptor = { type: 'type', name: 'name' }; + const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { + expect(descriptor).toEqual(subscribeDescriptor); + onUpdate({ fetch: objectFetch, error: null }); + return unsubscribe; + }); + const objectManager = { subscribe }; + asMock(useContext).mockReturnValue(objectManager); + + const { result } = renderHook(() => useObjectFetch(descriptor)); + await act(flushPromises); + expect(result.current).toEqual({ fetch: objectFetch, error: null }); + expect(result.error).toBeUndefined(); + expect(objectFetch).not.toHaveBeenCalled(); +}); + +it('should return an error if objectFetch not available in the context', async () => { + const descriptor = { type: 'type', name: 'name' }; + asMock(useContext).mockReturnValue(null); + + const { result } = renderHook(() => useObjectFetch(descriptor)); + await act(flushPromises); + expect(result.current).toEqual({ + fetch: null, + error: expect.any(Error), + }); + expect(result.error).toBeUndefined(); +}); diff --git a/packages/jsapi-bootstrap/src/useObjectFetch.ts b/packages/jsapi-bootstrap/src/useObjectFetch.ts new file mode 100644 index 0000000000..ada546a2f1 --- /dev/null +++ b/packages/jsapi-bootstrap/src/useObjectFetch.ts @@ -0,0 +1,80 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import type { dh } from '@deephaven/jsapi-types'; + +/** Function for unsubscribing from a given subscription */ +export type UnsubscribeFunction = () => void; + +/** + * Update with the current `fetch` fuonction and status of the object. + * - If both `fetch` and `error` are `null`, it is still loading the fetcher + * - If `fetch` is not `null`, the object is ready to be fetched + * - If `error` is not `null`, there was an error loading the object + */ +export type ObjectFetchUpdate = { + /** + * Function to fetch the object. If `null`, the object is still loading or there was an error. + */ + fetch: (() => Promise) | null; + + /** + * Error that occurred while fetching the object. If `null`, there was no error. + * Will automatically retry when possible while the subscribed. + */ + error: unknown | null; +}; + +/** ObjectFetchManager for managing a subscription to an object using a VariableDescriptor */ +export type ObjectFetchManager = { + /** + * + * @param descriptor Descriptor object to fetch the object from. Can be extended by a specific implementation to include more details necessary for the ObjectManager. + * @param onUpdate Callback function to be called when the object is updated. + * @returns An unsubscribe function to stop listening for updates and clean up the object. + */ + subscribe: ( + descriptor: dh.ide.VariableDescriptor, + onUpdate: (update: ObjectFetchUpdate) => void + ) => UnsubscribeFunction; +}; + +/** Context for tracking an implementation of the ObjectFetchManager. */ +export const ObjectFetchManagerContext = + createContext(null); + +/** + * Retrieve a `fetch` function for the given variable descriptor. + * + * @param descriptor Descriptor to get the `fetch` function for + * @returns An object with the current `fetch` function, OR an error status set if there was an issue fetching the object. + * Retrying is left up to the ObjectManager implementation used from this context. + */ +export function useObjectFetch( + descriptor: dh.ide.VariableDescriptor +): ObjectFetchUpdate { + const [currentUpdate, setCurrentUpdate] = useState>( + () => ({ + fetch: null, + error: null, + }) + ); + + const objectFetchManager = useContext(ObjectFetchManagerContext); + + useEffect(() => { + if (objectFetchManager == null) { + setCurrentUpdate({ + fetch: null, + error: new Error('No ObjectFetchManager available in context'), + }); + return; + } + // Signal that we're still loading + setCurrentUpdate({ + fetch: null, + error: null, + }); + return objectFetchManager.subscribe(descriptor, setCurrentUpdate); + }, [descriptor, objectFetchManager]); + + return currentUpdate; +} From 33b87f5e4d815baf052ff46f8a04532bf9768ffa Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 24 May 2024 09:03:06 -0400 Subject: [PATCH 2/8] useWidget hook and tests - Cleaned up the useObjectFetch tests as well - Still not using the useWidget hook, will use from deephaven-plugin-ui --- package-lock.json | 4 +- packages/jsapi-bootstrap/package.json | 3 +- packages/jsapi-bootstrap/src/index.ts | 1 + ...tFetch.test.ts => useObjectFetch.test.tsx} | 35 +++----- .../jsapi-bootstrap/src/useWidget.test.tsx | 68 ++++++++++++++ packages/jsapi-bootstrap/src/useWidget.ts | 90 +++++++++++++++++++ packages/jsapi-bootstrap/tsconfig.json | 3 +- 7 files changed, 177 insertions(+), 27 deletions(-) rename packages/jsapi-bootstrap/src/{useObjectFetch.test.ts => useObjectFetch.test.tsx} (54%) create mode 100644 packages/jsapi-bootstrap/src/useWidget.test.tsx create mode 100644 packages/jsapi-bootstrap/src/useWidget.ts diff --git a/package-lock.json b/package-lock.json index 88e426698c..2e39738859 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29602,7 +29602,8 @@ "@deephaven/components": "file:../components", "@deephaven/jsapi-types": "1.0.0-dev0.34.0", "@deephaven/log": "file:../log", - "@deephaven/react-hooks": "file:../react-hooks" + "@deephaven/react-hooks": "file:../react-hooks", + "@deephaven/utils": "file:../utils" }, "devDependencies": { "react": "^17.x" @@ -31828,6 +31829,7 @@ "@deephaven/jsapi-types": "1.0.0-dev0.34.0", "@deephaven/log": "file:../log", "@deephaven/react-hooks": "file:../react-hooks", + "@deephaven/utils": "file:../utils", "react": "^17.x" } }, diff --git a/packages/jsapi-bootstrap/package.json b/packages/jsapi-bootstrap/package.json index 52924f7cac..fd2117d67d 100644 --- a/packages/jsapi-bootstrap/package.json +++ b/packages/jsapi-bootstrap/package.json @@ -25,7 +25,8 @@ "@deephaven/components": "file:../components", "@deephaven/jsapi-types": "1.0.0-dev0.34.0", "@deephaven/log": "file:../log", - "@deephaven/react-hooks": "file:../react-hooks" + "@deephaven/react-hooks": "file:../react-hooks", + "@deephaven/utils": "file:../utils" }, "devDependencies": { "react": "^17.x" diff --git a/packages/jsapi-bootstrap/src/index.ts b/packages/jsapi-bootstrap/src/index.ts index a435367cc0..a016f8a2bf 100644 --- a/packages/jsapi-bootstrap/src/index.ts +++ b/packages/jsapi-bootstrap/src/index.ts @@ -6,3 +6,4 @@ export * from './useClient'; export * from './useDeferredApi'; export * from './useObjectFetch'; export * from './useObjectFetcher'; +export * from './useWidget'; diff --git a/packages/jsapi-bootstrap/src/useObjectFetch.test.ts b/packages/jsapi-bootstrap/src/useObjectFetch.test.tsx similarity index 54% rename from packages/jsapi-bootstrap/src/useObjectFetch.test.ts rename to packages/jsapi-bootstrap/src/useObjectFetch.test.tsx index 1a80c0c59f..4c11e6e468 100644 --- a/packages/jsapi-bootstrap/src/useObjectFetch.test.ts +++ b/packages/jsapi-bootstrap/src/useObjectFetch.test.tsx @@ -1,45 +1,32 @@ -import { act, renderHook } from '@testing-library/react-hooks'; -import { useContext } from 'react'; -import { TestUtils } from '@deephaven/utils'; -import { useObjectFetch } from './useObjectFetch'; - -const { asMock, flushPromises } = TestUtils; - -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: jest.fn(), -})); - -beforeEach(() => { - jest.clearAllMocks(); - asMock(useContext).mockName('useContext'); -}); +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { ObjectFetchManagerContext, useObjectFetch } from './useObjectFetch'; it('should resolve the objectFetch when in the context', async () => { const objectFetch = jest.fn(async () => undefined); const unsubscribe = jest.fn(); const descriptor = { type: 'type', name: 'name' }; const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { - expect(descriptor).toEqual(subscribeDescriptor); + expect(subscribeDescriptor).toEqual(descriptor); onUpdate({ fetch: objectFetch, error: null }); return unsubscribe; }); const objectManager = { subscribe }; - asMock(useContext).mockReturnValue(objectManager); + const wrapper = ({ children }) => ( + + {children} + + ); - const { result } = renderHook(() => useObjectFetch(descriptor)); - await act(flushPromises); + const { result } = renderHook(() => useObjectFetch(descriptor), { wrapper }); expect(result.current).toEqual({ fetch: objectFetch, error: null }); expect(result.error).toBeUndefined(); expect(objectFetch).not.toHaveBeenCalled(); }); -it('should return an error if objectFetch not available in the context', async () => { +it('should return an error, not throw if objectFetch not available in the context', async () => { const descriptor = { type: 'type', name: 'name' }; - asMock(useContext).mockReturnValue(null); - const { result } = renderHook(() => useObjectFetch(descriptor)); - await act(flushPromises); expect(result.current).toEqual({ fetch: null, error: expect.any(Error), diff --git a/packages/jsapi-bootstrap/src/useWidget.test.tsx b/packages/jsapi-bootstrap/src/useWidget.test.tsx new file mode 100644 index 0000000000..5e64dbccbd --- /dev/null +++ b/packages/jsapi-bootstrap/src/useWidget.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestUtils } from '@deephaven/utils'; +import { useWidget } from './useWidget'; +import { ObjectFetchManagerContext } from './useObjectFetch'; + +describe('useWidget', () => { + it('should return a widget when available', async () => { + const descriptor = { type: 'type', name: 'name' }; + const widget = { close: jest.fn() }; + const fetch = jest.fn(async () => widget); + const objectFetch = { fetch, error: null }; + const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { + expect(subscribeDescriptor).toEqual(descriptor); + onUpdate(objectFetch); + return jest.fn(); + }); + const objectManager = { subscribe }; + const wrapper = ({ children }) => ( + + {children} + + ); + const { result } = renderHook(() => useWidget(descriptor), { wrapper }); + await act(TestUtils.flushPromises); + expect(result.current).toEqual({ widget, error: null }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should return an error when an error occurs', () => { + const descriptor = { type: 'type', name: 'name' }; + const error = new Error('Error fetching widget'); + const objectFetch = { fetch: null, error }; + const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { + expect(subscribeDescriptor).toEqual(descriptor); + onUpdate(objectFetch); + return jest.fn(); + }); + const objectManager = { subscribe }; + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useWidget(descriptor), { wrapper }); + + expect(result.current).toEqual({ widget: null, error }); + }); + + it('should return null when still loading', () => { + const descriptor = { type: 'type', name: 'name' }; + const objectFetch = { fetch: null, error: null }; + const subscribe = jest.fn((_, onUpdate) => { + onUpdate(objectFetch); + return jest.fn(); + }); + const objectManager = { subscribe }; + const wrapper = ({ children }) => ( + + {children} + + ); + const { result } = renderHook(() => useWidget(descriptor), { wrapper }); + + expect(result.current).toEqual({ widget: null, error: null }); + }); +}); diff --git a/packages/jsapi-bootstrap/src/useWidget.ts b/packages/jsapi-bootstrap/src/useWidget.ts new file mode 100644 index 0000000000..417ae83195 --- /dev/null +++ b/packages/jsapi-bootstrap/src/useWidget.ts @@ -0,0 +1,90 @@ +import { dh } from '@deephaven/jsapi-types'; +import Log from '@deephaven/log'; +import { assertNotNull } from '@deephaven/utils'; +import { useEffect, useState } from 'react'; +import { useObjectFetch } from './useObjectFetch'; + +const log = Log.module('useWidget'); + +/** + * Wrapper object for a widget and error status. Both widget and error will be `null` if it is still loading. + */ +type WidgetWrapper = { + /** Widget object to retrieve */ + widget: T | null; + + /** Error status if there was an issue fetching the widget */ + error: unknown | null; +}; + +/** + * Retrieve a widget for the given variable descriptor. Note that if the widget is successfully fetched, ownership of the widget is passed to the consumer and will need to close the object as well. + * @param descriptor Descriptor to get the widget for + * @returns A WidgetWrapper object that contains the widget or an error status if there was an issue fetching the widget. Will contain nulls if still loading. + */ +export function useWidget( + descriptor: dh.ide.VariableDescriptor +): WidgetWrapper { + const [wrapper, setWrapper] = useState>(() => ({ + widget: null, + error: null, + })); + + const objectFetch = useObjectFetch(descriptor); + + useEffect( + function loadWidget() { + log.debug('loadWidget', descriptor); + + const { fetch, error } = objectFetch; + + if (error != null) { + // We can't fetch if there's an error getting the fetcher, just return an error + setWrapper({ widget: null, error }); + return; + } + + if (fetch == null) { + // Still loading + setWrapper({ widget: null, error: null }); + return; + } + + let isCancelled = false; + async function loadWidgetInternal() { + try { + assertNotNull(fetch); + const newWidget = await fetch(); + if (isCancelled) { + log.debug2('loadWidgetInternal cancelled', descriptor, newWidget); + newWidget.close(); + newWidget.exportedObjects.forEach( + (exportedObject: dh.WidgetExportedObject) => { + exportedObject.close(); + } + ); + return; + } + log.debug('loadWidgetInternal done', descriptor, newWidget); + + setWrapper({ widget: newWidget, error: null }); + } catch (e) { + if (isCancelled) { + return; + } + log.error('loadWidgetInternal error', descriptor, e); + setWrapper({ widget: null, error: e }); + } + } + loadWidgetInternal(); + return () => { + isCancelled = true; + }; + }, + [descriptor, objectFetch] + ); + + return wrapper; +} + +export default useWidget; diff --git a/packages/jsapi-bootstrap/tsconfig.json b/packages/jsapi-bootstrap/tsconfig.json index 398812c85f..7c57a9efe0 100644 --- a/packages/jsapi-bootstrap/tsconfig.json +++ b/packages/jsapi-bootstrap/tsconfig.json @@ -9,6 +9,7 @@ "references": [ { "path": "../components" }, { "path": "../log" }, - { "path": "../react-hooks" } + { "path": "../react-hooks" }, + { "path": "../utils" } ] } From cbcaf12eecf5f346f8acf75069a9498614695ad0 Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 24 May 2024 14:30:28 -0400 Subject: [PATCH 3/8] WIP need to base off V+ branch --- .../jsapi-bootstrap/src/useObjectFetch.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/jsapi-bootstrap/src/useObjectFetch.ts b/packages/jsapi-bootstrap/src/useObjectFetch.ts index ada546a2f1..3ec2c23156 100644 --- a/packages/jsapi-bootstrap/src/useObjectFetch.ts +++ b/packages/jsapi-bootstrap/src/useObjectFetch.ts @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useState } from 'react'; import type { dh } from '@deephaven/jsapi-types'; +import { ObjectFetcherContext } from './useObjectFetcher'; /** Function for unsubscribing from a given subscription */ export type UnsubscribeFunction = () => void; @@ -23,6 +24,10 @@ export type ObjectFetchUpdate = { error: unknown | null; }; +export type ObjectFetchUpdateCallback = ( + update: ObjectFetchUpdate +) => void; + /** ObjectFetchManager for managing a subscription to an object using a VariableDescriptor */ export type ObjectFetchManager = { /** @@ -33,7 +38,7 @@ export type ObjectFetchManager = { */ subscribe: ( descriptor: dh.ide.VariableDescriptor, - onUpdate: (update: ObjectFetchUpdate) => void + onUpdate: ObjectFetchUpdateCallback ) => UnsubscribeFunction; }; @@ -58,14 +63,22 @@ export function useObjectFetch( }) ); + const objectFetcher = useContext(ObjectFetcherContext); const objectFetchManager = useContext(ObjectFetchManagerContext); useEffect(() => { if (objectFetchManager == null) { - setCurrentUpdate({ - fetch: null, - error: new Error('No ObjectFetchManager available in context'), - }); + if (objectFetcher == null) { + setCurrentUpdate({ + fetch: null, + error: new Error('No ObjectFetchManager available in context'), + }); + } else { + setCurrentUpdate({ + fetch: () => objectFetcher(descriptor), + error: null, + }); + } return; } // Signal that we're still loading @@ -74,7 +87,7 @@ export function useObjectFetch( error: null, }); return objectFetchManager.subscribe(descriptor, setCurrentUpdate); - }, [descriptor, objectFetchManager]); + }, [descriptor, objectFetcher, objectFetchManager]); return currentUpdate; } From dbaa66b507d7d885bd43dc2dae46b699338c2dba Mon Sep 17 00:00:00 2001 From: mikebender Date: Mon, 27 May 2024 11:34:58 -0400 Subject: [PATCH 4/8] Add comment to useWidget --- packages/jsapi-bootstrap/src/useWidget.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/jsapi-bootstrap/src/useWidget.ts b/packages/jsapi-bootstrap/src/useWidget.ts index 417ae83195..fe898184de 100644 --- a/packages/jsapi-bootstrap/src/useWidget.ts +++ b/packages/jsapi-bootstrap/src/useWidget.ts @@ -50,6 +50,7 @@ export function useWidget( return; } + // We should be able to load the widget. Load it asynchronously, and set the widget when it's done. let isCancelled = false; async function loadWidgetInternal() { try { From 4460f9b64bdd7feff74ae3d02c9134e30135f790 Mon Sep 17 00:00:00 2001 From: mikebender Date: Mon, 27 May 2024 11:35:42 -0400 Subject: [PATCH 5/8] More docs --- packages/jsapi-bootstrap/src/useWidget.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/jsapi-bootstrap/src/useWidget.ts b/packages/jsapi-bootstrap/src/useWidget.ts index fe898184de..1e052f323d 100644 --- a/packages/jsapi-bootstrap/src/useWidget.ts +++ b/packages/jsapi-bootstrap/src/useWidget.ts @@ -51,6 +51,8 @@ export function useWidget( } // We should be able to load the widget. Load it asynchronously, and set the widget when it's done. + // If we get cancelled before the fetch is done, we should close the widget and its exported objects. + // If not though, the consumer of the widget is expected to take ownership and close the widget appropriately. let isCancelled = false; async function loadWidgetInternal() { try { From 6224d5ee43caf9c59586a09b98c92d640384d4db Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 31 May 2024 10:22:04 -0400 Subject: [PATCH 6/8] Cleanup after review --- .../src/components/ConnectionBootstrap.tsx | 2 +- .../src/useObjectFetch.test.tsx | 6 +- .../jsapi-bootstrap/src/useObjectFetch.ts | 74 +++++++++---------- .../jsapi-bootstrap/src/useWidget.test.tsx | 67 +++++++++++++---- packages/jsapi-bootstrap/src/useWidget.ts | 44 ++++++++--- 5 files changed, 126 insertions(+), 67 deletions(-) diff --git a/packages/app-utils/src/components/ConnectionBootstrap.tsx b/packages/app-utils/src/components/ConnectionBootstrap.tsx index 1566f1c36c..9ed32b4640 100644 --- a/packages/app-utils/src/components/ConnectionBootstrap.tsx +++ b/packages/app-utils/src/components/ConnectionBootstrap.tsx @@ -93,7 +93,7 @@ export function ConnectionBootstrap({ // We send an update with the fetch right away onUpdate({ fetch: () => objectFetcher(descriptor), - error: null, + status: 'ready', }); return () => { // no-op diff --git a/packages/jsapi-bootstrap/src/useObjectFetch.test.tsx b/packages/jsapi-bootstrap/src/useObjectFetch.test.tsx index 4c11e6e468..3dd4c1a367 100644 --- a/packages/jsapi-bootstrap/src/useObjectFetch.test.tsx +++ b/packages/jsapi-bootstrap/src/useObjectFetch.test.tsx @@ -8,7 +8,7 @@ it('should resolve the objectFetch when in the context', async () => { const descriptor = { type: 'type', name: 'name' }; const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { expect(subscribeDescriptor).toEqual(descriptor); - onUpdate({ fetch: objectFetch, error: null }); + onUpdate({ fetch: objectFetch, status: 'ready' }); return unsubscribe; }); const objectManager = { subscribe }; @@ -19,7 +19,7 @@ it('should resolve the objectFetch when in the context', async () => { ); const { result } = renderHook(() => useObjectFetch(descriptor), { wrapper }); - expect(result.current).toEqual({ fetch: objectFetch, error: null }); + expect(result.current).toEqual({ fetch: objectFetch, status: 'ready' }); expect(result.error).toBeUndefined(); expect(objectFetch).not.toHaveBeenCalled(); }); @@ -28,8 +28,8 @@ it('should return an error, not throw if objectFetch not available in the contex const descriptor = { type: 'type', name: 'name' }; const { result } = renderHook(() => useObjectFetch(descriptor)); expect(result.current).toEqual({ - fetch: null, error: expect.any(Error), + status: 'error', }); expect(result.error).toBeUndefined(); }); diff --git a/packages/jsapi-bootstrap/src/useObjectFetch.ts b/packages/jsapi-bootstrap/src/useObjectFetch.ts index 3ec2c23156..da77c7d2e0 100644 --- a/packages/jsapi-bootstrap/src/useObjectFetch.ts +++ b/packages/jsapi-bootstrap/src/useObjectFetch.ts @@ -1,28 +1,36 @@ import { createContext, useContext, useEffect, useState } from 'react'; import type { dh } from '@deephaven/jsapi-types'; -import { ObjectFetcherContext } from './useObjectFetcher'; /** Function for unsubscribing from a given subscription */ export type UnsubscribeFunction = () => void; +/** Update when the ObjectFetch is still loading */ +export type ObjectFetchLoading = { + status: 'loading'; +}; + +/** Update when the ObjectFetch has errored */ +export type ObjectFetchError = { + error: NonNullable; + status: 'error'; +}; + +/** Update when the object is ready */ +export type ObjectFetchReady = { + fetch: () => Promise; + status: 'ready'; +}; + /** - * Update with the current `fetch` fuonction and status of the object. + * Update with the current `fetch` function and status of the object. * - If both `fetch` and `error` are `null`, it is still loading the fetcher * - If `fetch` is not `null`, the object is ready to be fetched * - If `error` is not `null`, there was an error loading the object */ -export type ObjectFetchUpdate = { - /** - * Function to fetch the object. If `null`, the object is still loading or there was an error. - */ - fetch: (() => Promise) | null; - - /** - * Error that occurred while fetching the object. If `null`, there was no error. - * Will automatically retry when possible while the subscribed. - */ - error: unknown | null; -}; +export type ObjectFetchUpdate = + | ObjectFetchLoading + | ObjectFetchError + | ObjectFetchReady; export type ObjectFetchUpdateCallback = ( update: ObjectFetchUpdate @@ -31,7 +39,7 @@ export type ObjectFetchUpdateCallback = ( /** ObjectFetchManager for managing a subscription to an object using a VariableDescriptor */ export type ObjectFetchManager = { /** - * + * Subscribe to an object using a variable descriptor. * @param descriptor Descriptor object to fetch the object from. Can be extended by a specific implementation to include more details necessary for the ObjectManager. * @param onUpdate Callback function to be called when the object is updated. * @returns An unsubscribe function to stop listening for updates and clean up the object. @@ -56,38 +64,26 @@ export const ObjectFetchManagerContext = export function useObjectFetch( descriptor: dh.ide.VariableDescriptor ): ObjectFetchUpdate { - const [currentUpdate, setCurrentUpdate] = useState>( - () => ({ - fetch: null, - error: null, - }) - ); + const [currentUpdate, setCurrentUpdate] = useState>({ + status: 'loading', + }); - const objectFetcher = useContext(ObjectFetcherContext); const objectFetchManager = useContext(ObjectFetchManagerContext); useEffect(() => { if (objectFetchManager == null) { - if (objectFetcher == null) { - setCurrentUpdate({ - fetch: null, - error: new Error('No ObjectFetchManager available in context'), - }); - } else { - setCurrentUpdate({ - fetch: () => objectFetcher(descriptor), - error: null, - }); - } + setCurrentUpdate({ + error: new Error('No ObjectFetchManager available in context'), + status: 'error', + }); return; } - // Signal that we're still loading - setCurrentUpdate({ - fetch: null, - error: null, - }); + // Update to signal we're still loading, if we're not already in a loading state. + setCurrentUpdate(oldUpdate => + oldUpdate.status === 'loading' ? oldUpdate : { status: 'loading' } + ); return objectFetchManager.subscribe(descriptor, setCurrentUpdate); - }, [descriptor, objectFetcher, objectFetchManager]); + }, [descriptor, objectFetchManager]); return currentUpdate; } diff --git a/packages/jsapi-bootstrap/src/useWidget.test.tsx b/packages/jsapi-bootstrap/src/useWidget.test.tsx index 5e64dbccbd..25a04f9132 100644 --- a/packages/jsapi-bootstrap/src/useWidget.test.tsx +++ b/packages/jsapi-bootstrap/src/useWidget.test.tsx @@ -1,12 +1,26 @@ import React from 'react'; import { act, renderHook } from '@testing-library/react-hooks'; +import { dh } from '@deephaven/jsapi-types'; import { TestUtils } from '@deephaven/utils'; import { useWidget } from './useWidget'; import { ObjectFetchManagerContext } from './useObjectFetch'; +import { ApiContext } from './ApiBootstrap'; + +const WIDGET_TYPE = 'OtherWidget'; + +const api = TestUtils.createMockProxy({ + VariableType: TestUtils.createMockProxy({ + OTHERWIDGET: WIDGET_TYPE, + TABLE: 'Table', + }), +}); describe('useWidget', () => { it('should return a widget when available', async () => { - const descriptor = { type: 'type', name: 'name' }; + const descriptor: dh.ide.VariableDescriptor = { + type: 'OtherWidget', + name: 'name', + }; const widget = { close: jest.fn() }; const fetch = jest.fn(async () => widget); const objectFetch = { fetch, error: null }; @@ -17,9 +31,11 @@ describe('useWidget', () => { }); const objectManager = { subscribe }; const wrapper = ({ children }) => ( - - {children} - + + + {children} + + ); const { result } = renderHook(() => useWidget(descriptor), { wrapper }); await act(TestUtils.flushPromises); @@ -28,9 +44,12 @@ describe('useWidget', () => { }); it('should return an error when an error occurs', () => { - const descriptor = { type: 'type', name: 'name' }; + const descriptor: dh.ide.VariableDescriptor = { + type: WIDGET_TYPE, + name: 'name', + }; const error = new Error('Error fetching widget'); - const objectFetch = { fetch: null, error }; + const objectFetch = { error, status: 'error' }; const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { expect(subscribeDescriptor).toEqual(descriptor); onUpdate(objectFetch); @@ -38,9 +57,11 @@ describe('useWidget', () => { }); const objectManager = { subscribe }; const wrapper = ({ children }) => ( - - {children} - + + + {children} + + ); const { result } = renderHook(() => useWidget(descriptor), { wrapper }); @@ -49,20 +70,38 @@ describe('useWidget', () => { }); it('should return null when still loading', () => { - const descriptor = { type: 'type', name: 'name' }; - const objectFetch = { fetch: null, error: null }; + const descriptor = { type: WIDGET_TYPE, name: 'name' }; + const objectFetch = { status: 'loading' }; const subscribe = jest.fn((_, onUpdate) => { onUpdate(objectFetch); return jest.fn(); }); const objectManager = { subscribe }; const wrapper = ({ children }) => ( - - {children} - + + + {children} + + ); const { result } = renderHook(() => useWidget(descriptor), { wrapper }); expect(result.current).toEqual({ widget: null, error: null }); }); + + it('should return an error when the descriptor type is not supported', () => { + const descriptor: dh.ide.VariableDescriptor = { + type: 'Table', + name: 'name', + }; + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useWidget(descriptor), { wrapper }); + + expect(result.current).toEqual({ + widget: null, + error: new Error(`Unsupported descriptor type: ${descriptor.type}`), + }); + }); }); diff --git a/packages/jsapi-bootstrap/src/useWidget.ts b/packages/jsapi-bootstrap/src/useWidget.ts index 1e052f323d..4f4970802e 100644 --- a/packages/jsapi-bootstrap/src/useWidget.ts +++ b/packages/jsapi-bootstrap/src/useWidget.ts @@ -1,7 +1,8 @@ import { dh } from '@deephaven/jsapi-types'; import Log from '@deephaven/log'; import { assertNotNull } from '@deephaven/utils'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import useApi from './useApi'; import { useObjectFetch } from './useObjectFetch'; const log = Log.module('useWidget'); @@ -14,12 +15,12 @@ type WidgetWrapper = { widget: T | null; /** Error status if there was an issue fetching the widget */ - error: unknown | null; + error: NonNullable | null; }; /** * Retrieve a widget for the given variable descriptor. Note that if the widget is successfully fetched, ownership of the widget is passed to the consumer and will need to close the object as well. - * @param descriptor Descriptor to get the widget for + * @param descriptor Descriptor to get the widget for. Should be stable to avoid infinite re-fetching. * @returns A WidgetWrapper object that contains the widget or an error status if there was an issue fetching the widget. Will contain nulls if still loading. */ export function useWidget( @@ -29,27 +30,50 @@ export function useWidget( widget: null, error: null, })); - + const api = useApi(); + const unsupportedTypes = useMemo( + () => [ + api.VariableType.TABLE, + api.VariableType.TREETABLE, + api.VariableType.HIERARCHICALTABLE, + api.VariableType.TABLEMAP, + api.VariableType.PARTITIONEDTABLE, + api.VariableType.FIGURE, + api.VariableType.PANDAS, + api.VariableType.TREEMAP, + ], + [api] + ); const objectFetch = useObjectFetch(descriptor); useEffect( function loadWidget() { log.debug('loadWidget', descriptor); - const { fetch, error } = objectFetch; + if (unsupportedTypes.includes(descriptor.type)) { + // We only support fetching widgets with this hook + setWrapper({ + widget: null, + error: new Error(`Unsupported descriptor type: ${descriptor.type}`), + }); + return; + } + + const { status } = objectFetch; - if (error != null) { + if (status === 'error') { // We can't fetch if there's an error getting the fetcher, just return an error - setWrapper({ widget: null, error }); + setWrapper({ widget: null, error: objectFetch.error }); return; } - if (fetch == null) { + if (status === 'loading') { // Still loading setWrapper({ widget: null, error: null }); return; } + const { fetch } = objectFetch; // We should be able to load the widget. Load it asynchronously, and set the widget when it's done. // If we get cancelled before the fetch is done, we should close the widget and its exported objects. // If not though, the consumer of the widget is expected to take ownership and close the widget appropriately. @@ -76,7 +100,7 @@ export function useWidget( return; } log.error('loadWidgetInternal error', descriptor, e); - setWrapper({ widget: null, error: e }); + setWrapper({ widget: null, error: e ?? new Error('Null error') }); } } loadWidgetInternal(); @@ -84,7 +108,7 @@ export function useWidget( isCancelled = true; }; }, - [descriptor, objectFetch] + [descriptor, objectFetch, unsupportedTypes] ); return wrapper; From 322454d077d7b16233d11b7f3d2f8274af776cf6 Mon Sep 17 00:00:00 2001 From: mikebender Date: Mon, 3 Jun 2024 11:24:04 -0400 Subject: [PATCH 7/8] Cleanup based on review - Some docs changes - Allow other types other than just `dh.Widget` to be used --- .../jsapi-bootstrap/src/useObjectFetch.ts | 8 +- .../jsapi-bootstrap/src/useWidget.test.tsx | 137 ++++++++++++++---- packages/jsapi-bootstrap/src/useWidget.ts | 56 +++---- 3 files changed, 134 insertions(+), 67 deletions(-) diff --git a/packages/jsapi-bootstrap/src/useObjectFetch.ts b/packages/jsapi-bootstrap/src/useObjectFetch.ts index da77c7d2e0..5330ca5fd3 100644 --- a/packages/jsapi-bootstrap/src/useObjectFetch.ts +++ b/packages/jsapi-bootstrap/src/useObjectFetch.ts @@ -39,10 +39,12 @@ export type ObjectFetchUpdateCallback = ( /** ObjectFetchManager for managing a subscription to an object using a VariableDescriptor */ export type ObjectFetchManager = { /** - * Subscribe to an object using a variable descriptor. - * @param descriptor Descriptor object to fetch the object from. Can be extended by a specific implementation to include more details necessary for the ObjectManager. + * Subscribe to the fetch function for an object using a variable descriptor. + * It's possible that the fetch function changes over time, due to disconnection/reconnection, starting/stopping of applications that the object may be associated with, etc. + * + * @param descriptor Descriptor object of the object to fetch. Can be extended by a specific implementation to include more details necessary for the ObjectManager. * @param onUpdate Callback function to be called when the object is updated. - * @returns An unsubscribe function to stop listening for updates and clean up the object. + * @returns An unsubscribe function to stop listening for fetch updates and clean up the object. */ subscribe: ( descriptor: dh.ide.VariableDescriptor, diff --git a/packages/jsapi-bootstrap/src/useWidget.test.tsx b/packages/jsapi-bootstrap/src/useWidget.test.tsx index 25a04f9132..0d6f3e212a 100644 --- a/packages/jsapi-bootstrap/src/useWidget.test.tsx +++ b/packages/jsapi-bootstrap/src/useWidget.test.tsx @@ -4,17 +4,9 @@ import { dh } from '@deephaven/jsapi-types'; import { TestUtils } from '@deephaven/utils'; import { useWidget } from './useWidget'; import { ObjectFetchManagerContext } from './useObjectFetch'; -import { ApiContext } from './ApiBootstrap'; const WIDGET_TYPE = 'OtherWidget'; -const api = TestUtils.createMockProxy({ - VariableType: TestUtils.createMockProxy({ - OTHERWIDGET: WIDGET_TYPE, - TABLE: 'Table', - }), -}); - describe('useWidget', () => { it('should return a widget when available', async () => { const descriptor: dh.ide.VariableDescriptor = { @@ -31,11 +23,9 @@ describe('useWidget', () => { }); const objectManager = { subscribe }; const wrapper = ({ children }) => ( - - - {children} - - + + {children} + ); const { result } = renderHook(() => useWidget(descriptor), { wrapper }); await act(TestUtils.flushPromises); @@ -57,11 +47,9 @@ describe('useWidget', () => { }); const objectManager = { subscribe }; const wrapper = ({ children }) => ( - - - {children} - - + + {children} + ); const { result } = renderHook(() => useWidget(descriptor), { wrapper }); @@ -78,30 +66,119 @@ describe('useWidget', () => { }); const objectManager = { subscribe }; const wrapper = ({ children }) => ( - - - {children} - - + + {children} + ); const { result } = renderHook(() => useWidget(descriptor), { wrapper }); expect(result.current).toEqual({ widget: null, error: null }); }); - it('should return an error when the descriptor type is not supported', () => { + it('should close the widget and exported objects when cancelled', async () => { + const descriptor = { type: WIDGET_TYPE, name: 'name' }; + const widget: dh.Widget = TestUtils.createMockProxy({ + close: jest.fn(), + exportedObjects: [ + TestUtils.createMockProxy({ + close: jest.fn(), + }), + TestUtils.createMockProxy({ + close: jest.fn(), + }), + ], + }); + const fetch = jest.fn(async () => widget); + const objectFetch = { fetch, error: null }; + const subscribe = jest.fn((_, onUpdate) => { + onUpdate(objectFetch); + return jest.fn(); + }); + const objectManager = { subscribe }; + const wrapper = ({ children }) => ( + + {children} + + ); + const { result, unmount } = renderHook(() => useWidget(descriptor), { + wrapper, + }); + expect(widget.close).not.toHaveBeenCalled(); + expect(widget.exportedObjects[0].close).not.toHaveBeenCalled(); + expect(widget.exportedObjects[1].close).not.toHaveBeenCalled(); + + expect(result.current).toEqual({ widget: null, error: null }); + + // Unmount before flushing the promise + unmount(); + await act(TestUtils.flushPromises); + expect(widget.close).toHaveBeenCalledTimes(1); + expect(widget.exportedObjects[0].close).toHaveBeenCalledTimes(1); + expect(widget.exportedObjects[1].close).toHaveBeenCalledTimes(1); + }); + + it('should not close the widget if it is returned before unmount', async () => { + const descriptor = { type: WIDGET_TYPE, name: 'name' }; + const widget: dh.Widget = TestUtils.createMockProxy({ + close: jest.fn(), + exportedObjects: [ + TestUtils.createMockProxy({ + close: jest.fn(), + }), + TestUtils.createMockProxy({ + close: jest.fn(), + }), + ], + }); + const fetch = jest.fn(async () => widget); + const objectFetch = { fetch, error: null }; + const subscribe = jest.fn((_, onUpdate) => { + onUpdate(objectFetch); + return jest.fn(); + }); + const objectManager = { subscribe }; + const wrapper = ({ children }) => ( + + {children} + + ); + const { result, unmount } = renderHook(() => useWidget(descriptor), { + wrapper, + }); + + expect(result.current).toEqual({ widget: null, error: null }); + await act(TestUtils.flushPromises); + unmount(); + expect(widget.close).not.toHaveBeenCalled(); + expect(widget.exportedObjects[0].close).not.toHaveBeenCalled(); + expect(widget.exportedObjects[1].close).not.toHaveBeenCalled(); + }); + + it('should handle a Table being fetched', async () => { const descriptor: dh.ide.VariableDescriptor = { type: 'Table', name: 'name', }; + const widget = { close: jest.fn() }; + const fetch = jest.fn(async () => widget); + const objectFetch = { fetch, error: null }; + const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { + expect(subscribeDescriptor).toEqual(descriptor); + onUpdate(objectFetch); + return jest.fn(); + }); + const objectManager = { subscribe }; const wrapper = ({ children }) => ( - {children} + + {children} + ); - const { result } = renderHook(() => useWidget(descriptor), { wrapper }); - - expect(result.current).toEqual({ - widget: null, - error: new Error(`Unsupported descriptor type: ${descriptor.type}`), + const { result, unmount } = renderHook(() => useWidget(descriptor), { + wrapper, }); + await act(TestUtils.flushPromises); + expect(result.current).toEqual({ widget, error: null }); + expect(fetch).toHaveBeenCalledTimes(1); + unmount(); }); }); diff --git a/packages/jsapi-bootstrap/src/useWidget.ts b/packages/jsapi-bootstrap/src/useWidget.ts index 4f4970802e..270d46cddf 100644 --- a/packages/jsapi-bootstrap/src/useWidget.ts +++ b/packages/jsapi-bootstrap/src/useWidget.ts @@ -1,16 +1,25 @@ -import { dh } from '@deephaven/jsapi-types'; +import type { dh } from '@deephaven/jsapi-types'; import Log from '@deephaven/log'; import { assertNotNull } from '@deephaven/utils'; -import { useEffect, useMemo, useState } from 'react'; -import useApi from './useApi'; +import { useEffect, useState } from 'react'; import { useObjectFetch } from './useObjectFetch'; const log = Log.module('useWidget'); +/** + * Types of widgets that can be fetched with this hook. + */ +type WidgetTypes = + | dh.Table + | dh.TreeTable + | dh.PartitionedTable + | dh.plot.Figure + | dh.Widget; + /** * Wrapper object for a widget and error status. Both widget and error will be `null` if it is still loading. */ -type WidgetWrapper = { +type WidgetWrapper = { /** Widget object to retrieve */ widget: T | null; @@ -23,42 +32,19 @@ type WidgetWrapper = { * @param descriptor Descriptor to get the widget for. Should be stable to avoid infinite re-fetching. * @returns A WidgetWrapper object that contains the widget or an error status if there was an issue fetching the widget. Will contain nulls if still loading. */ -export function useWidget( +export function useWidget( descriptor: dh.ide.VariableDescriptor ): WidgetWrapper { const [wrapper, setWrapper] = useState>(() => ({ widget: null, error: null, })); - const api = useApi(); - const unsupportedTypes = useMemo( - () => [ - api.VariableType.TABLE, - api.VariableType.TREETABLE, - api.VariableType.HIERARCHICALTABLE, - api.VariableType.TABLEMAP, - api.VariableType.PARTITIONEDTABLE, - api.VariableType.FIGURE, - api.VariableType.PANDAS, - api.VariableType.TREEMAP, - ], - [api] - ); const objectFetch = useObjectFetch(descriptor); useEffect( function loadWidget() { log.debug('loadWidget', descriptor); - if (unsupportedTypes.includes(descriptor.type)) { - // We only support fetching widgets with this hook - setWrapper({ - widget: null, - error: new Error(`Unsupported descriptor type: ${descriptor.type}`), - }); - return; - } - const { status } = objectFetch; if (status === 'error') { @@ -85,11 +71,13 @@ export function useWidget( if (isCancelled) { log.debug2('loadWidgetInternal cancelled', descriptor, newWidget); newWidget.close(); - newWidget.exportedObjects.forEach( - (exportedObject: dh.WidgetExportedObject) => { - exportedObject.close(); - } - ); + if ('exportedObjects' in newWidget) { + newWidget.exportedObjects.forEach( + (exportedObject: dh.WidgetExportedObject) => { + exportedObject.close(); + } + ); + } return; } log.debug('loadWidgetInternal done', descriptor, newWidget); @@ -108,7 +96,7 @@ export function useWidget( isCancelled = true; }; }, - [descriptor, objectFetch, unsupportedTypes] + [descriptor, objectFetch] ); return wrapper; From 6679a2a7579cad143040b242cf82300d16549e43 Mon Sep 17 00:00:00 2001 From: mikebender Date: Tue, 4 Jun 2024 09:49:58 -0400 Subject: [PATCH 8/8] Cleanup from review - Remove type as it's already inferred - Add case to check if table is closed if unmounted before fetched --- .../jsapi-bootstrap/src/useWidget.test.tsx | 35 +++++++++++++++++-- packages/jsapi-bootstrap/src/useWidget.ts | 8 ++--- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/jsapi-bootstrap/src/useWidget.test.tsx b/packages/jsapi-bootstrap/src/useWidget.test.tsx index 0d6f3e212a..f6221b04da 100644 --- a/packages/jsapi-bootstrap/src/useWidget.test.tsx +++ b/packages/jsapi-bootstrap/src/useWidget.test.tsx @@ -159,8 +159,8 @@ describe('useWidget', () => { type: 'Table', name: 'name', }; - const widget = { close: jest.fn() }; - const fetch = jest.fn(async () => widget); + const table = TestUtils.createMockProxy({ close: jest.fn() }); + const fetch = jest.fn(async () => table); const objectFetch = { fetch, error: null }; const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { expect(subscribeDescriptor).toEqual(descriptor); @@ -177,8 +177,37 @@ describe('useWidget', () => { wrapper, }); await act(TestUtils.flushPromises); - expect(result.current).toEqual({ widget, error: null }); + expect(result.current).toEqual({ widget: table, error: null }); expect(fetch).toHaveBeenCalledTimes(1); unmount(); + + // Shouldn't be called if it was returned before unmount + expect(table.close).not.toHaveBeenCalled(); + }); + + it('should close the Table if unmounted before the fetch is done', async () => { + const descriptor: dh.ide.VariableDescriptor = { + type: 'Table', + name: 'name', + }; + const table = TestUtils.createMockProxy({ close: jest.fn() }); + const fetch = jest.fn(async () => table); + const objectFetch = { fetch, error: null }; + const subscribe = jest.fn((subscribeDescriptor, onUpdate) => { + expect(subscribeDescriptor).toEqual(descriptor); + onUpdate(objectFetch); + return jest.fn(); + }); + const objectManager = { subscribe }; + const wrapper = ({ children }) => ( + + {children} + + ); + const { unmount } = renderHook(() => useWidget(descriptor), { wrapper }); + + unmount(); + await act(TestUtils.flushPromises); + expect(table.close).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/jsapi-bootstrap/src/useWidget.ts b/packages/jsapi-bootstrap/src/useWidget.ts index 270d46cddf..e624245ac2 100644 --- a/packages/jsapi-bootstrap/src/useWidget.ts +++ b/packages/jsapi-bootstrap/src/useWidget.ts @@ -72,11 +72,9 @@ export function useWidget( log.debug2('loadWidgetInternal cancelled', descriptor, newWidget); newWidget.close(); if ('exportedObjects' in newWidget) { - newWidget.exportedObjects.forEach( - (exportedObject: dh.WidgetExportedObject) => { - exportedObject.close(); - } - ); + newWidget.exportedObjects.forEach(exportedObject => { + exportedObject.close(); + }); } return; }