From 9fbb9867bca1aa6e8bbc9ab96cb00845cbb54ce5 Mon Sep 17 00:00:00 2001 From: Jake Goulding Date: Wed, 7 Sep 2022 14:06:38 -0400 Subject: [PATCH 1/5] Install Redux Toolkit --- ui/frontend/actions.ts | 2 +- ui/frontend/configureStore.ts | 24 +++++++++++++----------- ui/frontend/declarations.d.ts | 1 - ui/frontend/package.json | 4 +--- ui/frontend/reducers/index.ts | 2 +- ui/frontend/reducers/output/index.ts | 2 +- ui/frontend/selectors/index.ts | 2 +- ui/frontend/uss-router/index.ts | 15 ++++++++++----- ui/frontend/websocketMiddleware.ts | 2 +- ui/frontend/yarn.lock | 21 ++++++++++++++++++--- 10 files changed, 47 insertions(+), 28 deletions(-) diff --git a/ui/frontend/actions.ts b/ui/frontend/actions.ts index e0fc3334b..1d1c741ce 100644 --- a/ui/frontend/actions.ts +++ b/ui/frontend/actions.ts @@ -1,5 +1,5 @@ import fetch from 'isomorphic-fetch'; -import { ThunkAction as ReduxThunkAction } from 'redux-thunk'; +import { ThunkAction as ReduxThunkAction } from '@reduxjs/toolkit'; import { z } from 'zod'; import { diff --git a/ui/frontend/configureStore.ts b/ui/frontend/configureStore.ts index 6021d5e61..e50630918 100644 --- a/ui/frontend/configureStore.ts +++ b/ui/frontend/configureStore.ts @@ -1,13 +1,14 @@ import { merge } from 'lodash-es'; -import { applyMiddleware, compose, createStore } from 'redux'; import { useDispatch } from 'react-redux'; -import thunk, { ThunkDispatch } from 'redux-thunk'; +import { configureStore as reduxConfigureStore } from '@reduxjs/toolkit'; +import { produce } from 'immer'; +import type {} from 'redux-thunk/extend-redux'; -import { Action, initializeApplication } from './actions'; +import { initializeApplication } from './actions'; import initializeLocalStorage from './local_storage'; import initializeSessionStorage from './session_storage'; -import playgroundApp, { State } from './reducers'; import { websocketMiddleware } from './websocketMiddleware'; +import reducer from './reducers'; export default function configureStore(window: Window) { const baseUrl = new URL('/', window.location.href).href; @@ -18,22 +19,23 @@ export default function configureStore(window: Window) { baseUrl, }, }; - const initialAppState = playgroundApp(undefined, initializeApplication()); + const initialAppState = reducer(undefined, initializeApplication()); const localStorage = initializeLocalStorage(); const sessionStorage = initializeSessionStorage(); - const initialState = merge( + const preloadedState = produce(initialAppState, (initialAppState) => merge( initialAppState, initialGlobalState, localStorage.initialState, sessionStorage.initialState, - ); + )); - const middlewares = applyMiddleware, {}>(thunk, websocket); - const composeEnhancers: typeof compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; - const enhancers = composeEnhancers(middlewares); - const store = createStore(playgroundApp, initialState, enhancers); + const store = reduxConfigureStore({ + reducer, + preloadedState, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(websocket), + }) store.subscribe(() => { const state = store.getState(); diff --git a/ui/frontend/declarations.d.ts b/ui/frontend/declarations.d.ts index f2d781c94..36a48be6f 100644 --- a/ui/frontend/declarations.d.ts +++ b/ui/frontend/declarations.d.ts @@ -17,7 +17,6 @@ declare const ACE_KEYBINDINGS: string[]; declare const ACE_THEMES: string[]; interface Window { - __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any; rustPlayground: { setCode(code: string): void; disableSyncChangesToStorage(): void; diff --git a/ui/frontend/package.json b/ui/frontend/package.json index 435357730..481cf69f1 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -5,6 +5,7 @@ "main": "index.js", "dependencies": { "@floating-ui/react": "^0.22.2", + "@reduxjs/toolkit": "^1.8.5", "ace-builds": "^1.4.4", "common-tags": "^1.8.0", "core-js": "^3.1.3", @@ -22,10 +23,7 @@ "react-prism": "^4.0.0", "react-redux": "^8.0.2", "react-shadow": "^20.0.0", - "redux": "^4.0.0", - "redux-thunk": "^2.1.0", "regenerator-runtime": "^0.13.2", - "reselect": "^4.0.0", "route-parser": "^0.0.5", "split-grid": "^1.0.9", "suspend-react": "^0.0.9", diff --git a/ui/frontend/reducers/index.ts b/ui/frontend/reducers/index.ts index a8063ea3c..e7cb4b021 100644 --- a/ui/frontend/reducers/index.ts +++ b/ui/frontend/reducers/index.ts @@ -1,4 +1,4 @@ -import { combineReducers } from 'redux'; +import { combineReducers } from '@reduxjs/toolkit'; import browser from './browser'; import code from './code'; diff --git a/ui/frontend/reducers/output/index.ts b/ui/frontend/reducers/output/index.ts index 8942a967a..cb5358251 100644 --- a/ui/frontend/reducers/output/index.ts +++ b/ui/frontend/reducers/output/index.ts @@ -1,4 +1,4 @@ -import { combineReducers } from 'redux'; +import { combineReducers } from '@reduxjs/toolkit'; import assembly from './assembly'; import clippy from './clippy'; diff --git a/ui/frontend/selectors/index.ts b/ui/frontend/selectors/index.ts index c8be3f5fb..49985f0c7 100644 --- a/ui/frontend/selectors/index.ts +++ b/ui/frontend/selectors/index.ts @@ -1,5 +1,5 @@ import { source } from 'common-tags'; -import { createSelector } from 'reselect'; +import { createSelector } from '@reduxjs/toolkit'; import { State } from '../reducers'; import { diff --git a/ui/frontend/uss-router/index.ts b/ui/frontend/uss-router/index.ts index bded16daa..fdcd9b315 100644 --- a/ui/frontend/uss-router/index.ts +++ b/ui/frontend/uss-router/index.ts @@ -1,7 +1,6 @@ import { isEqual } from 'lodash-es'; -import { createStore, Reducer, Store, Action, PreloadedState } from 'redux'; +import { configureStore, CombinedState, ThunkAction, Reducer, Store, Action, PreloadedState } from '@reduxjs/toolkit'; import { BrowserHistory, Location, Path } from 'history'; -import { ThunkAction } from 'redux-thunk'; export type PlainOrThunk> = A | ThunkAction; @@ -68,9 +67,15 @@ export function createRouter>({ return { provisionalLocation: (makeAction: () => A) => { const state = store.getState(); - // This is a hack -- we know that our fully-constructed state is - // valid as a "preloaded" state for a brand new store! - const tempStore = createStore(reducer, state as PreloadedState); + + const tempStore = configureStore({ + reducer, + // This is a hack -- we know that our fully-constructed state is + // valid as a "preloaded" state for a brand new store! + preloadedState: state as PreloadedState>, + devTools: false, + }); + const action = makeAction(); tempStore.dispatch(action); const maybeState = tempStore.getState(); diff --git a/ui/frontend/websocketMiddleware.ts b/ui/frontend/websocketMiddleware.ts index c2bfe9fa4..ce979ef11 100644 --- a/ui/frontend/websocketMiddleware.ts +++ b/ui/frontend/websocketMiddleware.ts @@ -1,4 +1,4 @@ -import { Middleware } from 'redux'; +import { Middleware } from '@reduxjs/toolkit'; import { z } from 'zod'; import { diff --git a/ui/frontend/yarn.lock b/ui/frontend/yarn.lock index e44e03a84..36a06b528 100644 --- a/ui/frontend/yarn.lock +++ b/ui/frontend/yarn.lock @@ -1407,6 +1407,16 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@reduxjs/toolkit@^1.8.5": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.3.tgz#27e1a33072b5a312e4f7fa19247fec160bbb2df9" + integrity sha512-GU2TNBQVofL09VGmuSioNPQIu6Ml0YLf4EJhgj0AvBadRlCGzUWet8372LjvO4fqKZF2vH1xU0htAa7BrK9pZg== + dependencies: + immer "^9.0.16" + redux "^4.2.0" + redux-thunk "^2.4.2" + reselect "^4.1.7" + "@sinclair/typebox@^0.25.16": version "0.25.24" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" @@ -3570,6 +3580,11 @@ image-size@~0.5.0: resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ== +immer@^9.0.16: + version "9.0.19" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.19.tgz#67fb97310555690b5f9cd8380d38fc0aabb6b38b" + integrity sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ== + immutable@^4.0.0: version "4.3.0" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.0.tgz#eb1738f14ffb39fd068b1dbe1296117484dd34be" @@ -5340,12 +5355,12 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -redux-thunk@^2.1.0: +redux-thunk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.2.tgz#b9d05d11994b99f7a91ea223e8b04cf0afa5ef3b" integrity sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q== -redux@^4.0.0: +redux@^4.2.0: version "4.2.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w== @@ -5430,7 +5445,7 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== -reselect@^4.0.0: +reselect@^4.1.7: version "4.1.7" resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.7.tgz#56480d9ff3d3188970ee2b76527bd94a95567a42" integrity sha512-Zu1xbUt3/OPwsXL46hvOOoQrap2azE7ZQbokq61BQfiXvhewsKDwhMeZjTX9sX0nvw1t/U5Audyn1I9P/m9z0A== From db67b2d4524cc846e9a99e9025a17af2ba20cfd1 Mon Sep 17 00:00:00 2001 From: Jake Goulding Date: Sun, 5 Mar 2023 14:39:55 -0500 Subject: [PATCH 2/5] Rewrite gist reducer with RTK --- ui/frontend/.eslintrc.js | 1 + ui/frontend/.prettierignore | 1 + ui/frontend/Header.tsx | 3 +- ui/frontend/actions.ts | 98 ++----------------------- ui/frontend/reducers/code.ts | 17 +++-- ui/frontend/reducers/output/gist.ts | 107 ++++++++++++++++++++++------ ui/frontend/reducers/output/meta.ts | 14 ++-- 7 files changed, 111 insertions(+), 130 deletions(-) diff --git a/ui/frontend/.eslintrc.js b/ui/frontend/.eslintrc.js index 197f5b732..d8433cca4 100644 --- a/ui/frontend/.eslintrc.js +++ b/ui/frontend/.eslintrc.js @@ -65,6 +65,7 @@ module.exports = { 'PopButton.tsx', 'editor/AceEditor.tsx', 'editor/SimpleEditor.tsx', + 'reducers/output/gist.ts', 'websocketMiddleware.ts', ], extends: ['prettier'], diff --git a/ui/frontend/.prettierignore b/ui/frontend/.prettierignore index d3e1193c4..850628b2c 100644 --- a/ui/frontend/.prettierignore +++ b/ui/frontend/.prettierignore @@ -14,4 +14,5 @@ node_modules !PopButton.tsx !editor/AceEditor.tsx !editor/SimpleEditor.tsx +!reducers/output/gist.ts !websocketMiddleware.ts diff --git a/ui/frontend/Header.tsx b/ui/frontend/Header.tsx index dde753416..334649019 100644 --- a/ui/frontend/Header.tsx +++ b/ui/frontend/Header.tsx @@ -15,6 +15,7 @@ import ToolsMenu from './ToolsMenu'; import * as actions from './actions'; import * as selectors from './selectors'; import { useAppDispatch } from './configureStore'; +import { performGistSave } from './reducers/output/gist'; import styles from './Header.module.css'; @@ -132,7 +133,7 @@ const AdvancedOptionsMenuButton: React.FC = () => { const ShareButton: React.FC = () => { const dispatch = useAppDispatch(); - const gistSave = useCallback(() => dispatch(actions.performGistSave()), [dispatch]); + const gistSave = useCallback(() => dispatch(performGistSave()), [dispatch]); return ( diff --git a/ui/frontend/actions.ts b/ui/frontend/actions.ts index 1d1c741ce..c09cd6f6e 100644 --- a/ui/frontend/actions.ts +++ b/ui/frontend/actions.ts @@ -9,7 +9,6 @@ import { getCrateType, runAsTest, useWebsocketSelector, - baseUrlSelector, } from './selectors'; import State from './state'; import { @@ -35,7 +34,9 @@ import { Crate, } from './types'; -const routes = { +import { performGistLoad } from './reducers/output/gist'; + +export const routes = { compile: '/compile', execute: '/execute', format: '/format', @@ -118,12 +119,6 @@ export enum ActionType { RequestMacroExpansion = 'REQUEST_MACRO_EXPANSION', MacroExpansionSucceeded = 'MACRO_EXPANSION_SUCCEEDED', MacroExpansionFailed = 'MACRO_EXPANSION_FAILED', - RequestGistLoad = 'REQUEST_GIST_LOAD', - GistLoadSucceeded = 'GIST_LOAD_SUCCEEDED', - GistLoadFailed = 'GIST_LOAD_FAILED', - RequestGistSave = 'REQUEST_GIST_SAVE', - GistSaveSucceeded = 'GIST_SAVE_SUCCEEDED', - GistSaveFailed = 'GIST_SAVE_FAILED', RequestCratesLoad = 'REQUEST_CRATES_LOAD', CratesLoadSucceeded = 'CRATES_LOAD_SUCCEEDED', RequestVersionsLoad = 'REQUEST_VERSIONS_LOAD', @@ -235,13 +230,13 @@ const receiveExecuteFailure = ({ error }: { error?: string }) => type FetchArg = Parameters[0]; -function jsonGet(url: FetchArg) { +export function jsonGet(url: FetchArg) { return fetchJson(url, { method: 'get', }); } -function jsonPost(url: FetchArg, body: Record): Promise { +export function jsonPost(url: FetchArg, body: Record): Promise { return fetchJson(url, { method: 'post', body: JSON.stringify(body), @@ -733,83 +728,6 @@ export function performMacroExpansion(): ThunkAction { }; } -interface GistSuccessProps { - id: string; - url: string; - code: string; - stdout: string; - stderr: string; - channel: Channel; - mode: Mode; - edition: Edition; -} - -const requestGistLoad = () => - createAction(ActionType.RequestGistLoad); - -const receiveGistLoadSuccess = (props: GistSuccessProps) => - createAction(ActionType.GistLoadSucceeded, props); - -const receiveGistLoadFailure = () => // eslint-disable-line no-unused-vars - createAction(ActionType.GistLoadFailed); - -type PerformGistLoadProps = - Pick>; - -export function performGistLoad({ id, channel, mode, edition }: PerformGistLoadProps): ThunkAction { - return function(dispatch, getState) { - dispatch(requestGistLoad()); - - const state = getState(); - const baseUrl = baseUrlSelector(state); - const gistUrl = new URL(routes.meta.gistLoad, baseUrl); - const u = new URL(id, gistUrl); - - jsonGet(u) - .then(gist => dispatch(receiveGistLoadSuccess({ channel, mode, edition, ...gist }))); - // TODO: Failure case - }; -} - -const requestGistSave = () => - createAction(ActionType.RequestGistSave); - -const receiveGistSaveSuccess = (props: GistSuccessProps) => - createAction(ActionType.GistSaveSucceeded, props); - -const receiveGistSaveFailure = ({ error }: CompileFailure) => // eslint-disable-line no-unused-vars - createAction(ActionType.GistSaveFailed, { error }); - -interface GistResponseBody { - id: string; - url: string; - code: string; -} - -export function performGistSave(): ThunkAction { - return function(dispatch, getState) { - dispatch(requestGistSave()); - - const state = getState(); - const code = codeSelector(state); - const { - configuration: { - channel, mode, edition, - }, - output: { - execute: { - stdout = '', - stderr = '', - }, - }, - } = state; - - return jsonPost(routes.meta.gistSave, { code }) - .then(json => dispatch(receiveGistSaveSuccess({ ...json, code, stdout, stderr, channel, mode, edition }))); - // TODO: Failure case - }; -} - const requestCratesLoad = () => createAction(ActionType.RequestCratesLoad); @@ -1018,12 +936,6 @@ export type Action = | ReturnType | ReturnType | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType | ReturnType | ReturnType | ReturnType diff --git a/ui/frontend/reducers/code.ts b/ui/frontend/reducers/code.ts index b64bacb39..f4b50572b 100644 --- a/ui/frontend/reducers/code.ts +++ b/ui/frontend/reducers/code.ts @@ -1,4 +1,5 @@ import { Action, ActionType } from '../actions'; +import { performGistLoad } from './output/gist' const DEFAULT: State = `fn main() { println!("Hello, world!"); @@ -8,11 +9,6 @@ export type State = string; export default function code(state = DEFAULT, action: Action): State { switch (action.type) { - case ActionType.RequestGistLoad: - return ''; - case ActionType.GistLoadSucceeded: - return action.code; - case ActionType.EditCode: return action.code; @@ -28,7 +24,14 @@ export default function code(state = DEFAULT, action: Action): State { case ActionType.FormatSucceeded: return action.code; - default: - return state; + default: { + if (performGistLoad.pending.match(action)) { + return ''; + } else if (performGistLoad.fulfilled.match(action)) { + return action.payload.code; + } else { + return state; + } + } } } diff --git a/ui/frontend/reducers/output/gist.ts b/ui/frontend/reducers/output/gist.ts index 6fc6eaf99..92f421cbe 100644 --- a/ui/frontend/reducers/output/gist.ts +++ b/ui/frontend/reducers/output/gist.ts @@ -1,8 +1,14 @@ -import { Action, ActionType } from '../../actions'; +import { Draft, PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import { jsonGet, jsonPost, routes } from '../../actions'; +import { baseUrlSelector, codeSelector } from '../../selectors'; +import RootState from '../../state'; import { Channel, Edition, Mode } from '../../types'; -import { finish, RequestsInProgress, start } from './sharedStateManagement'; +import { RequestsInProgress } from './sharedStateManagement'; + +const sliceName = 'output/gist'; -const DEFAULT: State = { +const initialState: State = { requestsInProgress: 0, }; @@ -15,26 +21,81 @@ interface State extends RequestsInProgress { channel?: Channel; mode?: Mode; edition?: Edition; - error?: string; } -export default function gist(state = DEFAULT, action: Action): State { - switch (action.type) { - case ActionType.RequestGistLoad: - case ActionType.RequestGistSave: - return start(DEFAULT, state); - - case ActionType.GistLoadSucceeded: - case ActionType.GistSaveSucceeded: { - const { id, url, code, stdout, stderr, channel, mode, edition } = action; - return finish(state, { id, url, code, stdout, stderr, channel, mode, edition }); - } - - case ActionType.GistLoadFailed: - case ActionType.GistSaveFailed: - return finish(state, { error: 'Some kind of error' }); - - default: - return state; - } +interface SuccessProps { + id: string; + url: string; + code: string; + stdout: string; + stderr: string; + channel: Channel; + mode: Mode; + edition: Edition; +} + +type PerformGistLoadProps = Pick< + SuccessProps, + Exclude +>; + +interface GistResponseBody { + id: string; + url: string; + code: string; } + +export const performGistLoad = createAsyncThunk< + SuccessProps, + PerformGistLoadProps, + { state: RootState } +>(`${sliceName}/load`, async ({ id, channel, mode, edition }, { getState }) => { + const state = getState(); + const baseUrl = baseUrlSelector(state); + const gistUrl = new URL(routes.meta.gistLoad, baseUrl); + const u = new URL(id, gistUrl); + + const gist = await jsonGet(u); + return { channel, mode, edition, ...gist }; +}); + +export const performGistSave = createAsyncThunk( + `${sliceName}/save`, + async (_arg, { getState }) => { + const state = getState(); + const code = codeSelector(state); + const { + configuration: { channel, mode, edition }, + output: { + execute: { stdout = '', stderr = '' }, + }, + } = state; + + const json = await jsonPost(routes.meta.gistSave, { code }); + return { ...json, code, stdout, stderr, channel, mode, edition }; + }, +); + +const pending = (state: Draft) => { + state.requestsInProgress += 1; +}; + +const fulfilled = (state: Draft, action: PayloadAction) => { + state.requestsInProgress -= 1; + Object.assign(state, action.payload); +}; + +const slice = createSlice({ + name: sliceName, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(performGistLoad.pending, pending) + .addCase(performGistLoad.fulfilled, fulfilled) + .addCase(performGistSave.pending, pending) + .addCase(performGistSave.fulfilled, fulfilled); + }, +}); + +export default slice.reducer; diff --git a/ui/frontend/reducers/output/meta.ts b/ui/frontend/reducers/output/meta.ts index 18af43c09..af9893caf 100644 --- a/ui/frontend/reducers/output/meta.ts +++ b/ui/frontend/reducers/output/meta.ts @@ -1,5 +1,6 @@ import { Action, ActionType } from '../../actions'; import { Focus } from '../../types'; +import { performGistLoad, performGistSave } from './gist'; const DEFAULT: State = { }; @@ -46,11 +47,12 @@ export default function meta(state = DEFAULT, action: Action) { case ActionType.FormatSucceeded: return { ...state, focus: undefined }; - case ActionType.RequestGistLoad: - case ActionType.RequestGistSave: - return { ...state, focus: Focus.Gist }; - - default: - return state; + default: { + if (performGistLoad.pending.match(action) || performGistSave.pending.match(action)) { + return { ...state, focus: Focus.Gist }; + } else { + return state; + } + } } } From c2e0ec6f3158ee4acb96251fe7f7b79464514de1 Mon Sep 17 00:00:00 2001 From: Jake Goulding Date: Sun, 5 Mar 2023 16:08:14 -0500 Subject: [PATCH 3/5] Rewrite format reducer with RTK --- ui/frontend/.eslintrc.js | 1 + ui/frontend/.prettierignore | 1 + ui/frontend/ToolsMenu.tsx | 3 +- ui/frontend/actions.ts | 62 +++++++------------------- ui/frontend/reducers/code.ts | 6 +-- ui/frontend/reducers/output/format.ts | 64 ++++++++++++++++++++------- ui/frontend/reducers/output/meta.ts | 10 ++--- 7 files changed, 75 insertions(+), 72 deletions(-) diff --git a/ui/frontend/.eslintrc.js b/ui/frontend/.eslintrc.js index d8433cca4..aa1e3955d 100644 --- a/ui/frontend/.eslintrc.js +++ b/ui/frontend/.eslintrc.js @@ -65,6 +65,7 @@ module.exports = { 'PopButton.tsx', 'editor/AceEditor.tsx', 'editor/SimpleEditor.tsx', + 'reducers/output/format.ts', 'reducers/output/gist.ts', 'websocketMiddleware.ts', ], diff --git a/ui/frontend/.prettierignore b/ui/frontend/.prettierignore index 850628b2c..2044e88ca 100644 --- a/ui/frontend/.prettierignore +++ b/ui/frontend/.prettierignore @@ -14,5 +14,6 @@ node_modules !PopButton.tsx !editor/AceEditor.tsx !editor/SimpleEditor.tsx +!reducers/output/format.ts !reducers/output/gist.ts !websocketMiddleware.ts diff --git a/ui/frontend/ToolsMenu.tsx b/ui/frontend/ToolsMenu.tsx index a0ed22c74..987abccc8 100644 --- a/ui/frontend/ToolsMenu.tsx +++ b/ui/frontend/ToolsMenu.tsx @@ -8,6 +8,7 @@ import MenuAside from './MenuAside'; import * as selectors from './selectors'; import * as actions from './actions'; import { useAppDispatch } from './configureStore'; +import { performFormat } from './reducers/output/format'; interface ToolsMenuProps { close: () => void; @@ -33,7 +34,7 @@ const ToolsMenu: React.FC = props => { props.close(); }, [dispatch, props]); const format = useCallback(() => { - dispatch(actions.performFormat()); + dispatch(performFormat()); props.close(); }, [dispatch, props]); const expandMacros = useCallback(() => { diff --git a/ui/frontend/actions.ts b/ui/frontend/actions.ts index c09cd6f6e..0b263cbfd 100644 --- a/ui/frontend/actions.ts +++ b/ui/frontend/actions.ts @@ -5,7 +5,6 @@ import { z } from 'zod'; import { codeSelector, clippyRequestSelector, - formatRequestSelector, getCrateType, runAsTest, useWebsocketSelector, @@ -107,9 +106,6 @@ export enum ActionType { EnableFeatureGate = 'ENABLE_FEATURE_GATE', GotoPosition = 'GOTO_POSITION', SelectText = 'SELECT_TEXT', - RequestFormat = 'REQUEST_FORMAT', - FormatSucceeded = 'FORMAT_SUCCEEDED', - FormatFailed = 'FORMAT_FAILED', RequestClippy = 'REQUEST_CLIPPY', ClippySucceeded = 'CLIPPY_SUCCEEDED', ClippyFailed = 'CLIPPY_FAILED', @@ -287,6 +283,21 @@ async function fetchJson(url: FetchArg, args: RequestInit) { } } +// We made some strange decisions with how the `fetchJson` function +// communicates errors, so we untwist those here to fit better with +// redux-toolkit's ideas. +export const adaptFetchError = async (cb: () => Promise): Promise => { + try { + return await cb(); + } catch (e) { + if (e && typeof e === 'object' && 'error' in e && typeof e.error === 'string') { + throw new Error(e.error); + } else { + throw new Error('An unknown error occurred'); + } + } +} + interface ExecuteRequestBody { channel: string; mode: string; @@ -567,46 +578,6 @@ export const gotoPosition = (line: string | number, column: string | number) => export const selectText = (start: Position, end: Position) => createAction(ActionType.SelectText, { start, end }); -const requestFormat = () => - createAction(ActionType.RequestFormat); - -interface FormatRequestBody { - code: string; - edition: string; -} - -interface FormatResponseBody { - success: boolean; - code: string; - stdout: string; - stderr: string; -} - -const receiveFormatSuccess = (body: FormatResponseBody) => - createAction(ActionType.FormatSucceeded, body); - -const receiveFormatFailure = (body: FormatResponseBody) => - createAction(ActionType.FormatFailed, body); - -export function performFormat(): ThunkAction { - // TODO: Check a cache - return function(dispatch, getState) { - dispatch(requestFormat()); - - const body: FormatRequestBody = formatRequestSelector(getState()); - - return jsonPost(routes.format, body) - .then(json => { - if (json.success) { - dispatch(receiveFormatSuccess(json)); - } else { - dispatch(receiveFormatFailure(json)); - } - }) - .catch(json => dispatch(receiveFormatFailure(json))); - }; -} - interface GeneralSuccess { stdout: string; stderr: string; @@ -924,9 +895,6 @@ export type Action = | ReturnType | ReturnType | ReturnType - | ReturnType - | ReturnType - | ReturnType | ReturnType | ReturnType | ReturnType diff --git a/ui/frontend/reducers/code.ts b/ui/frontend/reducers/code.ts index f4b50572b..2bdf322d6 100644 --- a/ui/frontend/reducers/code.ts +++ b/ui/frontend/reducers/code.ts @@ -1,5 +1,6 @@ import { Action, ActionType } from '../actions'; import { performGistLoad } from './output/gist' +import { performFormat } from './output/format' const DEFAULT: State = `fn main() { println!("Hello, world!"); @@ -21,14 +22,13 @@ export default function code(state = DEFAULT, action: Action): State { case ActionType.EnableFeatureGate: return `#![feature(${action.featureGate})]\n${state}`; - case ActionType.FormatSucceeded: - return action.code; - default: { if (performGistLoad.pending.match(action)) { return ''; } else if (performGistLoad.fulfilled.match(action)) { return action.payload.code; + } else if (performFormat.fulfilled.match(action)) { + return action.payload.code; } else { return state; } diff --git a/ui/frontend/reducers/output/format.ts b/ui/frontend/reducers/output/format.ts index f0324a815..b503eedf0 100644 --- a/ui/frontend/reducers/output/format.ts +++ b/ui/frontend/reducers/output/format.ts @@ -1,7 +1,12 @@ -import { Action, ActionType } from '../../actions'; -import { finish, start } from './sharedStateManagement'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -const DEFAULT: State = { +import { adaptFetchError, jsonPost, routes } from '../../actions'; +import { formatRequestSelector } from '../../selectors'; +import RootState from '../../state'; + +const sliceName = 'output/format'; + +const initialState: State = { requestsInProgress: 0, }; @@ -11,17 +16,44 @@ interface State { stderr?: string; } -export default function format(state = DEFAULT, action: Action): State { - switch (action.type) { - case ActionType.RequestFormat: - return start(DEFAULT, state); - case ActionType.FormatSucceeded: - return finish(state); - case ActionType.FormatFailed: { - const { stdout = '', stderr = '' } = action; - return finish(state, { stdout, stderr }); - } - default: - return state; - } +interface FormatRequestBody { + code: string; + edition: string; +} + +interface FormatResponseBody { + success: boolean; + code: string; + stdout: string; + stderr: string; } + +export const performFormat = createAsyncThunk( + sliceName, + async (_arg: void, { getState }) => { + const body: FormatRequestBody = formatRequestSelector(getState()); + + return adaptFetchError(() => jsonPost(routes.format, body)); + }, +); + +const slice = createSlice({ + name: sliceName, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(performFormat.pending, (state) => { + state.requestsInProgress += 1; + }) + .addCase(performFormat.fulfilled, (state, action) => { + state.requestsInProgress -= 1; + Object.assign(state, action.payload); + }) + .addCase(performFormat.rejected, (state) => { + state.requestsInProgress -= 1; + }); + }, +}); + +export default slice.reducer; diff --git a/ui/frontend/reducers/output/meta.ts b/ui/frontend/reducers/output/meta.ts index af9893caf..4578cb460 100644 --- a/ui/frontend/reducers/output/meta.ts +++ b/ui/frontend/reducers/output/meta.ts @@ -1,6 +1,7 @@ import { Action, ActionType } from '../../actions'; import { Focus } from '../../types'; import { performGistLoad, performGistSave } from './gist'; +import { performFormat } from './format'; const DEFAULT: State = { }; @@ -42,14 +43,13 @@ export default function meta(state = DEFAULT, action: Action) { case ActionType.WSExecuteRequest: return { ...state, focus: Focus.Execute }; - case ActionType.RequestFormat: - return { ...state, focus: Focus.Format }; - case ActionType.FormatSucceeded: - return { ...state, focus: undefined }; - default: { if (performGistLoad.pending.match(action) || performGistSave.pending.match(action)) { return { ...state, focus: Focus.Gist }; + } else if (performFormat.pending.match(action)) { + return { ...state, focus: Focus.Format }; + } else if (performFormat.fulfilled.match(action)) { + return { ...state, focus: undefined }; } else { return state; } From c6191cf7fcc572dcc9cfa5ddefb6cd7fb42eda50 Mon Sep 17 00:00:00 2001 From: Jake Goulding Date: Fri, 10 Mar 2023 11:58:20 -0500 Subject: [PATCH 4/5] Rewrite execute reducer with RTK We now use the Flux standard action `meta` property instead of our own `extra` property when sending to the backend server. --- ui/frontend/.eslintrc.js | 2 + ui/frontend/.prettierignore | 2 + ui/frontend/actions.ts | 97 +-------------- ui/frontend/reducers/output/execute.ts | 164 +++++++++++++++++++------ ui/frontend/reducers/output/meta.ts | 5 +- ui/frontend/selectors/index.ts | 15 +++ ui/frontend/websocketActions.ts | 43 +++++++ ui/frontend/websocketMiddleware.ts | 9 +- ui/src/server_axum/websocket.rs | 64 +++++----- 9 files changed, 231 insertions(+), 170 deletions(-) create mode 100644 ui/frontend/websocketActions.ts diff --git a/ui/frontend/.eslintrc.js b/ui/frontend/.eslintrc.js index aa1e3955d..a4415f611 100644 --- a/ui/frontend/.eslintrc.js +++ b/ui/frontend/.eslintrc.js @@ -65,8 +65,10 @@ module.exports = { 'PopButton.tsx', 'editor/AceEditor.tsx', 'editor/SimpleEditor.tsx', + 'reducers/output/execute.ts', 'reducers/output/format.ts', 'reducers/output/gist.ts', + 'websocketActions.ts', 'websocketMiddleware.ts', ], extends: ['prettier'], diff --git a/ui/frontend/.prettierignore b/ui/frontend/.prettierignore index 2044e88ca..05931cd05 100644 --- a/ui/frontend/.prettierignore +++ b/ui/frontend/.prettierignore @@ -14,6 +14,8 @@ node_modules !PopButton.tsx !editor/AceEditor.tsx !editor/SimpleEditor.tsx +!reducers/output/execute.ts !reducers/output/format.ts !reducers/output/gist.ts +!websocketActions.ts !websocketMiddleware.ts diff --git a/ui/frontend/actions.ts b/ui/frontend/actions.ts index 0b263cbfd..ff38f729b 100644 --- a/ui/frontend/actions.ts +++ b/ui/frontend/actions.ts @@ -1,5 +1,5 @@ import fetch from 'isomorphic-fetch'; -import { ThunkAction as ReduxThunkAction } from '@reduxjs/toolkit'; +import { ThunkAction as ReduxThunkAction, AnyAction } from '@reduxjs/toolkit'; import { z } from 'zod'; import { @@ -7,7 +7,6 @@ import { clippyRequestSelector, getCrateType, runAsTest, - useWebsocketSelector, } from './selectors'; import State from './state'; import { @@ -33,6 +32,7 @@ import { Crate, } from './types'; +import { ExecuteRequestBody, performCommonExecute, wsExecuteRequest } from './reducers/output/execute'; import { performGistLoad } from './reducers/output/gist'; export const routes = { @@ -58,6 +58,7 @@ export const routes = { }; export type ThunkAction = ReduxThunkAction; +export type SimpleThunkAction = ReduxThunkAction; const createAction = (type: T, props?: P) => ( Object.assign({ type }, props) @@ -82,9 +83,6 @@ export enum ActionType { ChangeEdition = 'CHANGE_EDITION', ChangeBacktrace = 'CHANGE_BACKTRACE', ChangeFocus = 'CHANGE_FOCUS', - ExecuteRequest = 'EXECUTE_REQUEST', - ExecuteSucceeded = 'EXECUTE_SUCCEEDED', - ExecuteFailed = 'EXECUTE_FAILED', CompileAssemblyRequest = 'COMPILE_ASSEMBLY_REQUEST', CompileAssemblySucceeded = 'COMPILE_ASSEMBLY_SUCCEEDED', CompileAssemblyFailed = 'COMPILE_ASSEMBLY_FAILED', @@ -126,8 +124,6 @@ export enum ActionType { WebSocketConnected = 'WEBSOCKET_CONNECTED', WebSocketDisconnected = 'WEBSOCKET_DISCONNECTED', WebSocketFeatureFlagEnabled = 'WEBSOCKET_FEATURE_FLAG_ENABLED', - WSExecuteRequest = 'WS_EXECUTE_REQUEST', - WSExecuteResponse = 'WS_EXECUTE_RESPONSE', } export const WebSocketError = z.object({ @@ -136,20 +132,6 @@ export const WebSocketError = z.object({ }); export type WebSocketError = z.infer; -const ExecuteExtra = z.object({ - sequenceNumber: z.number(), -}); -type ExecuteExtra = z.infer; - -export const WSExecuteResponse = z.object({ - type: z.literal(ActionType.WSExecuteResponse), - success: z.boolean(), - stdout: z.string(), - stderr: z.string(), - extra: ExecuteExtra, -}); -export type WSExecuteResponse = z.infer; - export const initializeApplication = () => createAction(ActionType.InitializeApplication); export const disableSyncChangesToStorage = () => createAction(ActionType.DisableSyncChangesToStorage); @@ -210,20 +192,6 @@ export const reExecuteWithBacktrace = (): ThunkAction => dispatch => { export const changeFocus = (focus?: Focus) => createAction(ActionType.ChangeFocus, { focus }); -interface ExecuteResponseBody { - stdout: string; - stderr: string; -} - -const requestExecute = () => - createAction(ActionType.ExecuteRequest); - -const receiveExecuteSuccess = ({ stdout, stderr }: ExecuteResponseBody) => - createAction(ActionType.ExecuteSucceeded, { stdout, stderr }); - -const receiveExecuteFailure = ({ error }: { error?: string }) => - createAction(ActionType.ExecuteFailed, { error }); - type FetchArg = Parameters[0]; export function jsonGet(url: FetchArg) { @@ -298,35 +266,6 @@ export const adaptFetchError = async (cb: () => Promise): Promise => { } } -interface ExecuteRequestBody { - channel: string; - mode: string; - crateType: string; - tests: boolean; - code: string; - edition: string; - backtrace: boolean; -} - -const performCommonExecute = (crateType: string, tests: boolean): ThunkAction => (dispatch, getState) => { - const state = getState(); - const code = codeSelector(state); - const { configuration: { channel, mode, edition } } = state; - const backtrace = state.configuration.backtrace === Backtrace.Enabled; - - if (useWebsocketSelector(state)) { - return dispatch(wsExecuteRequest(channel, mode, edition, crateType, tests, code, backtrace)); - } else { - dispatch(requestExecute()); - - const body: ExecuteRequestBody = { channel, mode, edition, crateType, tests, code, backtrace }; - - return jsonPost(routes.execute, body) - .then(json => dispatch(receiveExecuteSuccess(json))) - .catch(json => dispatch(receiveExecuteFailure(json))); - } -}; - function performAutoOnly(): ThunkAction { return function(dispatch, getState) { const state = getState(); @@ -506,32 +445,6 @@ const PRIMARY_ACTIONS: { [index in PrimaryAction]: () => ThunkAction } = { [PrimaryActionCore.Wasm]: performCompileToNightlyWasmOnly, }; -let sequenceNumber = 0; -const nextSequenceNumber = () => sequenceNumber++; -const makeExtra = (): ExecuteExtra => ({ - sequenceNumber: nextSequenceNumber(), -}); - -const wsExecuteRequest = ( - channel: Channel, - mode: Mode, - edition: Edition, - crateType: string, - tests: boolean, - code: string, - backtrace: boolean -) => - createAction(ActionType.WSExecuteRequest, { - channel, - mode, - edition, - crateType, - tests, - code, - backtrace, - extra: makeExtra(), - }); - export const performPrimaryAction = (): ThunkAction => (dispatch, getState) => { const state = getState(); const primaryAction = PRIMARY_ACTIONS[state.configuration.primaryAction]; @@ -871,9 +784,6 @@ export type Action = | ReturnType | ReturnType | ReturnType - | ReturnType - | ReturnType - | ReturnType | ReturnType | ReturnType | ReturnType @@ -916,5 +826,4 @@ export type Action = | ReturnType | ReturnType | ReturnType - | WSExecuteResponse ; diff --git a/ui/frontend/reducers/output/execute.ts b/ui/frontend/reducers/output/execute.ts index cfb54db3a..275e00bf1 100644 --- a/ui/frontend/reducers/output/execute.ts +++ b/ui/frontend/reducers/output/execute.ts @@ -1,7 +1,17 @@ -import { Action, ActionType } from '../../actions'; -import { finish, start } from './sharedStateManagement'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import * as z from 'zod'; -const DEFAULT: State = { +import { SimpleThunkAction, adaptFetchError, jsonPost, routes } from '../../actions'; +import { executeRequestPayloadSelector, useWebsocketSelector } from '../../selectors'; +import { Channel, Edition, Mode } from '../../types'; +import { + WsPayloadAction, + createWebsocketResponseAction, + createWebsocketResponseSchema, + makeWebSocketMeta, +} from '../../websocketActions'; + +const initialState: State = { requestsInProgress: 0, }; @@ -13,38 +23,118 @@ interface State { error?: string; } -export default function execute(state = DEFAULT, action: Action) { - switch (action.type) { - case ActionType.ExecuteRequest: - return start(DEFAULT, state); - case ActionType.WSExecuteRequest: { - const { extra: { sequenceNumber } } = action; - if (sequenceNumber >= (state.sequenceNumber ?? 0)) { - const requestsInProgress = 1; // Only tracking one request - return {...state, sequenceNumber, requestsInProgress }; - } else { - return state; - } - } - case ActionType.ExecuteSucceeded: { - const { stdout = '', stderr = '' } = action; - return finish(state, { stdout, stderr }); - } - case ActionType.ExecuteFailed: { - const { error } = action; - return finish(state, { error }); - } - case ActionType.WSExecuteResponse: { - const { stdout, stderr, extra: { sequenceNumber } } = action; - - if (sequenceNumber >= (state.sequenceNumber ?? 0)) { - const requestsInProgress = 0; // Only tracking one request - return { ...state, stdout, stderr, requestsInProgress }; - } else { - return state; - } - } - default: - return state; - } +const wsExecuteResponsePayloadSchema = z.object({ + success: z.boolean(), + stdout: z.string(), + stderr: z.string(), +}); +type wsExecuteResponsePayload = z.infer; + +type wsExecuteRequestPayload = { + channel: Channel; + mode: Mode; + edition: Edition; + crateType: string; + tests: boolean; + code: string; + backtrace: boolean; +}; + +const wsExecuteResponse = createWebsocketResponseAction( + 'output/execute/wsExecuteResponse', +); + +const sliceName = 'output/execute'; + +export interface ExecuteRequestBody { + channel: string; + mode: string; + crateType: string; + tests: boolean; + code: string; + edition: string; + backtrace: boolean; +} + +interface ExecuteResponseBody { + success: boolean; + stdout: string; + stderr: string; } + +export const performExecute = createAsyncThunk(sliceName, async (payload: ExecuteRequestBody) => + adaptFetchError(() => jsonPost(routes.execute, payload)), +); + +const slice = createSlice({ + name: 'output/execute', + initialState, + reducers: { + wsExecuteRequest: { + reducer: (state, action: WsPayloadAction) => { + const { sequenceNumber } = action.meta; + if (sequenceNumber >= (state.sequenceNumber ?? 0)) { + state.sequenceNumber = sequenceNumber; + state.requestsInProgress = 1; // Only tracking one request + } + }, + + prepare: (payload: wsExecuteRequestPayload) => ({ + payload, + meta: makeWebSocketMeta(), + }), + }, + }, + extraReducers: (builder) => { + builder + .addCase(performExecute.pending, (state) => { + state.requestsInProgress += 1; + }) + .addCase(performExecute.fulfilled, (state, action) => { + const { stdout, stderr } = action.payload; + Object.assign(state, { stdout, stderr }); + state.requestsInProgress -= 1; + }) + .addCase(performExecute.rejected, (state, action) => { + if (action.payload) { + } else { + state.error = action.error.message; + } + state.requestsInProgress -= 1; + }) + .addCase(wsExecuteResponse, (state, action) => { + const { + payload: { stdout, stderr }, + meta: { sequenceNumber }, + } = action; + + if (sequenceNumber >= (state.sequenceNumber ?? 0)) { + Object.assign(state, { stdout, stderr }); + state.requestsInProgress = 0; // Only tracking one request + } + }); + }, +}); + +export const { wsExecuteRequest } = slice.actions; + +export const performCommonExecute = + (crateType: string, tests: boolean): SimpleThunkAction => + (dispatch, getState) => { + const state = getState(); + const body = executeRequestPayloadSelector(state, { crateType, tests }); + const useWebSocket = useWebsocketSelector(state); + + if (useWebSocket) { + dispatch(wsExecuteRequest(body)); + } else { + dispatch(performExecute(body)); + } + }; + +export const wsExecuteResponseSchema = createWebsocketResponseSchema( + wsExecuteResponse, + wsExecuteResponsePayloadSchema, +); + +export default slice.reducer; diff --git a/ui/frontend/reducers/output/meta.ts b/ui/frontend/reducers/output/meta.ts index 4578cb460..a92bb964f 100644 --- a/ui/frontend/reducers/output/meta.ts +++ b/ui/frontend/reducers/output/meta.ts @@ -2,6 +2,7 @@ import { Action, ActionType } from '../../actions'; import { Focus } from '../../types'; import { performGistLoad, performGistSave } from './gist'; import { performFormat } from './format'; +import { performExecute, wsExecuteRequest } from './execute'; const DEFAULT: State = { }; @@ -39,8 +40,8 @@ export default function meta(state = DEFAULT, action: Action) { case ActionType.CompileAssemblyRequest: return { ...state, focus: Focus.Asm }; - case ActionType.ExecuteRequest: - case ActionType.WSExecuteRequest: + case performExecute.pending.type: + case wsExecuteRequest.type: return { ...state, focus: Focus.Execute }; default: { diff --git a/ui/frontend/selectors/index.ts b/ui/frontend/selectors/index.ts index 49985f0c7..332d28477 100644 --- a/ui/frontend/selectors/index.ts +++ b/ui/frontend/selectors/index.ts @@ -364,3 +364,18 @@ export const websocketStatusSelector = createSelector( return { state: 'disconnected' }; } ); + +export const executeRequestPayloadSelector = createSelector( + codeSelector, + (state: State) => state.configuration, + (_state: State, { crateType, tests }: { crateType: string, tests: boolean }) => ({ crateType, tests }), + (code, configuration, { crateType, tests }) => ({ + channel: configuration.channel, + mode: configuration.mode, + edition: configuration.edition, + crateType, + tests, + code, + backtrace: configuration.backtrace === Backtrace.Enabled, + }), +); diff --git a/ui/frontend/websocketActions.ts b/ui/frontend/websocketActions.ts new file mode 100644 index 000000000..a2348e633 --- /dev/null +++ b/ui/frontend/websocketActions.ts @@ -0,0 +1,43 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import z from 'zod'; + +export type WsPayloadAction

= PayloadAction< + P, + T, + { sequenceNumber: number } +>; + +export const createWebsocketResponseAction = (type: T) => { + function actionCreator(): WsPayloadAction

{ + throw 'Should never be executed by JS'; + } + actionCreator.type = type; + actionCreator.toString = () => type; + // TODO: Add .match() ? + + return actionCreator; +}; + +export const createWebsocketResponseSchema =

( + creator: { type: T }, + payload: P, +) => + z.object({ + type: z.literal(creator.type), + payload, + meta: z.object({ + // deliberately omitting `websocket` to avoid sending the server's + // responses back to the server infinitely + sequenceNumber: z.number(), + }), + }); + +const nextSequenceNumber = (() => { + let sequenceNumber = 0; + return () => sequenceNumber++; +})(); + +export const makeWebSocketMeta = () => ({ + websocket: true, + sequenceNumber: nextSequenceNumber(), +}); diff --git a/ui/frontend/websocketMiddleware.ts b/ui/frontend/websocketMiddleware.ts index ce979ef11..9fe1510b4 100644 --- a/ui/frontend/websocketMiddleware.ts +++ b/ui/frontend/websocketMiddleware.ts @@ -1,16 +1,15 @@ -import { Middleware } from '@reduxjs/toolkit'; +import { AnyAction, Middleware } from '@reduxjs/toolkit'; import { z } from 'zod'; import { - ActionType, - WSExecuteResponse, WebSocketError, websocketConnected, websocketDisconnected, websocketError, } from './actions'; +import { wsExecuteResponseSchema } from './reducers/output/execute'; -const WSMessageResponse = z.discriminatedUnion('type', [WebSocketError, WSExecuteResponse]); +const WSMessageResponse = z.discriminatedUnion('type', [WebSocketError, wsExecuteResponseSchema]); const reportWebSocketError = async (error: string) => { try { @@ -136,4 +135,4 @@ export const websocketMiddleware = }; }; -const sendActionOnWebsocket = (action: any): boolean => action.type === ActionType.WSExecuteRequest; +const sendActionOnWebsocket = (action: AnyAction): boolean => action?.meta?.websocket; diff --git a/ui/src/server_axum/websocket.rs b/ui/src/server_axum/websocket.rs index eda458a18..f374b6695 100644 --- a/ui/src/server_axum/websocket.rs +++ b/ui/src/server_axum/websocket.rs @@ -13,16 +13,18 @@ use std::{ }; use tokio::{sync::mpsc, task::JoinSet}; +type Meta = serde_json::Value; + #[derive(serde::Deserialize)] #[serde(tag = "type")] enum WSMessageRequest { - #[serde(rename = "WS_EXECUTE_REQUEST")] - WSExecuteRequest(WSExecuteRequest), + #[serde(rename = "output/execute/wsExecuteRequest")] + ExecuteRequest { payload: ExecuteRequest, meta: Meta }, } #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] -struct WSExecuteRequest { +struct ExecuteRequest { channel: String, mode: String, edition: String, @@ -30,14 +32,13 @@ struct WSExecuteRequest { tests: bool, code: String, backtrace: bool, - extra: serde_json::Value, } -impl TryFrom for (sandbox::ExecuteRequest, serde_json::Value) { +impl TryFrom for sandbox::ExecuteRequest { type Error = Error; - fn try_from(value: WSExecuteRequest) -> Result { - let WSExecuteRequest { + fn try_from(value: ExecuteRequest) -> Result { + let ExecuteRequest { channel, mode, edition, @@ -45,10 +46,9 @@ impl TryFrom for (sandbox::ExecuteRequest, serde_json::Value) tests, code, backtrace, - extra, } = value; - let req = sandbox::ExecuteRequest { + Ok(sandbox::ExecuteRequest { channel: parse_channel(&channel)?, mode: parse_mode(&mode)?, edition: parse_edition(&edition)?, @@ -56,19 +56,21 @@ impl TryFrom for (sandbox::ExecuteRequest, serde_json::Value) tests, backtrace, code, - }; - - Ok((req, extra)) + }) } } #[derive(Debug, serde::Serialize)] #[serde(tag = "type")] -enum WSMessageResponse { +enum MessageResponse { #[serde(rename = "WEBSOCKET_ERROR")] Error(WSError), - #[serde(rename = "WS_EXECUTE_RESPONSE")] - WSExecuteResponse(WSExecuteResponse), + + #[serde(rename = "output/execute/wsExecuteResponse")] + ExecuteResponse { + payload: ExecuteResponse, + meta: Meta, + }, } #[derive(Debug, serde::Serialize)] @@ -79,27 +81,24 @@ struct WSError { #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] -struct WSExecuteResponse { +struct ExecuteResponse { success: bool, stdout: String, stderr: String, - extra: serde_json::Value, } -impl From<(sandbox::ExecuteResponse, serde_json::Value)> for WSExecuteResponse { - fn from(value: (sandbox::ExecuteResponse, serde_json::Value)) -> Self { +impl From for ExecuteResponse { + fn from(value: sandbox::ExecuteResponse) -> Self { let sandbox::ExecuteResponse { success, stdout, stderr, - } = value.0; - let extra = value.1; + } = value; - WSExecuteResponse { + ExecuteResponse { success, stdout, stderr, - extra, } } } @@ -173,12 +172,12 @@ pub async fn handle(mut socket: WebSocket) { DURATION_WS.observe(elapsed.as_secs_f64()); } -fn error_to_response(error: Error) -> WSMessageResponse { +fn error_to_response(error: Error) -> MessageResponse { let error = error.to_string(); - WSMessageResponse::Error(WSError { error }) + MessageResponse::Error(WSError { error }) } -fn response_to_message(response: WSMessageResponse) -> Message { +fn response_to_message(response: MessageResponse) -> Message { const LAST_CHANCE_ERROR: &str = r#"{ "type": "WEBSOCKET_ERROR", "error": "Unable to serialize JSON" }"#; let resp = serde_json::to_string(&response).unwrap_or_else(|_| LAST_CHANCE_ERROR.into()); @@ -187,7 +186,7 @@ fn response_to_message(response: WSMessageResponse) -> Message { async fn handle_msg( txt: String, - tx: &mpsc::Sender>, + tx: &mpsc::Sender>, tasks: &mut JoinSet>, ) { use WSMessageRequest::*; @@ -195,10 +194,11 @@ async fn handle_msg( let msg = serde_json::from_str(&txt).context(crate::DeserializationSnafu); match msg { - Ok(WSExecuteRequest(req)) => { + Ok(ExecuteRequest { payload, meta }) => { let tx = tx.clone(); tasks.spawn(async move { - let resp = handle_execute(req).await; + let resp = handle_execute(payload).await; + let resp = resp.map(|payload| MessageResponse::ExecuteResponse { payload, meta }); tx.send(resp).await.ok(/* We don't care if the channel is closed */); Ok(()) }); @@ -210,10 +210,10 @@ async fn handle_msg( } } -async fn handle_execute(req: WSExecuteRequest) -> Result { +async fn handle_execute(req: ExecuteRequest) -> Result { let sb = Sandbox::new().await.context(SandboxCreationSnafu)?; - let (req, extra) = req.try_into()?; + let req = req.try_into()?; let resp = sb.execute(&req).await.context(ExecutionSnafu)?; - Ok(WSMessageResponse::WSExecuteResponse((resp, extra).into())) + Ok(resp.into()) } From 163b71585e110c2d933bb956f7b281e477cfcd61 Mon Sep 17 00:00:00 2001 From: Jake Goulding Date: Fri, 10 Mar 2023 13:03:09 -0500 Subject: [PATCH 5/5] Rewrite websocket reducer with RTK --- ui/frontend/.eslintrc.js | 1 + ui/frontend/.prettierignore | 1 + ui/frontend/actions.ts | 20 ----------- ui/frontend/index.tsx | 2 +- ui/frontend/reducers/websocket.ts | 58 +++++++++++++++++++++--------- ui/frontend/websocketMiddleware.ts | 13 ++++--- ui/src/server_axum/websocket.rs | 11 ++++-- 7 files changed, 61 insertions(+), 45 deletions(-) diff --git a/ui/frontend/.eslintrc.js b/ui/frontend/.eslintrc.js index a4415f611..9e8824186 100644 --- a/ui/frontend/.eslintrc.js +++ b/ui/frontend/.eslintrc.js @@ -68,6 +68,7 @@ module.exports = { 'reducers/output/execute.ts', 'reducers/output/format.ts', 'reducers/output/gist.ts', + 'reducers/websocket.ts', 'websocketActions.ts', 'websocketMiddleware.ts', ], diff --git a/ui/frontend/.prettierignore b/ui/frontend/.prettierignore index 05931cd05..def633a59 100644 --- a/ui/frontend/.prettierignore +++ b/ui/frontend/.prettierignore @@ -17,5 +17,6 @@ node_modules !reducers/output/execute.ts !reducers/output/format.ts !reducers/output/gist.ts +!reducers/websocket.ts !websocketActions.ts !websocketMiddleware.ts diff --git a/ui/frontend/actions.ts b/ui/frontend/actions.ts index ff38f729b..92acac992 100644 --- a/ui/frontend/actions.ts +++ b/ui/frontend/actions.ts @@ -1,6 +1,5 @@ import fetch from 'isomorphic-fetch'; import { ThunkAction as ReduxThunkAction, AnyAction } from '@reduxjs/toolkit'; -import { z } from 'zod'; import { codeSelector, @@ -120,18 +119,8 @@ export enum ActionType { NotificationSeen = 'NOTIFICATION_SEEN', BrowserWidthChanged = 'BROWSER_WIDTH_CHANGED', SplitRatioChanged = 'SPLIT_RATIO_CHANGED', - WebSocketError = 'WEBSOCKET_ERROR', - WebSocketConnected = 'WEBSOCKET_CONNECTED', - WebSocketDisconnected = 'WEBSOCKET_DISCONNECTED', - WebSocketFeatureFlagEnabled = 'WEBSOCKET_FEATURE_FLAG_ENABLED', } -export const WebSocketError = z.object({ - type: z.literal(ActionType.WebSocketError), - error: z.string(), -}); -export type WebSocketError = z.infer; - export const initializeApplication = () => createAction(ActionType.InitializeApplication); export const disableSyncChangesToStorage = () => createAction(ActionType.DisableSyncChangesToStorage); @@ -675,11 +664,6 @@ export const browserWidthChanged = (isSmall: boolean) => export const splitRatioChanged = () => createAction(ActionType.SplitRatioChanged); -export const websocketError = (error: string): WebSocketError => createAction(ActionType.WebSocketError, { error }); -export const websocketConnected = () => createAction(ActionType.WebSocketConnected); -export const websocketDisconnected = () => createAction(ActionType.WebSocketDisconnected); -export const websocketFeatureFlagEnabled = () => createAction(ActionType.WebSocketFeatureFlagEnabled); - function parseChannel(s?: string): Channel | null { switch (s) { case 'stable': @@ -821,9 +805,5 @@ export type Action = | ReturnType | ReturnType | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType | ReturnType ; diff --git a/ui/frontend/index.tsx b/ui/frontend/index.tsx index 6bbc6f00a..e1525b087 100644 --- a/ui/frontend/index.tsx +++ b/ui/frontend/index.tsx @@ -19,11 +19,11 @@ import { performVersionsLoad, reExecuteWithBacktrace, browserWidthChanged, - websocketFeatureFlagEnabled, } from './actions'; import { configureRustErrors } from './highlighting'; import PageSwitcher from './PageSwitcher'; import playgroundApp from './reducers'; +import { websocketFeatureFlagEnabled } from './reducers/websocket'; import Router from './Router'; import configureStore from './configureStore'; diff --git a/ui/frontend/reducers/websocket.ts b/ui/frontend/reducers/websocket.ts index 183be1c83..b95654854 100644 --- a/ui/frontend/reducers/websocket.ts +++ b/ui/frontend/reducers/websocket.ts @@ -1,4 +1,7 @@ -import { Action, ActionType } from '../actions'; +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import z from 'zod'; + +import { createWebsocketResponseSchema } from '../websocketActions'; export type State = { connected: boolean; @@ -6,26 +9,49 @@ export type State = { featureFlagEnabled: boolean; }; -const DEFAULT: State = { +const initialState: State = { connected: false, featureFlagEnabled: false, }; -export default function websocket(state = DEFAULT, action: Action): State { - switch (action.type) { - case ActionType.WebSocketConnected: - return { ...state, connected: true, error: undefined }; +const websocketErrorPayloadSchema = z.object({ + error: z.string(), +}); +type websocketErrorPayload = z.infer; + +const slice = createSlice({ + name: 'websocket', + initialState, + reducers: { + connected: (state) => { + state.connected = true; + delete state.error; + }, + + disconnected: (state) => { + state.connected = false; + }, + + error: (state, action: PayloadAction) => { + state.error = action.payload.error; + }, - case ActionType.WebSocketDisconnected: - return { ...state, connected: false }; + featureFlagEnabled: (state) => { + state.featureFlagEnabled = true; + }, + }, +}); - case ActionType.WebSocketError: - return { ...state, error: action.error }; +export const { + connected: websocketConnected, + disconnected: websocketDisconnected, + error: websocketError, + featureFlagEnabled: websocketFeatureFlagEnabled, +} = slice.actions; - case ActionType.WebSocketFeatureFlagEnabled: - return { ...state, featureFlagEnabled: true }; +export const websocketErrorSchema = createWebsocketResponseSchema( + websocketError, + websocketErrorPayloadSchema, +); - default: - return state; - } -} +export default slice.reducer; diff --git a/ui/frontend/websocketMiddleware.ts b/ui/frontend/websocketMiddleware.ts index 9fe1510b4..68eeeabe3 100644 --- a/ui/frontend/websocketMiddleware.ts +++ b/ui/frontend/websocketMiddleware.ts @@ -1,15 +1,18 @@ import { AnyAction, Middleware } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { wsExecuteResponseSchema } from './reducers/output/execute'; import { - WebSocketError, websocketConnected, websocketDisconnected, websocketError, -} from './actions'; -import { wsExecuteResponseSchema } from './reducers/output/execute'; + websocketErrorSchema, +} from './reducers/websocket'; -const WSMessageResponse = z.discriminatedUnion('type', [WebSocketError, wsExecuteResponseSchema]); +const WSMessageResponse = z.discriminatedUnion('type', [ + websocketErrorSchema, + wsExecuteResponseSchema, +]); const reportWebSocketError = async (error: string) => { try { @@ -93,7 +96,7 @@ export const websocketMiddleware = // We cannot get detailed information about the failure // https://stackoverflow.com/a/31003057/155423 const error = 'Generic WebSocket Error'; - store.dispatch(websocketError(error)); + store.dispatch(websocketError({ error })); reportWebSocketError(error); }); diff --git a/ui/src/server_axum/websocket.rs b/ui/src/server_axum/websocket.rs index f374b6695..c0d8b9e2a 100644 --- a/ui/src/server_axum/websocket.rs +++ b/ui/src/server_axum/websocket.rs @@ -63,8 +63,8 @@ impl TryFrom for sandbox::ExecuteRequest { #[derive(Debug, serde::Serialize)] #[serde(tag = "type")] enum MessageResponse { - #[serde(rename = "WEBSOCKET_ERROR")] - Error(WSError), + #[serde(rename = "websocket/error")] + Error { payload: WSError, meta: Meta }, #[serde(rename = "output/execute/wsExecuteResponse")] ExecuteResponse { @@ -174,7 +174,12 @@ pub async fn handle(mut socket: WebSocket) { fn error_to_response(error: Error) -> MessageResponse { let error = error.to_string(); - MessageResponse::Error(WSError { error }) + // TODO: thread through the Meta from the originating request + let meta = serde_json::json!({ "sequenceNumber": -1 }); + MessageResponse::Error { + payload: WSError { error }, + meta, + } } fn response_to_message(response: MessageResponse) -> Message {