From 822444f8be977c85a6f4333d0178fa096a787d8e Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 22 Feb 2024 12:24:59 -0500 Subject: [PATCH 1/2] feat(context): uniform naming and main export --- src/core/Algorithm.tsx | 8 ++++---- src/core/CellData.tsx | 4 ++-- src/core/DataArray.ts | 6 +++--- src/core/Dataset.tsx | 6 +++--- src/core/FieldData.tsx | 4 ++-- src/core/ImageData.tsx | 10 +++++++--- src/core/PointData.tsx | 4 ++-- src/core/PolyData.tsx | 10 +++++++--- src/core/Reader.tsx | 6 +++--- src/core/ShareDataSet.tsx | 14 +++++++------- src/core/VolumeController.tsx | 4 ++-- src/core/contexts.ts | 16 +++++++++++----- src/index.ts | 2 ++ 13 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/core/Algorithm.tsx b/src/core/Algorithm.tsx index cd30246..f38761f 100644 --- a/src/core/Algorithm.tsx +++ b/src/core/Algorithm.tsx @@ -8,8 +8,8 @@ import { usePrevious } from '../utils/usePrevious'; import useUnmount from '../utils/useUnmount'; import { DownstreamContext, - useDownstream, - useRepresentation, + useDownstreamContext, + useRepresentationContext, } from './contexts'; export interface AlgorithmProps extends PropsWithChildren { @@ -46,8 +46,8 @@ export default function Algorithm(props: AlgorithmProps) { return algo; }); - const representation = useRepresentation(); - const downstream = useDownstream(); + const representation = useRepresentationContext(); + const downstream = useDownstreamContext(); useEffect(() => { let algoChanged = false; diff --git a/src/core/CellData.tsx b/src/core/CellData.tsx index 2302bfb..80e43a0 100644 --- a/src/core/CellData.tsx +++ b/src/core/CellData.tsx @@ -1,8 +1,8 @@ import { PropsWithChildren, useCallback } from 'react'; -import { FieldDataContext, useDataset } from './contexts'; +import { FieldDataContext, useDatasetContext } from './contexts'; export default function CellData(props: PropsWithChildren) { - const dataset = useDataset(); + const dataset = useDatasetContext(); const getCellData = useCallback(() => { return dataset.getDataSet().getCellData(); }, [dataset]); diff --git a/src/core/DataArray.ts b/src/core/DataArray.ts index e66bb46..c3c9029 100644 --- a/src/core/DataArray.ts +++ b/src/core/DataArray.ts @@ -8,7 +8,7 @@ import deletionRegistry from '../utils/DeletionRegistry'; import useGetterRef from '../utils/useGetterRef'; import { usePrevious } from '../utils/usePrevious'; import useUnmount from '../utils/useUnmount'; -import { useDataset, useFieldData } from './contexts'; +import { useDatasetContext, useFieldDataContext } from './contexts'; export interface DataArrayProps { /** @@ -61,8 +61,8 @@ export default function DataArray(props: DataArrayProps) { return da; }); - const getFieldData = useFieldData(); - const dataset = useDataset(); + const getFieldData = useFieldDataContext(); + const dataset = useDatasetContext(); const { registration = DefaultProps.registration } = props; diff --git a/src/core/Dataset.tsx b/src/core/Dataset.tsx index cd963c5..ea4e19d 100644 --- a/src/core/Dataset.tsx +++ b/src/core/Dataset.tsx @@ -1,14 +1,14 @@ import { vtkObject } from '@kitware/vtk.js/interfaces'; import { useEffect } from 'react'; -import { useDownstream, useRepresentation } from './contexts'; +import { useDownstreamContext, useRepresentationContext } from './contexts'; export interface DatasetProps { dataset: vtkObject | null; } export default function Dataset(props: DatasetProps) { - const representation = useRepresentation(); - const downstream = useDownstream(); + const representation = useRepresentationContext(); + const downstream = useDownstreamContext(); const { dataset } = props; diff --git a/src/core/FieldData.tsx b/src/core/FieldData.tsx index 3a55dda..0dfc31c 100644 --- a/src/core/FieldData.tsx +++ b/src/core/FieldData.tsx @@ -1,8 +1,8 @@ import { PropsWithChildren, useCallback } from 'react'; -import { FieldDataContext, useDataset } from './contexts'; +import { FieldDataContext, useDatasetContext } from './contexts'; export default function FieldData(props: PropsWithChildren) { - const dataset = useDataset(); + const dataset = useDatasetContext(); const getFieldData = useCallback(() => { return dataset.getDataSet().getFieldData(); }, [dataset]); diff --git a/src/core/ImageData.tsx b/src/core/ImageData.tsx index 21f0581..5a8b2d5 100644 --- a/src/core/ImageData.tsx +++ b/src/core/ImageData.tsx @@ -11,7 +11,11 @@ import { IDataset } from '../types'; import deletionRegistry from '../utils/DeletionRegistry'; import useGetterRef from '../utils/useGetterRef'; import useUnmount from '../utils/useUnmount'; -import { DatasetContext, useDownstream, useRepresentation } from './contexts'; +import { + DatasetContext, + useDownstreamContext, + useRepresentationContext, +} from './contexts'; export interface ImageDataProps extends PropsWithChildren { /** @@ -63,8 +67,8 @@ export default forwardRef(function PolyData(props: ImageDataProps, fwdRef) { return im; }); - const representation = useRepresentation(); - const downstream = useDownstream(); + const representation = useRepresentationContext(); + const downstream = useDownstreamContext(); // dataset API const dataset = useMemo>( diff --git a/src/core/PointData.tsx b/src/core/PointData.tsx index c26cbcb..4cabe7d 100644 --- a/src/core/PointData.tsx +++ b/src/core/PointData.tsx @@ -1,8 +1,8 @@ import { PropsWithChildren, useCallback } from 'react'; -import { FieldDataContext, useDataset } from './contexts'; +import { FieldDataContext, useDatasetContext } from './contexts'; export default function PointData(props: PropsWithChildren) { - const dataset = useDataset(); + const dataset = useDatasetContext(); const getPointData = useCallback(() => { return dataset.getDataSet().getPointData(); }, [dataset]); diff --git a/src/core/PolyData.tsx b/src/core/PolyData.tsx index 5f7c5bb..1a100bc 100644 --- a/src/core/PolyData.tsx +++ b/src/core/PolyData.tsx @@ -17,7 +17,11 @@ import deletionRegistry from '../utils/DeletionRegistry'; import useGetterRef from '../utils/useGetterRef'; import { usePrevious } from '../utils/usePrevious'; import useUnmount from '../utils/useUnmount'; -import { DatasetContext, useDownstream, useRepresentation } from './contexts'; +import { + DatasetContext, + useDownstreamContext, + useRepresentationContext, +} from './contexts'; export interface PolyDataProps extends PropsWithChildren { /** @@ -103,8 +107,8 @@ export default forwardRef(function PolyData(props: PolyDataProps, fwdRef) { return pd; }); - const representation = useRepresentation(); - const downstream = useDownstream(); + const representation = useRepresentationContext(); + const downstream = useDownstreamContext(); // dataset API const dataset = useMemo>( diff --git a/src/core/Reader.tsx b/src/core/Reader.tsx index 1e46218..2b82b2f 100644 --- a/src/core/Reader.tsx +++ b/src/core/Reader.tsx @@ -4,7 +4,7 @@ import vtk from '@kitware/vtk.js/vtk'; import { PropsWithChildren, useCallback, useEffect, useRef } from 'react'; import { VtkConstructor } from '../types'; import deletionRegistry from '../utils/DeletionRegistry'; -import { useDownstream, useRepresentation } from './contexts'; +import { useDownstreamContext, useRepresentationContext } from './contexts'; export interface ReaderProps extends PropsWithChildren { /** @@ -71,7 +71,7 @@ export default function Reader(props: ReaderProps) { ); } - const representation = useRepresentation(); + const representation = useRepresentationContext(); const createReader = useCallback(() => { if (typeof vtkClass === 'string') { @@ -138,7 +138,7 @@ export default function Reader(props: ReaderProps) { // --- downstream registration --- // - const downstream = useDownstream(); + const downstream = useDownstreamContext(); const { port } = props; useEffect(() => { diff --git a/src/core/ShareDataSet.tsx b/src/core/ShareDataSet.tsx index 2e1172d..a09bc2b 100644 --- a/src/core/ShareDataSet.tsx +++ b/src/core/ShareDataSet.tsx @@ -27,9 +27,9 @@ import { DownstreamContext, RepresentationContext, ShareDataSetContext, - useDownstream, - useRepresentation, - useShareDataSet, + useDownstreamContext, + useRepresentationContext, + useShareDataSetContext, } from './contexts'; import useDataEvents from './modules/useDataEvents'; @@ -138,7 +138,7 @@ export interface RegisterDataSetProps extends PropsWithChildren { } export function RegisterDataSet(props: RegisterDataSetProps) { - const share = useShareDataSet(); + const share = useShareDataSetContext(); const { id } = props; // --- handle registrations --- // @@ -206,10 +206,10 @@ export interface UseDataSetProps extends PropsWithChildren { export function UseDataSet(props: UseDataSetProps) { const { id, port = 0 } = props; - const share = useShareDataSet(); + const share = useShareDataSetContext(); // TODO if useDataSet is input to an algorithm, should representation be null? - const representation = useRepresentation(); - const downstream = useDownstream(); + const representation = useRepresentationContext(); + const downstream = useDownstreamContext(); useEffect(() => { return share.onDataAvailable(id, (ds) => { diff --git a/src/core/VolumeController.tsx b/src/core/VolumeController.tsx index 4a251a7..2843273 100644 --- a/src/core/VolumeController.tsx +++ b/src/core/VolumeController.tsx @@ -5,7 +5,7 @@ import { Contexts } from '..'; import deletionRegistry from '../utils/DeletionRegistry'; import useGetterRef from '../utils/useGetterRef'; import useUnmount from '../utils/useUnmount'; -import { useRepresentation } from './contexts'; +import { useRepresentationContext } from './contexts'; export interface VolumeControllerProps { /** @@ -35,7 +35,7 @@ export default function VolumeController(props: VolumeControllerProps) { const view = useContext(Contexts.ViewContext); if (!view) throw new Error('Need view context'); - const volumeRep = useRepresentation(); + const volumeRep = useRepresentationContext(); const updateWidget = useCallback(() => { const volume = volumeRep.getMapper()?.getInputData() as diff --git a/src/core/contexts.ts b/src/core/contexts.ts index 3a69ffa..64ae9ec 100644 --- a/src/core/contexts.ts +++ b/src/core/contexts.ts @@ -37,6 +37,12 @@ export const MultiViewRootContext = createContext(false); export const ViewContext = createContext(null); +export function useViewContext() { + const view = useContext(ViewContext); + if (!view) throw new Error('No View context!'); + return view; +} + export function useRenderWindowContext() { const rw = useContext(RenderWindowContext); if (!rw) throw new Error('No RenderWindow context!'); @@ -49,31 +55,31 @@ export function useRendererContext() { return r; } -export function useFieldData() { +export function useFieldDataContext() { const fd = useContext(FieldDataContext); if (!fd) throw new Error('No FieldData context!'); return fd as () => T; } -export function useDataset() { +export function useDatasetContext() { const ds = useContext(DatasetContext); if (!ds) throw new Error('No Dataset context!'); return ds as IDataset; } -export function useRepresentation() { +export function useRepresentationContext() { const rep = useContext(RepresentationContext); if (!rep) throw new Error('No Representation context!'); return rep; } -export function useDownstream() { +export function useDownstreamContext() { const ds = useContext(DownstreamContext); if (!ds) throw new Error('No Downstream context!'); return ds; } -export function useShareDataSet() { +export function useShareDataSetContext() { const share = useContext(ShareDataSetContext); if (!share) throw new Error('No ShareDataSet context!'); return share; diff --git a/src/index.ts b/src/index.ts index 46ccee6..68fe1f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import '@kitware/vtk.js/Rendering/OpenGL/Profiles/Volume'; export { default as Algorithm } from './core/Algorithm'; export type { AlgorithmProps } from './core/Algorithm'; export { default as CellData } from './core/CellData'; +export * from './core/contexts'; export * as Contexts from './core/contexts'; export { default as DataArray } from './core/DataArray'; export type { DataArrayProps } from './core/DataArray'; @@ -46,3 +47,4 @@ export { default as View } from './core/View'; export type { ViewProps } from './core/View'; export { default as VolumeController } from './core/VolumeController'; export { default as VolumeRepresentation } from './core/VolumeRepresentation'; +export * from './suspense'; From d47f388ea5462f1841d90bb984efa5f5ab9c8560 Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 22 Feb 2024 12:30:49 -0500 Subject: [PATCH 2/2] feat: add view mounted suspense hook Add an example usage for how to use the useViewReadySuspense hook. --- src/core/View.tsx | 1 + src/core/internal/ParentedView.tsx | 9 ++++ src/core/internal/SingleView.tsx | 10 +++- src/core/internal/events.ts | 33 +++++++++++++ src/suspense/index.ts | 1 + src/suspense/useViewReadySuspense.ts | 38 +++++++++++++++ src/types.ts | 1 + src/utils/deferred.ts | 12 +++++ usage/src/App.jsx | 4 ++ usage/src/Suspense/GeometrySuspense.jsx | 63 +++++++++++++++++++++++++ 10 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src/core/internal/events.ts create mode 100644 src/suspense/index.ts create mode 100644 src/suspense/useViewReadySuspense.ts create mode 100644 src/utils/deferred.ts create mode 100644 usage/src/Suspense/GeometrySuspense.jsx diff --git a/src/core/View.tsx b/src/core/View.tsx index 047e5d7..8a57f7d 100644 --- a/src/core/View.tsx +++ b/src/core/View.tsx @@ -24,6 +24,7 @@ export default forwardRef(function View(props: ViewProps, fwdRef) { multiViewRoot ? parentedViewRef.current : singleViewRef.current; return { isInMultiViewRoot: () => multiViewRoot, + isMounted: () => getView()?.isMounted() ?? false, getViewContainer: () => getView()?.getViewContainer() ?? null, getOpenGLRenderWindow: () => getView()?.getOpenGLRenderWindow() ?? null, getRenderWindow: () => getView()?.getRenderWindow() ?? null, diff --git a/src/core/internal/ParentedView.tsx b/src/core/internal/ParentedView.tsx index b427cd5..3c9235a 100644 --- a/src/core/internal/ParentedView.tsx +++ b/src/core/internal/ParentedView.tsx @@ -27,6 +27,7 @@ import { } from '../modules/useInteractorStyle'; import useViewEvents, { ViewEvents } from '../modules/useViewEvents'; import Renderer from '../Renderer'; +import { viewMountedEvent } from './events'; import { DefaultProps, ViewProps } from './view-shared'; /** @@ -172,9 +173,16 @@ const ParentedView = forwardRef(function ParentedView( // --- api --- // + let mounted = false; + useMount(() => { + mounted = true; + viewMountedEvent.trigger(); + }); + const api = useMemo( () => ({ isInMultiViewRoot: () => true, + isMounted: () => mounted, getViewContainer: () => containerRef.current, getOpenGLRenderWindow: () => openGLRenderWindowAPI, getRenderWindow: () => renderWindowAPI, @@ -187,6 +195,7 @@ const ParentedView = forwardRef(function ParentedView( rendererRef.current?.resetCamera(boundsToUse), }), [ + mounted, openGLRenderWindowAPI, renderWindowAPI, getInteractorStyle, diff --git a/src/core/internal/SingleView.tsx b/src/core/internal/SingleView.tsx index 605f30b..808af5f 100644 --- a/src/core/internal/SingleView.tsx +++ b/src/core/internal/SingleView.tsx @@ -24,6 +24,7 @@ import useViewEvents, { ViewEvents } from '../modules/useViewEvents'; import OpenGLRenderWindow from '../OpenGLRenderWindow'; import Renderer from '../Renderer'; import RenderWindow from '../RenderWindow'; +import { viewMountedEvent } from './events'; import { DefaultProps, ViewProps } from './view-shared'; /** @@ -84,9 +85,16 @@ const SingleView = forwardRef(function SingleView(props: ViewProps, fwdRef) { // --- api --- // + let mounted = false; + useMount(() => { + mounted = true; + viewMountedEvent.trigger(); + }); + const api = useMemo( () => ({ isInMultiViewRoot: () => false, + isMounted: () => mounted, getViewContainer: () => openGLRenderWindowRef.current?.getContainer() ?? null, getOpenGLRenderWindow: () => openGLRenderWindowRef.current, @@ -99,7 +107,7 @@ const SingleView = forwardRef(function SingleView(props: ViewProps, fwdRef) { resetCamera: (boundsToUse?: Bounds) => rendererRef.current?.resetCamera(boundsToUse), }), - [getInteractorStyle, setInteractorStyle] + [mounted, getInteractorStyle, setInteractorStyle] ); useImperativeHandle(fwdRef, () => api); diff --git a/src/core/internal/events.ts b/src/core/internal/events.ts new file mode 100644 index 0000000..b6a78a1 --- /dev/null +++ b/src/core/internal/events.ts @@ -0,0 +1,33 @@ +export type EventListener = (...args: any[]) => void; + +function createEvent() { + const callbacks: EventListener[] = []; + + const off = (callback: EventListener) => { + const idx = callbacks.indexOf(callback); + if (idx === -1) return; + callbacks.splice(idx, 1); + }; + + const on = (callback: EventListener) => { + callbacks.push(callback); + return () => off(callback); + }; + + const once = (callback: EventListener) => { + const stop = on(() => { + stop(); + callback(); + }); + }; + + const trigger = (...args: any[]) => { + callbacks.forEach((cb) => { + cb(...args); + }); + }; + + return { off, on, once, trigger }; +} + +export const viewMountedEvent = createEvent(); diff --git a/src/suspense/index.ts b/src/suspense/index.ts new file mode 100644 index 0000000..31d9826 --- /dev/null +++ b/src/suspense/index.ts @@ -0,0 +1 @@ +export * from './useViewReadySuspense'; diff --git a/src/suspense/useViewReadySuspense.ts b/src/suspense/useViewReadySuspense.ts new file mode 100644 index 0000000..80ae904 --- /dev/null +++ b/src/suspense/useViewReadySuspense.ts @@ -0,0 +1,38 @@ +import { useContext, useRef } from 'react'; +import { Contexts } from '..'; +import { viewMountedEvent } from '../core/internal/events'; +import { makeDeferred } from '../utils/deferred'; + +type Status = 'pending' | 'error' | 'success'; + +/** + * A suspense-aware hook that waits for the containing View to be mounted before evaluating the getter. + * @param getter + * @returns + */ +export function useViewReadySuspense(getter: () => T): T { + const view = useContext(Contexts.ViewContext); + if (!view) throw new Error('No view context'); + + let status = 'pending' as Status; + const deferred = useRef(makeDeferred()); + + if (view.isMounted()) { + status = 'success'; + } else { + viewMountedEvent.once(() => { + status = 'success'; + deferred.current.resolve(); + }); + } + + switch (status) { + case 'success': + return getter(); + case 'pending': + throw deferred.current.promise; + case 'error': + default: + throw new Error('Unexpected unreachable code execution'); + } +} diff --git a/src/types.ts b/src/types.ts index 16e3c13..5b251c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,7 @@ export interface IRenderer { export interface IView { isInMultiViewRoot(): boolean; + isMounted(): boolean; getViewContainer(): HTMLElement | null; getRenderer(): IRenderer | null; getRenderWindow(): IRenderWindow | null; diff --git a/src/utils/deferred.ts b/src/utils/deferred.ts new file mode 100644 index 0000000..1cd2760 --- /dev/null +++ b/src/utils/deferred.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-function +const empty = () => {}; + +export function makeDeferred() { + let resolve: (value: T) => void = empty; + let reject: (error: unknown) => void = empty; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return { resolve, reject, promise }; +} diff --git a/usage/src/App.jsx b/usage/src/App.jsx index f00ed69..b030b4b 100644 --- a/usage/src/App.jsx +++ b/usage/src/App.jsx @@ -45,6 +45,10 @@ const demos = new Map([ lazy(() => import('./Tests/ChangeInteractorStyle')), ], ['MultiView', lazy(() => import('./MultiView'))], + [ + 'Suspense/GeometrySuspense', + lazy(() => import('./Suspense/GeometrySuspense')), + ], ]); function App() { diff --git a/usage/src/Suspense/GeometrySuspense.jsx b/usage/src/Suspense/GeometrySuspense.jsx new file mode 100644 index 0000000..9c53902 --- /dev/null +++ b/usage/src/Suspense/GeometrySuspense.jsx @@ -0,0 +1,63 @@ +import { Suspense, useRef } from 'react'; + +import { + Algorithm, + GeometryRepresentation, + useViewContext, + useViewReadySuspense, + View, +} from 'react-vtk-js'; + +import vtkConeSource from '@kitware/vtk.js/Filters/Sources/ConeSource'; + +const styles = { + position: 'relative', + zIndex: 10, + textAlign: 'center', + color: 'white', +}; + +function Inner() { + const view = useViewContext(); + const renderer = view.getRenderer(); + return ( +
+ Without Suspense: has renderer = {String(!!renderer)} +
+ ); +} + +function InnerSuspense() { + const useRenderer = () => useViewContext().getRenderer(); + const renderer = useViewReadySuspense(useRenderer); + return ( +
With Suspense: has renderer = {String(!!renderer)}
+ ); +} + +// React complains about unique key prop but I don't see why +function Example() { + const view = useRef(); + const rep = useRef(); + + return ( +
+ + + + + + + + + +
+ ); +} + +export default Example;