diff --git a/website/.eslintrc.js b/website/.eslintrc.js index 409364b0..09098546 100644 --- a/website/.eslintrc.js +++ b/website/.eslintrc.js @@ -1,5 +1,9 @@ module.exports = { + extends: ["plugin:react-hooks/recommended"], parserOptions: { project: `${__dirname}/tsconfig.json`, }, -} + rules: { + "react-hooks/exhaustive-deps": "error", + }, +}; diff --git a/website/package.json b/website/package.json index 7653a588..5a955fd4 100644 --- a/website/package.json +++ b/website/package.json @@ -28,9 +28,9 @@ "monaco-editor": "^0.33.0", "monaco-editor-webpack-plugin": "^7.0.1", "process": "^0.11.10", - "react": "^16.11.0", + "react": "^18.2.0", "react-dev-utils": "^12.0.0-next.47", - "react-dom": "^16.11.0", + "react-dom": "^18.2.0", "react-hot-loader": "^4.12.16", "react-monaco-editor": "^0.48.0", "react-virtualized-auto-sizer": "^1.0.6", @@ -78,5 +78,8 @@ "jsx", "node" ] + }, + "devDependencies": { + "eslint-plugin-react-hooks": "^4.6.0" } } diff --git a/website/src/App.tsx b/website/src/App.tsx index d62c2363..044debe5 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -1,5 +1,5 @@ import {css, StyleSheet} from "aphrodite"; -import {Component} from "react"; +import {useEffect, useRef, useState} from "react"; import {hot} from "react-hot-loader/root"; import CompareOptionsBox from "./CompareOptionsBox"; @@ -9,211 +9,179 @@ import { DEFAULT_COMPARE_OPTIONS, DEFAULT_OPTIONS, type CompareOptions, - type HydratedOptions, INITIAL_CODE, } from "./Constants"; import DebugOptionsBox from "./DebugOptionsBox"; import EditorWrapper from "./EditorWrapper"; import SucraseOptionsBox from "./SucraseOptionsBox"; -import {loadHashState, saveHashState} from "./URLHashState"; +import {type BaseHashState, loadHashState, saveHashState} from "./URLHashState"; import * as WorkerClient from "./WorkerClient"; +import {type StateUpdate} from "./WorkerClient"; -interface State { - code: string; - sucraseOptions: HydratedOptions; - compareOptions: CompareOptions; - debugOptions: DebugOptions; - sucraseCode: string; - sucraseTimeMs: number | null | "LOADING"; - babelCode: string; - babelTimeMs: number | null | "LOADING"; - typeScriptCode: string; - typeScriptTimeMs: number | null | "LOADING"; - tokensStr: string; - sourceMapStr: string; - showMore: boolean; - babelLoaded: boolean; - typeScriptLoaded: boolean; -} - -class App extends Component { - constructor(props: unknown) { - super(props); - this.state = { - code: INITIAL_CODE, - sucraseOptions: DEFAULT_OPTIONS, - compareOptions: DEFAULT_COMPARE_OPTIONS, - debugOptions: DEFAULT_DEBUG_OPTIONS, - sucraseCode: "", - sucraseTimeMs: null, - babelCode: "", - babelTimeMs: null, - typeScriptCode: "", - typeScriptTimeMs: null, - tokensStr: "", - sourceMapStr: "", - showMore: false, - babelLoaded: false, - typeScriptLoaded: false, - }; - const hashState = loadHashState(); - if (hashState) { - this.state = {...this.state, ...hashState}; +function App(): JSX.Element { + const cachedHashState = useRef("NOT_LOADED"); + function hashState(): BaseHashState | null { + if (cachedHashState.current === "NOT_LOADED") { + cachedHashState.current = loadHashState(); } + return cachedHashState.current; } - componentDidMount(): void { - WorkerClient.subscribe({ + const [code, setCode] = useState(hashState()?.code ?? INITIAL_CODE); + const [sucraseOptions, setSucraseOptions] = useState( + hashState()?.sucraseOptions ?? DEFAULT_OPTIONS, + ); + const [compareOptions, setCompareOptions] = useState( + hashState()?.compareOptions ?? DEFAULT_COMPARE_OPTIONS, + ); + const [debugOptions, setDebugOptions] = useState( + hashState()?.debugOptions ?? DEFAULT_DEBUG_OPTIONS, + ); + const [sucraseCode, setSucraseCode] = useState(""); + const [sucraseTimeMs, setSucraseTimeMs] = useState(null); + const [babelCode, setBabelCode] = useState(""); + const [babelTimeMs, setBabelTimeMs] = useState(null); + const [typeScriptCode, setTypeScriptCode] = useState(""); + const [typeScriptTimeMs, setTypeScriptTimeMs] = useState(null); + const [tokensStr, setTokensStr] = useState(""); + const [sourceMapStr, setSourceMapStr] = useState(""); + const [babelLoaded, setBabelLoaded] = useState(false); + const [typeScriptLoaded, setTypeScriptLoaded] = useState(false); + + useEffect(() => { + WorkerClient.updateHandlers({ updateState: (stateUpdate) => { - this.setState((state) => ({...state, ...stateUpdate})); + const setters: { + [k in keyof StateUpdate]-?: (newValue: Exclude) => void; + } = { + sucraseCode: setSucraseCode, + babelCode: setBabelCode, + typeScriptCode: setTypeScriptCode, + tokensStr: setTokensStr, + sourceMapStr: setSourceMapStr, + sucraseTimeMs: setSucraseTimeMs, + babelTimeMs: setBabelTimeMs, + typeScriptTimeMs: setTypeScriptTimeMs, + babelLoaded: setBabelLoaded, + typeScriptLoaded: setTypeScriptLoaded, + }; + // The above mapping ensures we list all properties in StateUpdate with the right types. + // Use escape hatches for actually setting the properties. + for (const [key, setter] of Object.entries(setters)) { + if (stateUpdate[key as keyof StateUpdate] !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (setter as any)(stateUpdate[key as keyof StateUpdate]); + } + } }, handleCompressedCode: (compressedCode) => { saveHashState({ - code: this.state.code, + code, compressedCode, - sucraseOptions: this.state.sucraseOptions, - compareOptions: this.state.compareOptions, - debugOptions: this.state.debugOptions, + sucraseOptions, + compareOptions, + debugOptions, }); }, }); - this.postConfigToWorker(); - } - - componentDidUpdate(prevProps: unknown, prevState: State): void { - if ( - this.state.code !== prevState.code || - this.state.sucraseOptions !== prevState.sucraseOptions || - this.state.compareOptions !== prevState.compareOptions || - this.state.debugOptions !== prevState.debugOptions || - this.state.babelLoaded !== prevState.babelLoaded || - this.state.typeScriptLoaded !== prevState.typeScriptLoaded - ) { - this.postConfigToWorker(); - } - } + }, [code, sucraseOptions, compareOptions, debugOptions]); - postConfigToWorker(): void { - this.setState({sucraseTimeMs: "LOADING", babelTimeMs: "LOADING", typeScriptTimeMs: "LOADING"}); - WorkerClient.updateConfig({ - code: this.state.code, - sucraseOptions: this.state.sucraseOptions, - compareOptions: this.state.compareOptions, - debugOptions: this.state.debugOptions, - }); - } + // On any change to code, config, or loading state, kick off a worker task to re-calculate. + useEffect(() => { + setSucraseTimeMs("LOADING"); + setBabelTimeMs("LOADING"); + setTypeScriptTimeMs("LOADING"); + WorkerClient.updateConfig({code, sucraseOptions, compareOptions, debugOptions}); + }, [code, sucraseOptions, compareOptions, debugOptions, babelLoaded, typeScriptLoaded]); - _handleCodeChange = (newCode: string): void => { - this.setState({ - code: newCode, - }); - }; + return ( +
+ Sucrase + + Super-fast Babel alternative + {" | "} + + GitHub + + +
+ { + setSucraseOptions(newSucraseOptions); + }} + /> + { + setCompareOptions(newCompareOptions); + }} + /> + { + setDebugOptions(newDebugOptions); + }} + /> +
- render(): JSX.Element { - const { - sucraseCode, - sucraseTimeMs, - babelCode, - babelTimeMs, - typeScriptCode, - typeScriptTimeMs, - tokensStr, - sourceMapStr, - } = this.state; - return ( -
- Sucrase - - Super-fast Babel alternative - {" | "} - - GitHub - - -
- { - this.setState({sucraseOptions}); - }} - /> - { - this.setState({compareOptions}); - }} +
+ + + {compareOptions.compareWithBabel && ( + - { - this.setState({debugOptions}); - }} + )} + {compareOptions.compareWithTypeScript && ( + -
- -
+ )} + {debugOptions.showTokens && ( (n > 1 ? String(n - 2) : ""), + }} + babelLoaded={babelLoaded} /> + )} + {debugOptions.showSourceMap && ( - {this.state.compareOptions.compareWithBabel && ( - - )} - {this.state.compareOptions.compareWithTypeScript && ( - - )} - {this.state.debugOptions.showTokens && ( - (n > 1 ? String(n - 2) : ""), - }} - babelLoaded={this.state.babelLoaded} - /> - )} - {this.state.debugOptions.showSourceMap && ( - - )} -
- - - sucrase - {" "} - {process.env.SUCRASE_VERSION} - + )}
- ); - } + + + sucrase + {" "} + {process.env.SUCRASE_VERSION} + +
+ ); } export default hot(App); diff --git a/website/src/Editor.tsx b/website/src/Editor.tsx index ebb23e17..f3e06da3 100644 --- a/website/src/Editor.tsx +++ b/website/src/Editor.tsx @@ -1,7 +1,6 @@ import type {editor} from "monaco-editor"; -import {Component} from "react"; +import {useCallback, useEffect, useRef} from "react"; import type MonacoEditor from "react-monaco-editor"; -import type {EditorDidMount} from "react-monaco-editor"; interface EditorProps { MonacoEditor: typeof MonacoEditor; @@ -12,49 +11,51 @@ interface EditorProps { options?: editor.IEditorConstructionOptions; width: number; height: number; + onMount: (editor: editor.IStandaloneCodeEditor) => void; } -export default class Editor extends Component { - editor: editor.IStandaloneCodeEditor | null = null; +export default function Editor({ + MonacoEditor, + code, + onChange, + isReadOnly, + isPlaintext, + options, + width, + height, +}: EditorProps): JSX.Element { + const editorRef = useRef(null); - componentDidMount(): void { - setTimeout(this.invalidate, 0); - } - - _editorDidMount: EditorDidMount = (monacoEditor, monaco) => { - this.editor = monacoEditor; + const editorDidMount = useCallback((monacoEditor, monaco) => { + editorRef.current = monacoEditor; monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ noSemanticValidation: true, noSyntaxValidation: true, noSuggestionDiagnostics: true, }); - this.invalidate(); - }; + monacoEditor.layout(); + }, []); - invalidate = (): void => { - if (this.editor) { - this.editor.layout(); - } - }; + useEffect(() => { + setTimeout(() => { + editorRef.current?.layout(); + }, 0); + }, []); - render(): JSX.Element { - const {MonacoEditor, code, onChange, isReadOnly, isPlaintext, options, width, height} = - this.props; - return ( - - ); - } + return ( + + ); } diff --git a/website/src/EditorWrapper.tsx b/website/src/EditorWrapper.tsx index cb1de7ca..70401f81 100644 --- a/website/src/EditorWrapper.tsx +++ b/website/src/EditorWrapper.tsx @@ -1,7 +1,7 @@ import {css, StyleSheet} from "aphrodite"; import type {editor} from "monaco-editor"; -import {Component} from "react"; -import type MonacoEditor from "react-monaco-editor"; +import {useEffect, useRef, useState} from "react"; +import type * as MonacoEditorModule from "react-monaco-editor"; import AutoSizer from "react-virtualized-auto-sizer"; import Editor from "./Editor"; @@ -18,31 +18,32 @@ interface EditorWrapperProps { babelLoaded: boolean; } -interface State { - MonacoEditor: typeof MonacoEditor | null; -} - -export default class EditorWrapper extends Component { - state: State = { - MonacoEditor: null, - }; +export default function EditorWrapper({ + label, + code, + onChange, + isReadOnly, + isPlaintext, + options, + timeMs, + babelLoaded, +}: EditorWrapperProps): JSX.Element { + const innerEditor = useRef(null); - editor: Editor | null = null; + const [monacoEditorModule, setMonacoEditorModule] = useState( + null, + ); - async componentDidUpdate(prevProps: EditorWrapperProps): Promise { - if (this.props.babelLoaded && !this.state.MonacoEditor) { - this.setState({MonacoEditor: (await import("react-monaco-editor")).default}); - } - } + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + if (babelLoaded && !monacoEditorModule) { + setMonacoEditorModule(await import("react-monaco-editor")); + } + })(); + }, [babelLoaded, monacoEditorModule]); - invalidate = (): void => { - if (this.editor) { - this.editor.invalidate(); - } - }; - - _formatTime(): string { - const {timeMs} = this.props; + function formatTime(): string { if (timeMs == null) { return ""; } else if (timeMs === "LOADING") { @@ -52,50 +53,50 @@ export default class EditorWrapper extends Component } } - render(): JSX.Element { - const {MonacoEditor} = this.state; - const {label, code, onChange, isReadOnly, isPlaintext, options} = this.props; - return ( -
- - {label} - {this._formatTime()} - - - - { - // TODO: The explicit type params can be removed once we're on TS 5.1 - // https://github.com/bvaughn/react-virtualized-auto-sizer/issues/63 - ({width, height}: {width: number; height: number}) => - MonacoEditor ? ( - { - this.editor = e; - }} - MonacoEditor={MonacoEditor} - width={width} - height={height - 30} - code={code} - onChange={onChange} - isPlaintext={isPlaintext} - isReadOnly={isReadOnly} - options={options} - /> - ) : ( - - ) - } - - -
- ); + function invalidate(): void { + innerEditor.current?.layout(); } + + return ( +
+ + {label} + {formatTime()} + + + + { + // TODO: The explicit type params can be removed once we're on TS 5.1 + // https://github.com/bvaughn/react-virtualized-auto-sizer/issues/63 + ({width, height}: {width: number; height: number}) => + monacoEditorModule ? ( + { + innerEditor.current = editor; + }} + MonacoEditor={monacoEditorModule.default} + width={width} + height={height - 30} + code={code} + onChange={onChange} + isPlaintext={isPlaintext} + isReadOnly={isReadOnly} + options={options} + /> + ) : ( + + ) + } + + +
+ ); } const styles = StyleSheet.create({ diff --git a/website/src/URLHashState.ts b/website/src/URLHashState.ts index ae2fff9b..38ab20e4 100644 --- a/website/src/URLHashState.ts +++ b/website/src/URLHashState.ts @@ -14,7 +14,7 @@ import { } from "./Constants"; import {entriesExact, hasKeyExact} from "./Util"; -interface BaseHashState { +export interface BaseHashState { code: string; sucraseOptions: HydratedOptions; compareOptions: CompareOptions; diff --git a/website/src/WorkerClient.ts b/website/src/WorkerClient.ts index 68413a31..00b7055c 100644 --- a/website/src/WorkerClient.ts +++ b/website/src/WorkerClient.ts @@ -18,7 +18,7 @@ let nextConfig: WorkerConfig | null = null; // Function to be called when the config is set. let notifyConfig: (() => void) | null = null; -type UpdateStateFunc = (values: { +export interface StateUpdate { sucraseCode?: string; babelCode?: string; typeScriptCode?: string; @@ -29,7 +29,9 @@ type UpdateStateFunc = (values: { typeScriptTimeMs?: number | null; babelLoaded?: boolean; typeScriptLoaded?: boolean; -}) => void; +} + +type UpdateStateFunc = (stateUpdate: StateUpdate) => void; // Callback function to update the main app state. Just forwards the object to // setState in the App component. @@ -231,7 +233,7 @@ export function updateConfig(config: WorkerConfig): void { } } -export function subscribe({ +export function updateHandlers({ updateState, handleCompressedCode, }: { diff --git a/website/yarn.lock b/website/yarn.lock index d21e6a8a..21f02696 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2287,6 +2287,11 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" +eslint-plugin-react-hooks@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" + integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== + eslint-scope@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" @@ -4882,7 +4887,7 @@ prompts@^2.0.1, prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.8.1: +prop-types@^15.6.1, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -4986,15 +4991,13 @@ react-dev-utils@^12.0.0-next.47: strip-ansi "^6.0.1" text-table "^0.2.0" -react-dom@^16.11.0: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" - integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== +react-dom@^18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.19.1" + scheduler "^0.23.0" react-error-overlay@^6.0.11: version "6.0.11" @@ -5042,14 +5045,12 @@ react-virtualized-auto-sizer@^1.0.6: resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.20.tgz#d9a907253a7c221c52fa57dc775a6ef40c182645" integrity sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA== -react@^16.11.0: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" - integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== +react@^18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" read-pkg-up@^7.0.1: version "7.0.1" @@ -5302,13 +5303,12 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" -scheduler@^0.19.1: - version "0.19.1" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" - integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== +scheduler@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" schema-utils@2.7.0: version "2.7.0"