Skip to content

Commit d47f388

Browse files
committed
feat: add view mounted suspense hook
Add an example usage for how to use the useViewReadySuspense hook.
1 parent 822444f commit d47f388

File tree

10 files changed

+171
-1
lines changed

10 files changed

+171
-1
lines changed

src/core/View.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export default forwardRef(function View(props: ViewProps, fwdRef) {
2424
multiViewRoot ? parentedViewRef.current : singleViewRef.current;
2525
return {
2626
isInMultiViewRoot: () => multiViewRoot,
27+
isMounted: () => getView()?.isMounted() ?? false,
2728
getViewContainer: () => getView()?.getViewContainer() ?? null,
2829
getOpenGLRenderWindow: () => getView()?.getOpenGLRenderWindow() ?? null,
2930
getRenderWindow: () => getView()?.getRenderWindow() ?? null,

src/core/internal/ParentedView.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../modules/useInteractorStyle';
2828
import useViewEvents, { ViewEvents } from '../modules/useViewEvents';
2929
import Renderer from '../Renderer';
30+
import { viewMountedEvent } from './events';
3031
import { DefaultProps, ViewProps } from './view-shared';
3132

3233
/**
@@ -172,9 +173,16 @@ const ParentedView = forwardRef(function ParentedView(
172173

173174
// --- api --- //
174175

176+
let mounted = false;
177+
useMount(() => {
178+
mounted = true;
179+
viewMountedEvent.trigger();
180+
});
181+
175182
const api = useMemo<IView>(
176183
() => ({
177184
isInMultiViewRoot: () => true,
185+
isMounted: () => mounted,
178186
getViewContainer: () => containerRef.current,
179187
getOpenGLRenderWindow: () => openGLRenderWindowAPI,
180188
getRenderWindow: () => renderWindowAPI,
@@ -187,6 +195,7 @@ const ParentedView = forwardRef(function ParentedView(
187195
rendererRef.current?.resetCamera(boundsToUse),
188196
}),
189197
[
198+
mounted,
190199
openGLRenderWindowAPI,
191200
renderWindowAPI,
192201
getInteractorStyle,

src/core/internal/SingleView.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import useViewEvents, { ViewEvents } from '../modules/useViewEvents';
2424
import OpenGLRenderWindow from '../OpenGLRenderWindow';
2525
import Renderer from '../Renderer';
2626
import RenderWindow from '../RenderWindow';
27+
import { viewMountedEvent } from './events';
2728
import { DefaultProps, ViewProps } from './view-shared';
2829

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

8586
// --- api --- //
8687

88+
let mounted = false;
89+
useMount(() => {
90+
mounted = true;
91+
viewMountedEvent.trigger();
92+
});
93+
8794
const api = useMemo<IView>(
8895
() => ({
8996
isInMultiViewRoot: () => false,
97+
isMounted: () => mounted,
9098
getViewContainer: () =>
9199
openGLRenderWindowRef.current?.getContainer() ?? null,
92100
getOpenGLRenderWindow: () => openGLRenderWindowRef.current,
@@ -99,7 +107,7 @@ const SingleView = forwardRef(function SingleView(props: ViewProps, fwdRef) {
99107
resetCamera: (boundsToUse?: Bounds) =>
100108
rendererRef.current?.resetCamera(boundsToUse),
101109
}),
102-
[getInteractorStyle, setInteractorStyle]
110+
[mounted, getInteractorStyle, setInteractorStyle]
103111
);
104112

105113
useImperativeHandle(fwdRef, () => api);

src/core/internal/events.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export type EventListener = (...args: any[]) => void;
2+
3+
function createEvent() {
4+
const callbacks: EventListener[] = [];
5+
6+
const off = (callback: EventListener) => {
7+
const idx = callbacks.indexOf(callback);
8+
if (idx === -1) return;
9+
callbacks.splice(idx, 1);
10+
};
11+
12+
const on = (callback: EventListener) => {
13+
callbacks.push(callback);
14+
return () => off(callback);
15+
};
16+
17+
const once = (callback: EventListener) => {
18+
const stop = on(() => {
19+
stop();
20+
callback();
21+
});
22+
};
23+
24+
const trigger = (...args: any[]) => {
25+
callbacks.forEach((cb) => {
26+
cb(...args);
27+
});
28+
};
29+
30+
return { off, on, once, trigger };
31+
}
32+
33+
export const viewMountedEvent = createEvent();

src/suspense/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useViewReadySuspense';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useContext, useRef } from 'react';
2+
import { Contexts } from '..';
3+
import { viewMountedEvent } from '../core/internal/events';
4+
import { makeDeferred } from '../utils/deferred';
5+
6+
type Status = 'pending' | 'error' | 'success';
7+
8+
/**
9+
* A suspense-aware hook that waits for the containing View to be mounted before evaluating the getter.
10+
* @param getter
11+
* @returns
12+
*/
13+
export function useViewReadySuspense<T>(getter: () => T): T {
14+
const view = useContext(Contexts.ViewContext);
15+
if (!view) throw new Error('No view context');
16+
17+
let status = 'pending' as Status;
18+
const deferred = useRef(makeDeferred<void>());
19+
20+
if (view.isMounted()) {
21+
status = 'success';
22+
} else {
23+
viewMountedEvent.once(() => {
24+
status = 'success';
25+
deferred.current.resolve();
26+
});
27+
}
28+
29+
switch (status) {
30+
case 'success':
31+
return getter();
32+
case 'pending':
33+
throw deferred.current.promise;
34+
case 'error':
35+
default:
36+
throw new Error('Unexpected unreachable code execution');
37+
}
38+
}

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface IRenderer {
5656

5757
export interface IView {
5858
isInMultiViewRoot(): boolean;
59+
isMounted(): boolean;
5960
getViewContainer(): HTMLElement | null;
6061
getRenderer(): IRenderer | null;
6162
getRenderWindow(): IRenderWindow | null;

src/utils/deferred.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// eslint-disable-next-line @typescript-eslint/no-empty-function
2+
const empty = () => {};
3+
4+
export function makeDeferred<T>() {
5+
let resolve: (value: T) => void = empty;
6+
let reject: (error: unknown) => void = empty;
7+
const promise = new Promise<T>((_resolve, _reject) => {
8+
resolve = _resolve;
9+
reject = _reject;
10+
});
11+
return { resolve, reject, promise };
12+
}

usage/src/App.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const demos = new Map([
4545
lazy(() => import('./Tests/ChangeInteractorStyle')),
4646
],
4747
['MultiView', lazy(() => import('./MultiView'))],
48+
[
49+
'Suspense/GeometrySuspense',
50+
lazy(() => import('./Suspense/GeometrySuspense')),
51+
],
4852
]);
4953

5054
function App() {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Suspense, useRef } from 'react';
2+
3+
import {
4+
Algorithm,
5+
GeometryRepresentation,
6+
useViewContext,
7+
useViewReadySuspense,
8+
View,
9+
} from 'react-vtk-js';
10+
11+
import vtkConeSource from '@kitware/vtk.js/Filters/Sources/ConeSource';
12+
13+
const styles = {
14+
position: 'relative',
15+
zIndex: 10,
16+
textAlign: 'center',
17+
color: 'white',
18+
};
19+
20+
function Inner() {
21+
const view = useViewContext();
22+
const renderer = view.getRenderer();
23+
return (
24+
<div style={styles}>
25+
Without Suspense: has renderer = {String(!!renderer)}
26+
</div>
27+
);
28+
}
29+
30+
function InnerSuspense() {
31+
const useRenderer = () => useViewContext().getRenderer();
32+
const renderer = useViewReadySuspense(useRenderer);
33+
return (
34+
<div style={styles}>With Suspense: has renderer = {String(!!renderer)}</div>
35+
);
36+
}
37+
38+
// React complains about unique key prop but I don't see why
39+
function Example() {
40+
const view = useRef();
41+
const rep = useRef();
42+
43+
return (
44+
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
45+
<View ref={view}>
46+
<Inner />
47+
<Suspense>
48+
<InnerSuspense />
49+
</Suspense>
50+
<GeometryRepresentation ref={rep}>
51+
<Algorithm
52+
vtkClass={vtkConeSource}
53+
state={{
54+
height: 1,
55+
}}
56+
/>
57+
</GeometryRepresentation>
58+
</View>
59+
</div>
60+
);
61+
}
62+
63+
export default Example;

0 commit comments

Comments
 (0)