UNPKG

8.38 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { IDisposable } from '@lumino/disposable';
5import { Message, MessageLoop } from '@lumino/messaging';
6import { ISignal, Signal } from '@lumino/signaling';
7import { Widget } from '@lumino/widgets';
8import * as React from 'react';
9import { createRoot, Root } from 'react-dom/client';
10
11type ReactRenderElement =
12 | Array<React.ReactElement<any>>
13 | React.ReactElement<any>;
14
15/**
16 * An abstract class for a Lumino widget which renders a React component.
17 */
18export abstract class ReactWidget extends Widget {
19 constructor() {
20 super();
21 }
22 /**
23 * Creates a new `ReactWidget` that renders a constant element.
24 * @param element React element to render.
25 */
26 static create(element: ReactRenderElement): ReactWidget {
27 return new (class extends ReactWidget {
28 render() {
29 return element;
30 }
31 })();
32 }
33
34 /**
35 * Render the content of this widget using the virtual DOM.
36 *
37 * This method will be called anytime the widget needs to be rendered, which
38 * includes layout triggered rendering.
39 *
40 * Subclasses should define this method and return the root React nodes here.
41 */
42 protected abstract render(): ReactRenderElement | null;
43
44 /**
45 * Called to update the state of the widget.
46 *
47 * The default implementation of this method triggers
48 * VDOM based rendering by calling the `renderDOM` method.
49 */
50 protected onUpdateRequest(msg: Message): void {
51 this.renderPromise = this.renderDOM();
52 }
53
54 /**
55 * Called after the widget is attached to the DOM
56 */
57 protected onAfterAttach(msg: Message): void {
58 // Make *sure* the widget is rendered.
59 MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest);
60 }
61
62 /**
63 * Called before the widget is detached from the DOM.
64 */
65 protected onBeforeDetach(msg: Message): void {
66 // Unmount the component so it can tear down.
67 if (this._rootDOM !== null) {
68 this._rootDOM.unmount();
69 this._rootDOM = null;
70 }
71 }
72
73 /**
74 * Render the React nodes to the DOM.
75 *
76 * @returns a promise that resolves when the rendering is done.
77 */
78 private renderDOM(): Promise<void> {
79 return new Promise<void>(resolve => {
80 const vnode = this.render();
81 if (this._rootDOM === null) {
82 this._rootDOM = createRoot(this.node);
83 }
84 // Split up the array/element cases so type inference chooses the right
85 // signature.
86 if (Array.isArray(vnode)) {
87 this._rootDOM.render(vnode);
88 // Resolves after the widget has been rendered.
89 // https://github.com/reactwg/react-18/discussions/5#discussioncomment-798304
90 requestIdleCallback(() => resolve());
91 } else if (vnode) {
92 this._rootDOM.render(vnode);
93 // Resolves after the widget has been rendered.
94 // https://github.com/reactwg/react-18/discussions/5#discussioncomment-798304
95 requestIdleCallback(() => resolve());
96 } else {
97 // If the virtual node is null, unmount the node content
98 this._rootDOM.unmount();
99 this._rootDOM = null;
100 requestIdleCallback(() => resolve());
101 }
102 });
103 }
104
105 // Set whenever a new render is triggered and resolved when it is finished.
106 renderPromise?: Promise<void>;
107 private _rootDOM: Root | null = null;
108}
109
110/**
111 * An abstract ReactWidget with a model.
112 */
113export abstract class VDomRenderer<
114 T extends VDomRenderer.IModel | null = null
115> extends ReactWidget {
116 /**
117 * Create a new VDomRenderer
118 */
119 constructor(model?: T) {
120 super();
121 this.model = (model ?? null) as unknown as T;
122 }
123 /**
124 * A signal emitted when the model changes.
125 */
126 get modelChanged(): ISignal<this, void> {
127 return this._modelChanged;
128 }
129
130 /**
131 * Set the model and fire changed signals.
132 */
133 set model(newValue: T) {
134 if (this._model === newValue) {
135 return;
136 }
137
138 if (this._model) {
139 this._model.stateChanged.disconnect(this.update, this);
140 }
141 this._model = newValue;
142 if (newValue) {
143 newValue.stateChanged.connect(this.update, this);
144 }
145 this.update();
146 this._modelChanged.emit(void 0);
147 }
148
149 /**
150 * Get the current model.
151 */
152 get model(): T {
153 return this._model;
154 }
155
156 /**
157 * Dispose this widget.
158 */
159 dispose(): void {
160 if (this.isDisposed) {
161 return;
162 }
163 this._model = null!;
164 super.dispose();
165 }
166
167 private _model: T;
168 private _modelChanged = new Signal<this, void>(this);
169}
170
171/**
172 * Props for the UseSignal component
173 */
174export interface IUseSignalProps<SENDER, ARGS> {
175 /**
176 * Phosphor signal to connect to.
177 */
178
179 signal: ISignal<SENDER, ARGS>;
180 /**
181 * Initial value to use for the sender, used before the signal emits a value.
182 * If not provided, initial sender will be undefined
183 */
184 initialSender?: SENDER;
185 /**
186 * Initial value to use for the args, used before the signal emits a value.
187 * If not provided, initial args will be undefined.
188 */
189 initialArgs?: ARGS;
190 /**
191 * Function mapping the last signal value or initial values to an element to render.
192 *
193 * Note: returns `React.ReactNode` as per
194 * https://github.com/sw-yx/react-typescript-cheatsheet#higher-order-componentsrender-props
195 */
196
197 children: (sender?: SENDER, args?: ARGS) => React.ReactNode;
198 /**
199 * Given the last signal value, should return whether to update the state or not.
200 *
201 * The default unconditionally returns `true`, so you only have to override if you want
202 * to skip some updates.
203 */
204
205 shouldUpdate?: (sender: SENDER, args: ARGS) => boolean;
206}
207
208/**
209 * State for the UseSignal component
210 */
211export interface IUseSignalState<SENDER, ARGS> {
212 value: [SENDER?, ARGS?];
213}
214
215/**
216 * UseSignal provides a way to hook up a Lumino signal to a React element,
217 * so that the element is re-rendered every time the signal fires.
218 *
219 * It is implemented through the "render props" technique, using the `children`
220 * prop as a function to render, so that it can be used either as a prop or as a child
221 * of this element
222 * https://reactjs.org/docs/render-props.html
223 *
224 *
225 * Example as child:
226 *
227 * ```
228 * function LiveButton(isActiveSignal: ISignal<any, boolean>) {
229 * return (
230 * <UseSignal signal={isActiveSignal} initialArgs={true}>
231 * {(_, isActive) => <Button isActive={isActive}>}
232 * </UseSignal>
233 * )
234 * }
235 * ```
236 *
237 * Example as prop:
238 *
239 * ```
240 * function LiveButton(isActiveSignal: ISignal<any, boolean>) {
241 * return (
242 * <UseSignal
243 * signal={isActiveSignal}
244 * initialArgs={true}
245 * children={(_, isActive) => <Button isActive={isActive}>}
246 * />
247 * )
248 * }
249 * ```
250 */
251export class UseSignal<SENDER, ARGS> extends React.Component<
252 IUseSignalProps<SENDER, ARGS>,
253 IUseSignalState<SENDER, ARGS>
254> {
255 constructor(props: IUseSignalProps<SENDER, ARGS>) {
256 super(props);
257 this.state = { value: [this.props.initialSender, this.props.initialArgs] };
258 }
259
260 componentDidMount(): void {
261 this.props.signal.connect(this.slot);
262 }
263
264 componentWillUnmount(): void {
265 this.props.signal.disconnect(this.slot);
266 }
267
268 private slot = (sender: SENDER, args: ARGS) => {
269 // skip setting new state if we have a shouldUpdate function and it returns false
270 if (this.props.shouldUpdate && !this.props.shouldUpdate(sender, args)) {
271 return;
272 }
273 this.setState({ value: [sender, args] });
274 };
275
276 render(): React.ReactNode {
277 return this.props.children(...this.state.value);
278 }
279}
280
281/**
282 * The namespace for VDomRenderer statics.
283 */
284export namespace VDomRenderer {
285 /**
286 * An interface for a model to be used with vdom rendering.
287 */
288 export interface IModel extends IDisposable {
289 /**
290 * A signal emitted when any model state changes.
291 */
292 readonly stateChanged: ISignal<this, void>;
293 }
294}
295
296/**
297 * Concrete implementation of VDomRenderer model.
298 */
299export class VDomModel implements VDomRenderer.IModel {
300 /**
301 * A signal emitted when any model state changes.
302 */
303 readonly stateChanged = new Signal<this, void>(this);
304
305 /**
306 * Test whether the model is disposed.
307 */
308 get isDisposed(): boolean {
309 return this._isDisposed;
310 }
311
312 /**
313 * Dispose the model.
314 */
315 dispose(): void {
316 if (this.isDisposed) {
317 return;
318 }
319 this._isDisposed = true;
320 Signal.clearData(this);
321 }
322
323 private _isDisposed = false;
324}