diff --git a/packages/components/src/components/SquigglePlayground.tsx b/packages/components/src/components/SquigglePlayground.tsx index 5ca84040..a2fadb7d 100644 --- a/packages/components/src/components/SquigglePlayground.tsx +++ b/packages/components/src/components/SquigglePlayground.tsx @@ -1,7 +1,7 @@ import React, { FC, useState, useEffect, useMemo } from "react"; import { Path, useForm, UseFormRegister, useWatch } from "react-hook-form"; import * as yup from "yup"; -import { useMaybeControlledValue } from "../lib/hooks"; +import { useMaybeControlledValue, useRunnerState } from "../lib/hooks"; import { yupResolver } from "@hookform/resolvers/yup"; import { ChartSquareBarIcon, @@ -358,54 +358,18 @@ const RunControls: React.FC<{ )} ); }; -const useRunnerState = (code: string) => { - const [autorunMode, setAutorunMode] = useState(true); - const [renderedCode, setRenderedCode] = useState(code); // used in manual run mode only - const [isRunning, setIsRunning] = useState(false); // used in manual run mode only - - // This part is tricky and fragile; we need to re-render first to make sure that the icon is spinning, - // and only then evaluate the squiggle code (which freezes the UI). - // Also note that `useEffect` execution order matters here. - // Hopefully it'll all go away after we make squiggle code evaluation async. - useEffect(() => { - if (renderedCode === code && isRunning) { - // It's not possible to put this after `setRenderedCode(code)` below because React would apply - // `setIsRunning` and `setRenderedCode` together and spinning icon will disappear immediately. - setIsRunning(false); - } - }, [renderedCode, code, isRunning]); - - useEffect(() => { - if (!autorunMode && isRunning) { - setRenderedCode(code); // TODO - force run even if code hasn't changed - } - }, [autorunMode, code, isRunning]); - - const run = () => { - // The rest will be handled by useEffects above, but we need to update the spinner first. - setIsRunning(true); - }; - - return { - run, - renderedCode: autorunMode ? code : renderedCode, - isRunning, - autorunMode, - setAutorunMode: (newValue: boolean) => { - if (!newValue) setRenderedCode(code); - setAutorunMode(newValue); - }, - }; -}; - export const SquigglePlayground: FC = ({ defaultCode = "", height = 500, diff --git a/packages/components/src/components/ui/Toggle.tsx b/packages/components/src/components/ui/Toggle.tsx index 3295bda0..17ecc3f6 100644 --- a/packages/components/src/components/ui/Toggle.tsx +++ b/packages/components/src/components/ui/Toggle.tsx @@ -8,13 +8,15 @@ type Props = { onChange: (status: boolean) => void; texts: [string, string]; icons: [IconType, IconType]; + spinIcon?: boolean; }; export const Toggle: React.FC = ({ - texts: [onText, offText], - icons: [OnIcon, OffIcon], status, onChange, + texts: [onText, offText], + icons: [OnIcon, OffIcon], + spinIcon, }) => { const CurrentIcon = status ? OnIcon : OffIcon; return ( @@ -28,7 +30,7 @@ export const Toggle: React.FC = ({ onClick={() => onChange(!status)} >
- +
{status ? onText : offText} diff --git a/packages/components/src/lib/hooks/index.ts b/packages/components/src/lib/hooks/index.ts new file mode 100644 index 00000000..01fb46f9 --- /dev/null +++ b/packages/components/src/lib/hooks/index.ts @@ -0,0 +1,3 @@ +export { useMaybeControlledValue } from "./useMaybeControlledValue"; +export { useSquiggle, useSquigglePartial } from "./useSquiggle"; +export { useRunnerState } from "./useRunnerState"; diff --git a/packages/components/src/lib/hooks/useMaybeControlledValue.ts b/packages/components/src/lib/hooks/useMaybeControlledValue.ts new file mode 100644 index 00000000..aa7abc50 --- /dev/null +++ b/packages/components/src/lib/hooks/useMaybeControlledValue.ts @@ -0,0 +1,22 @@ +import { useState } from "react"; + +type ControlledValueArgs = { + value?: T; + defaultValue: T; + onChange?: (x: T) => void; +}; + +export function useMaybeControlledValue( + args: ControlledValueArgs +): [T, (x: T) => void] { + let [uncontrolledValue, setUncontrolledValue] = useState(args.defaultValue); + let value = args.value ?? uncontrolledValue; + let onChange = (newValue: T) => { + if (args.value === undefined) { + // uncontrolled mode + setUncontrolledValue(newValue); + } + args.onChange?.(newValue); + }; + return [value, onChange]; +} diff --git a/packages/components/src/lib/hooks/useRunnerState.ts b/packages/components/src/lib/hooks/useRunnerState.ts new file mode 100644 index 00000000..e12eb5ad --- /dev/null +++ b/packages/components/src/lib/hooks/useRunnerState.ts @@ -0,0 +1,92 @@ +import { useEffect, useReducer } from "react"; + +type State = { + autorunMode: boolean; + renderedCode: string; + isRunning: boolean; + executionId: number; +}; + +const buildInitialState = (code: string) => ({ + autorunMode: true, + renderedCode: code, + isRunning: false, + executionId: 0, +}); + +type Action = + | { + type: "SET_AUTORUN_MODE"; + value: boolean; + code: string; + } + | { + type: "PREPARE_RUN"; + } + | { + type: "RUN"; + code: string; + } + | { + type: "STOP_RUN"; + }; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "SET_AUTORUN_MODE": + return { + ...state, + autorunMode: action.value, + }; + case "PREPARE_RUN": + return { + ...state, + isRunning: true, + }; + case "RUN": + return { + ...state, + renderedCode: action.code, + executionId: state.executionId + 1, + }; + case "STOP_RUN": + return { + ...state, + isRunning: false, + }; + } +}; + +export const useRunnerState = (code: string) => { + const [state, dispatch] = useReducer(reducer, buildInitialState(code)); + + useEffect(() => { + if (state.isRunning) { + if (state.renderedCode !== code) { + dispatch({ type: "RUN", code }); + } else { + dispatch({ type: "STOP_RUN" }); + } + } + }, [state.isRunning, state.renderedCode, code]); + + const run = () => { + // The rest will be handled by dispatches above on following renders, but we need to update the spinner first. + dispatch({ type: "PREPARE_RUN" }); + }; + + if (state.autorunMode && state.renderedCode !== code && !state.isRunning) { + run(); + } + + return { + run, + autorunMode: state.autorunMode, + renderedCode: state.renderedCode, + isRunning: state.isRunning, + executionId: state.executionId, + setAutorunMode: (newValue: boolean) => { + dispatch({ type: "SET_AUTORUN_MODE", value: newValue, code }); + }, + }; +}; diff --git a/packages/components/src/lib/hooks.ts b/packages/components/src/lib/hooks/useSquiggle.ts similarity index 65% rename from packages/components/src/lib/hooks.ts rename to packages/components/src/lib/hooks/useSquiggle.ts index b52c23be..03b8c69d 100644 --- a/packages/components/src/lib/hooks.ts +++ b/packages/components/src/lib/hooks/useSquiggle.ts @@ -5,7 +5,7 @@ import { run, runPartial, } from "@quri/squiggle-lang"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; type SquiggleArgs> = { code: string; @@ -42,23 +42,3 @@ export const useSquigglePartial = ( export const useSquiggle = (args: SquiggleArgs>) => { return useSquiggleAny(args, run); }; - -type ControlledValueArgs = { - value?: T; - defaultValue: T; - onChange?: (x: T) => void; -}; -export function useMaybeControlledValue( - args: ControlledValueArgs -): [T, (x: T) => void] { - let [uncontrolledValue, setUncontrolledValue] = useState(args.defaultValue); - let value = args.value ?? uncontrolledValue; - let onChange = (newValue: T) => { - if (args.value === undefined) { - // uncontrolled mode - setUncontrolledValue(newValue); - } - args.onChange?.(newValue); - }; - return [value, onChange]; -}