Skip to content

Commit

Permalink
feat: add view mounted suspense hook
Browse files Browse the repository at this point in the history
Add an example usage for how to use the useViewReadySuspense hook.
  • Loading branch information
floryst committed Feb 22, 2024
1 parent 822444f commit f8dc3c3
Show file tree
Hide file tree
Showing 10 changed files with 214 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/core/View.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/core/internal/ParentedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -172,9 +173,16 @@ const ParentedView = forwardRef(function ParentedView(

// --- api --- //

let mounted = false;
useMount(() => {
mounted = true;
viewMountedEvent.trigger();
});

const api = useMemo<IView>(
() => ({
isInMultiViewRoot: () => true,
isMounted: () => mounted,
getViewContainer: () => containerRef.current,
getOpenGLRenderWindow: () => openGLRenderWindowAPI,
getRenderWindow: () => renderWindowAPI,
Expand All @@ -187,6 +195,7 @@ const ParentedView = forwardRef(function ParentedView(
rendererRef.current?.resetCamera(boundsToUse),
}),
[
mounted,
openGLRenderWindowAPI,
renderWindowAPI,
getInteractorStyle,
Expand Down
10 changes: 9 additions & 1 deletion src/core/internal/SingleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -84,9 +85,16 @@ const SingleView = forwardRef(function SingleView(props: ViewProps, fwdRef) {

// --- api --- //

let mounted = false;
useMount(() => {
mounted = true;
viewMountedEvent.trigger();
});

const api = useMemo<IView>(
() => ({
isInMultiViewRoot: () => false,
isMounted: () => mounted,
getViewContainer: () =>
openGLRenderWindowRef.current?.getContainer() ?? null,
getOpenGLRenderWindow: () => openGLRenderWindowRef.current,
Expand All @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions src/core/internal/events.ts
Original file line number Diff line number Diff line change
@@ -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();
1 change: 1 addition & 0 deletions src/suspense/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useViewReadySuspense';
38 changes: 38 additions & 0 deletions src/suspense/useViewReadySuspense.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useContext } 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<T>(getter: () => T): T {
const view = useContext(Contexts.ViewContext);
if (!view) throw new Error('No view context');

let status = 'pending' as Status;
const deferred = makeDeferred<void>();

if (view.isMounted()) {
status = 'success';
} else {
viewMountedEvent.once(() => {
status = 'success';
deferred.resolve();
});
}

switch (status) {
case 'success':
return getter();
case 'pending':
throw deferred.promise;
case 'error':
default:
throw new Error('Unexpected unreachable code execution');
}
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface IRenderer {

export interface IView {
isInMultiViewRoot(): boolean;
isMounted(): boolean;
getViewContainer(): HTMLElement | null;
getRenderer(): IRenderer | null;
getRenderWindow(): IRenderWindow | null;
Expand Down
12 changes: 12 additions & 0 deletions src/utils/deferred.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// eslint-disable-next-line @typescript-eslint/no-empty-function
const empty = () => {};

export function makeDeferred<T>() {
let resolve: (value: T) => void = empty;
let reject: (error: unknown) => void = empty;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { resolve, reject, promise };
}
4 changes: 4 additions & 0 deletions usage/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const demos = new Map([
lazy(() => import('./Tests/ChangeInteractorStyle')),
],
['MultiView', lazy(() => import('./MultiView'))],
[
'Suspense/GeometrySuspense',
lazy(() => import('./Suspense/GeometrySuspense')),
],
]);

function App() {
Expand Down
106 changes: 106 additions & 0 deletions usage/src/Suspense/GeometrySuspense.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Suspense, useRef, useState } from 'react';

import {
Algorithm,
DataArray,
GeometryRepresentation,
PointData,
PolyData,
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 (
<div style={styles}>
Without Suspense: has renderer = {String(!!renderer)}
</div>
);
}

function InnerSuspense() {
const useRenderer = () => useViewContext().getRenderer();
const renderer = useViewReadySuspense(useRenderer);
return (
<div style={styles}>With Suspense: has renderer = {String(!!renderer)}</div>
);
}

// React complains about unique key prop but I don't see why
function Example() {
const view = useRef();
const rep = useRef();

const [height, setHeight] = useState(1);
const [colorByName, setColorByName] = useState('Temperature');
const [preset, setPreset] = useState('Black-Body Radiation');
const [range, setRange] = useState([0, 0.7]);

const randomize = () => {
setHeight(Math.random() * 3);
setColorByName(Math.random() < 0.5 ? 'Temperature' : 'Pressure');
setPreset(Math.random() < 0.5 ? 'Black-Body Radiation' : 'Cool to Warm');
setRange([0, Math.random()]);
};

return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<View ref={view}>
<Inner />
<Suspense>
<InnerSuspense />
</Suspense>
<GeometryRepresentation ref={rep}>
<Algorithm
vtkClass={vtkConeSource}
state={{
height,
}}
/>
</GeometryRepresentation>
<GeometryRepresentation
ref={rep}
mapper={{
colorByArrayName: colorByName,
scalarMode: colorByName === 'Temperature' ? 0 : 3,
interpolateScalarsBeforeMapping: true,
}}
colorDataRange={range}
colorMapPreset={preset}
showScalarBar
>
<PolyData
points={[0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]}
polys={[4, 0, 3, 2, 1]}
>
<PointData>
<DataArray
registration='setScalars'
name='Temperature'
values={[0, 0.7, 0.3, 1]}
/>
<DataArray name='Pressure' values={[0.5, 0.3, 0.7, 0]} />
</PointData>
</PolyData>
</GeometryRepresentation>
</View>
<div style={{ position: 'absolute', top: 0 }}>
<button onClick={randomize}>Randomize</button>
</div>
</div>
);
}

export default Example;

0 comments on commit f8dc3c3

Please sign in to comment.