Skip to content

Commit c6165a3

Browse files
authored
Merge pull request #899 from integer32llc/ws-frontend
2 parents fb0afc1 + 955af30 commit c6165a3

19 files changed

+588
-83
lines changed

tests/spec/spec_helper.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,15 @@
7979
"[data-test-id = '#{id_s}']"
8080
end
8181
end
82+
83+
RSpec.configure do |config|
84+
config.after(:example, :js) do
85+
page.execute_script <<~JS
86+
(() => {
87+
if (window.rustPlayground) {
88+
window.rustPlayground.disableSyncChangesToStorage();
89+
}
90+
})()
91+
JS
92+
end
93+
end

ui/frontend/Playground.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,40 @@ const ResizableArea: React.FC = () => {
8787
);
8888
};
8989

90+
91+
const WebSocketStatus: React.FC = () => {
92+
const enabled = useSelector(selectors.websocketFeatureFlagEnabled);
93+
const status = useSelector(selectors.websocketStatusSelector);
94+
95+
if (!enabled) { return null; }
96+
97+
const style: React.CSSProperties = {
98+
position: 'absolute',
99+
left: '1em',
100+
bottom: '1em',
101+
zIndex: '1',
102+
};
103+
104+
switch (status.state) {
105+
case 'connected':
106+
style.color = 'green';
107+
return <div style={style}></div>;
108+
case 'disconnected':
109+
style.color = 'grey';
110+
return <div style={style}></div>;
111+
case 'error':
112+
style.color = 'red';
113+
return <div style={style} title={status.error}></div>;
114+
}
115+
}
116+
90117
const Playground: React.FC = () => {
91118
const showNotifications = useSelector(selectors.anyNotificationsToShowSelector);
92119

93120
return (
94121
<>
95122
<div className={styles.container}>
123+
<WebSocketStatus />
96124
<Header />
97125
<ResizableArea />
98126
</div>

ui/frontend/actions.ts

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import fetch from 'isomorphic-fetch';
22
import { ThunkAction as ReduxThunkAction } from 'redux-thunk';
33
import url, { UrlObject } from 'url';
4+
import { z } from 'zod';
45

56
import {
67
clippyRequestSelector,
78
formatRequestSelector,
89
getCrateType,
9-
isAutoBuildSelector,
1010
runAsTest,
11+
useWebsocketSelector,
1112
} from './selectors';
1213
import State from './state';
1314
import {
@@ -62,6 +63,7 @@ const createAction = <T extends string, P extends {}>(type: T, props?: P) => (
6263

6364
export enum ActionType {
6465
InitializeApplication = 'INITIALIZE_APPLICATION',
66+
DisableSyncChangesToStorage = 'DISABLE_SYNC_CHANGES_TO_STORAGE',
6567
SetPage = 'SET_PAGE',
6668
ChangeEditor = 'CHANGE_EDITOR',
6769
ChangeKeybinding = 'CHANGE_KEYBINDING',
@@ -127,10 +129,38 @@ export enum ActionType {
127129
NotificationSeen = 'NOTIFICATION_SEEN',
128130
BrowserWidthChanged = 'BROWSER_WIDTH_CHANGED',
129131
SplitRatioChanged = 'SPLIT_RATIO_CHANGED',
132+
WebSocketError = 'WEBSOCKET_ERROR',
133+
WebSocketConnected = 'WEBSOCKET_CONNECTED',
134+
WebSocketDisconnected = 'WEBSOCKET_DISCONNECTED',
135+
WebSocketFeatureFlagEnabled = 'WEBSOCKET_FEATURE_FLAG_ENABLED',
136+
WSExecuteRequest = 'WS_EXECUTE_REQUEST',
137+
WSExecuteResponse = 'WS_EXECUTE_RESPONSE',
130138
}
131139

140+
export const WebSocketError = z.object({
141+
type: z.literal(ActionType.WebSocketError),
142+
error: z.string(),
143+
});
144+
export type WebSocketError = z.infer<typeof WebSocketError>;
145+
146+
const ExecuteExtra = z.object({
147+
sequenceNumber: z.number(),
148+
});
149+
type ExecuteExtra = z.infer<typeof ExecuteExtra>;
150+
151+
export const WSExecuteResponse = z.object({
152+
type: z.literal(ActionType.WSExecuteResponse),
153+
success: z.boolean(),
154+
stdout: z.string(),
155+
stderr: z.string(),
156+
extra: ExecuteExtra,
157+
});
158+
export type WSExecuteResponse = z.infer<typeof WSExecuteResponse>;
159+
132160
export const initializeApplication = () => createAction(ActionType.InitializeApplication);
133161

162+
export const disableSyncChangesToStorage = () => createAction(ActionType.DisableSyncChangesToStorage);
163+
134164
const setPage = (page: Page) =>
135165
createAction(ActionType.SetPage, { page });
136166

@@ -192,22 +222,14 @@ interface ExecuteResponseBody {
192222
stderr: string;
193223
}
194224

195-
interface ExecuteSuccess extends ExecuteResponseBody {
196-
isAutoBuild: boolean;
197-
}
198-
199225
const requestExecute = () =>
200226
createAction(ActionType.ExecuteRequest);
201227

202-
const receiveExecuteSuccess = ({ stdout, stderr, isAutoBuild }: ExecuteSuccess) =>
203-
createAction(ActionType.ExecuteSucceeded, { stdout, stderr, isAutoBuild });
228+
const receiveExecuteSuccess = ({ stdout, stderr }: ExecuteResponseBody) =>
229+
createAction(ActionType.ExecuteSucceeded, { stdout, stderr });
204230

205-
const receiveExecuteFailure = ({
206-
error, isAutoBuild,
207-
}: {
208-
error?: string, isAutoBuild: boolean,
209-
}) =>
210-
createAction(ActionType.ExecuteFailed, { error, isAutoBuild });
231+
const receiveExecuteFailure = ({ error }: { error?: string }) =>
232+
createAction(ActionType.ExecuteFailed, { error });
211233

212234
function jsonGet(urlObj: string | UrlObject) {
213235
const urlStr = url.format(urlObj);
@@ -281,18 +303,21 @@ interface ExecuteRequestBody {
281303
}
282304

283305
const performCommonExecute = (crateType: string, tests: boolean): ThunkAction => (dispatch, getState) => {
284-
dispatch(requestExecute());
285-
286306
const state = getState();
287307
const { code, configuration: { channel, mode, edition } } = state;
288308
const backtrace = state.configuration.backtrace === Backtrace.Enabled;
289-
const isAutoBuild = isAutoBuildSelector(state);
290309

291-
const body: ExecuteRequestBody = { channel, mode, edition, crateType, tests, code, backtrace };
310+
if (useWebsocketSelector(state)) {
311+
return dispatch(wsExecuteRequest(channel, mode, edition, crateType, tests, code, backtrace));
312+
} else {
313+
dispatch(requestExecute());
314+
315+
const body: ExecuteRequestBody = { channel, mode, edition, crateType, tests, code, backtrace };
292316

293-
return jsonPost<ExecuteResponseBody>(routes.execute, body)
294-
.then(json => dispatch(receiveExecuteSuccess({ ...json, isAutoBuild })))
295-
.catch(json => dispatch(receiveExecuteFailure({ ...json, isAutoBuild })));
317+
return jsonPost<ExecuteResponseBody>(routes.execute, body)
318+
.then(json => dispatch(receiveExecuteSuccess(json)))
319+
.catch(json => dispatch(receiveExecuteFailure(json)));
320+
}
296321
};
297322

298323
function performAutoOnly(): ThunkAction {
@@ -473,6 +498,32 @@ const PRIMARY_ACTIONS: { [index in PrimaryAction]: () => ThunkAction } = {
473498
[PrimaryActionCore.Wasm]: performCompileToNightlyWasmOnly,
474499
};
475500

501+
let sequenceNumber = 0;
502+
const nextSequenceNumber = () => sequenceNumber++;
503+
const makeExtra = (): ExecuteExtra => ({
504+
sequenceNumber: nextSequenceNumber(),
505+
});
506+
507+
const wsExecuteRequest = (
508+
channel: Channel,
509+
mode: Mode,
510+
edition: Edition,
511+
crateType: string,
512+
tests: boolean,
513+
code: string,
514+
backtrace: boolean
515+
) =>
516+
createAction(ActionType.WSExecuteRequest, {
517+
channel,
518+
mode,
519+
edition,
520+
crateType,
521+
tests,
522+
code,
523+
backtrace,
524+
extra: makeExtra(),
525+
});
526+
476527
export const performPrimaryAction = (): ThunkAction => (dispatch, getState) => {
477528
const state = getState();
478529
const primaryAction = PRIMARY_ACTIONS[state.configuration.primaryAction];
@@ -810,6 +861,11 @@ export const browserWidthChanged = (isSmall: boolean) =>
810861
export const splitRatioChanged = () =>
811862
createAction(ActionType.SplitRatioChanged);
812863

864+
export const websocketError = (error: string): WebSocketError => createAction(ActionType.WebSocketError, { error });
865+
export const websocketConnected = () => createAction(ActionType.WebSocketConnected);
866+
export const websocketDisconnected = () => createAction(ActionType.WebSocketDisconnected);
867+
export const websocketFeatureFlagEnabled = () => createAction(ActionType.WebSocketFeatureFlagEnabled);
868+
813869
function parseChannel(s?: string): Channel | null {
814870
switch (s) {
815871
case 'stable':
@@ -897,6 +953,7 @@ export function showExample(code: string): ThunkAction {
897953

898954
export type Action =
899955
| ReturnType<typeof initializeApplication>
956+
| ReturnType<typeof disableSyncChangesToStorage>
900957
| ReturnType<typeof setPage>
901958
| ReturnType<typeof changePairCharacters>
902959
| ReturnType<typeof changeAssemblyFlavor>
@@ -962,4 +1019,10 @@ export type Action =
9621019
| ReturnType<typeof notificationSeen>
9631020
| ReturnType<typeof browserWidthChanged>
9641021
| ReturnType<typeof splitRatioChanged>
1022+
| ReturnType<typeof websocketError>
1023+
| ReturnType<typeof websocketConnected>
1024+
| ReturnType<typeof websocketDisconnected>
1025+
| ReturnType<typeof websocketFeatureFlagEnabled>
1026+
| ReturnType<typeof wsExecuteRequest>
1027+
| WSExecuteResponse
9651028
;

ui/frontend/configureStore.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import { Action, initializeApplication } from './actions';
88
import initializeLocalStorage from './local_storage';
99
import initializeSessionStorage from './session_storage';
1010
import playgroundApp, { State } from './reducers';
11+
import { websocketMiddleware } from './websocketMiddleware';
1112

1213
export default function configureStore(window: Window) {
1314
const baseUrl = url.resolve(window.location.href, '/');
15+
const websocket = websocketMiddleware(window);
1416

1517
const initialGlobalState = {
1618
globalConfiguration: {
@@ -29,15 +31,25 @@ export default function configureStore(window: Window) {
2931
sessionStorage.initialState,
3032
);
3133

32-
const middlewares = applyMiddleware<ThunkDispatch<State, {}, Action>, {}>(thunk);
34+
const middlewares = applyMiddleware<ThunkDispatch<State, {}, Action>, {}>(thunk, websocket);
3335
const composeEnhancers: typeof compose = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
3436
const enhancers = composeEnhancers(middlewares);
3537
const store = createStore(playgroundApp, initialState, enhancers);
3638

3739
store.subscribe(() => {
3840
const state = store.getState();
39-
localStorage.saveChanges(state);
40-
sessionStorage.saveChanges(state);
41+
42+
// Some automated tests run fast enough that the following interleaving is possible:
43+
//
44+
// 1. RSpec test finishes, local/session storage cleared
45+
// 2. WebSocket connects, the state updates, and the local/session storage is saved
46+
// 3. Subsequent RSpec test starts and local/session storage has been preserved
47+
//
48+
// We allow the tests to stop saving to sidestep that.
49+
if (state.globalConfiguration.syncChangesToStorage) {
50+
localStorage.saveChanges(state);
51+
sessionStorage.saveChanges(state);
52+
}
4153
})
4254

4355
return store;

ui/frontend/declarations.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,6 @@ interface Window {
2020
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any;
2121
rustPlayground: {
2222
setCode(code: string): void;
23-
webSocket: WebSocket | null;
23+
disableSyncChangesToStorage(): void;
2424
};
2525
}

ui/frontend/index.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Provider } from 'react-redux';
1010

1111
import {
1212
editCode,
13+
disableSyncChangesToStorage,
1314
enableFeatureGate,
1415
gotoPosition,
1516
selectText,
@@ -18,19 +19,21 @@ import {
1819
performVersionsLoad,
1920
reExecuteWithBacktrace,
2021
browserWidthChanged,
22+
websocketFeatureFlagEnabled,
2123
} from './actions';
2224
import { configureRustErrors } from './highlighting';
2325
import PageSwitcher from './PageSwitcher';
2426
import playgroundApp from './reducers';
2527
import Router from './Router';
2628
import configureStore from './configureStore';
27-
import openWebSocket from './websocket';
28-
29-
// openWebSocket() may return null.
30-
const socket = openWebSocket(window.location);
3129

3230
const store = configureStore(window);
3331

32+
const params = new URLSearchParams(window.location.search);
33+
if (params.has('websocket')) {
34+
store.dispatch(websocketFeatureFlagEnabled());
35+
}
36+
3437
const z = (evt: MediaQueryList | MediaQueryListEvent) => { store.dispatch(browserWidthChanged(evt.matches)); };
3538

3639
const maxWidthMediaQuery = window.matchMedia('(max-width: 1600px)');
@@ -53,9 +56,9 @@ window.rustPlayground = {
5356
setCode: code => {
5457
store.dispatch(editCode(code));
5558
},
56-
// Temporarily storing this as a global to prevent it from being
57-
// garbage collected (at least by Safari).
58-
webSocket: socket,
59+
disableSyncChangesToStorage: () => {
60+
store.dispatch(disableSyncChangesToStorage());
61+
},
5962
};
6063

6164
const container = document.getElementById('playground');

ui/frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"reselect": "^4.0.0",
3030
"route-parser": "^0.0.5",
3131
"split-grid": "^1.0.9",
32-
"url": "^0.11.0"
32+
"url": "^0.11.0",
33+
"zod": "^3.20.3"
3334
},
3435
"devDependencies": {
3536
"@babel/core": "^7.0.0",
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
import { Action } from '../actions';
1+
import { Action, ActionType } from '../actions';
22

33
export interface State {
44
baseUrl: string;
5+
syncChangesToStorage: boolean;
56
}
67

78
const DEFAULT: State = {
89
baseUrl: '',
10+
syncChangesToStorage: true,
911
};
1012

11-
export default function globalConfiguration(state = DEFAULT, _action: Action): State {
12-
return state;
13+
export default function globalConfiguration(state = DEFAULT, action: Action): State {
14+
switch (action.type) {
15+
case ActionType.DisableSyncChangesToStorage: {
16+
return { ...state, syncChangesToStorage: false };
17+
}
18+
default:
19+
return state;
20+
}
1321
}

ui/frontend/reducers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import page from './page';
1111
import position from './position';
1212
import selection from './selection';
1313
import versions from './versions';
14+
import websocket from './websocket';
1415

1516
const playgroundApp = combineReducers({
1617
browser,
@@ -24,6 +25,7 @@ const playgroundApp = combineReducers({
2425
position,
2526
selection,
2627
versions,
28+
websocket,
2729
});
2830

2931
export type State = ReturnType<typeof playgroundApp>;

0 commit comments

Comments
 (0)