diff --git a/package.json b/package.json index 4cafc439..7c57e4a5 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "pako": "2.1.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-redux": "8.1.2" + "react-redux": "8.1.2", + "rxjs": "8.0.0-alpha.10" }, "devDependencies": { "@types/jest": "28.1.8", diff --git a/src/app/actionListener.ts b/src/app/actionListener.ts deleted file mode 100644 index 0c13fb2a..00000000 --- a/src/app/actionListener.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { MiddlewareAPI, Middleware, PayloadActionCreator } from '@reduxjs/toolkit' -import type { RootState } from './store' - -interface ListenerAPI extends MiddlewareAPI { - getState: () => RootState -} - -type ListenCallback = (payload: TPayload, api: ListenerAPI) => void | Promise - -type Subscription = Set> - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Subscriptions = Map> - -interface ListenOptions { - once?: boolean -} - -type Unsubscribe = () => void - -type ListenAction = ( - actionCreator: PayloadActionCreator, - callback: ListenCallback, - options?: ListenOptions -) => Unsubscribe - -interface ActionListener extends Middleware { - listenAction: ListenAction -} - -const createActionListener = (): ActionListener => { - const subscriptions: Subscriptions = new Map() - - // FIXME: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actionListener: ActionListener = api => next => (action: any) => { - const result = next(action) - subscriptions.get(action.type)?.forEach(cb => cb(action.payload, api)) - return result - } - - actionListener.listenAction = (actionCreator, __callback, { once = false } = {}) => { - const { type: actionType } = actionCreator - if (!subscriptions.has(actionType)) { - subscriptions.set(actionType, new Set()) - } - const callbacks = subscriptions.get(actionType)! - const callback: typeof __callback = once - ? (payload, api) => { - unsubscribe() - return __callback(payload, api) - } - : __callback - callbacks.add(callback) - const unsubscribe: Unsubscribe = () => { - if (callbacks.delete(callback) && callbacks.size === 0) { - subscriptions.delete(actionType) - } - } - return unsubscribe - } - - return actionListener -} - -export const actionListener = createActionListener() -export const { listenAction } = actionListener diff --git a/src/app/actionObserver.ts b/src/app/actionObserver.ts new file mode 100644 index 00000000..b2db06bb --- /dev/null +++ b/src/app/actionObserver.ts @@ -0,0 +1,28 @@ +import { Middleware, PayloadActionCreator, Action, isAction } from '@reduxjs/toolkit' +import { Observable, Subject, filter, map } from 'rxjs' + +interface ActionObserver { + middleware: Middleware + onAction: (actionCreator: PayloadActionCreator) => Observable +} + +export const createActionObserver = (): ActionObserver => { + const action$ = new Subject() + + const middleware: Middleware = () => next => action => { + const result = next(action) + if (isAction(action)) { + action$.next(action) + } + return result + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const onAction = (actionCreator: PayloadActionCreator): Observable => + action$.pipe( + filter(actionCreator.match), + map(action => action.payload) + ) + + return { middleware, onAction } +} diff --git a/src/app/hooks.ts b/src/app/hooks.ts index 68e1f400..1c2fcc2d 100644 --- a/src/app/hooks.ts +++ b/src/app/hooks.ts @@ -7,6 +7,6 @@ import type { RootState, Store } from './store' type TypedUseStoreHook = () => Store -export const useStore: TypedUseStoreHook = __useStore +export const useStore = __useStore as TypedUseStoreHook export const useSelector: TypedUseSelectorHook = __useSelector diff --git a/src/app/stateObserver.ts b/src/app/stateObserver.ts new file mode 100644 index 00000000..ace3f1f5 --- /dev/null +++ b/src/app/stateObserver.ts @@ -0,0 +1,36 @@ +import type { Middleware } from '@reduxjs/toolkit' +import { Observable, ReplaySubject, map, distinctUntilChanged, skip } from 'rxjs' +import type { RootState, RootStateSelector } from './store' + +interface StateObserver { + middleware: Middleware + onState: (selector: RootStateSelector) => Observable +} + +export const createStateObserver = (): StateObserver => { + const state$ = new ReplaySubject(1) + const distinctState$ = state$.pipe(distinctUntilChanged()) + + const middleware: Middleware = api => { + state$.next(api.getState()) + let nestedDepth = 0 + + return next => action => { + try { + nestedDepth += 1 + const result = next(action) + if (nestedDepth === 1) { + state$.next(api.getState()) + } + return result + } finally { + nestedDepth -= 1 + } + } + } + + const onState = (selector: RootStateSelector): Observable => + distinctState$.pipe(map(selector), distinctUntilChanged(), skip(1)) + + return { middleware, onState } +} diff --git a/src/app/stateSaver.ts b/src/app/stateSaver.ts index 5aa02fb0..27c3bf8b 100644 --- a/src/app/stateSaver.ts +++ b/src/app/stateSaver.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { useStore } from './hooks' -import { watch } from './watcher' +import { subscribe } from './subscribe' import { StateToPersist, selectStateToPersist } from './persist' import { saveState as saveStateToUrl } from './url' import { saveState as saveStateToLocalStorage } from './localStorage' @@ -16,6 +16,6 @@ export const useStateSaver = (): void => { useEffect(() => { const stateToPersist = selectStateToPersist(store.getState()) saveState(stateToPersist) - return watch(selectStateToPersist, saveState) + return subscribe(store.onState(selectStateToPersist), saveState) }, []) } diff --git a/src/app/store.ts b/src/app/store.ts index 117cfa88..809c6ad5 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -9,8 +9,8 @@ import exceptionReducer from '@/features/exception/exceptionSlice' import { loadState as loadStateFromLocalStorage } from './localStorage' import { loadState as loadStateFromUrl } from './url' import { getInitialStateToPersist } from './persist' -import { actionListener } from './actionListener' -import { watcher } from './watcher' +import { createActionObserver } from './actionObserver' +import { createStateObserver } from './stateObserver' import { merge } from '@/common/utils' const rootReducer = combineReducers({ @@ -34,14 +34,23 @@ const getPreloadedState = (): Partial => { return merge(getInitialStateToPersist(), stateFromLocalStorage, stateFromUrl) } -const store = configureStore({ - reducer: rootReducer, - middleware: getDefaultMiddleware => { - const defaultMiddleware = getDefaultMiddleware() - return defaultMiddleware.prepend(watcher, actionListener) - }, - preloadedState: getPreloadedState() -}) +const actionObserver = createActionObserver() +const stateObserver = createStateObserver() + +const store = Object.assign( + configureStore({ + reducer: rootReducer, + middleware: getDefaultMiddleware => { + const defaultMiddleware = getDefaultMiddleware() + return defaultMiddleware.prepend(stateObserver.middleware, actionObserver.middleware) + }, + preloadedState: getPreloadedState() + }), + { + onAction: actionObserver.onAction, + onState: stateObserver.onState + } +) export type Store = typeof store diff --git a/src/app/subscribe.ts b/src/app/subscribe.ts new file mode 100644 index 00000000..a726a40c --- /dev/null +++ b/src/app/subscribe.ts @@ -0,0 +1,11 @@ +import type { Observable, Observer, Subscription } from 'rxjs' + +export type Unsubscribe = Subscription['unsubscribe'] + +export const subscribe = ( + observable: Observable, + observerOrNext?: Partial> | ((value: T) => void) | null +): Unsubscribe => { + const subscription = observable.subscribe(observerOrNext) + return () => subscription.unsubscribe() +} diff --git a/src/app/watcher.ts b/src/app/watcher.ts deleted file mode 100644 index 98fd71aa..00000000 --- a/src/app/watcher.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { MiddlewareAPI, Middleware } from '@reduxjs/toolkit' -import type { RootState, RootStateSelector } from './store' - -interface WatcherAPI extends MiddlewareAPI { - getState: () => RootState -} - -type WatchCallback = (selectedState: TSelected, api: WatcherAPI) => void | Promise - -interface Subscription { - selectedState: TSelected - callbacks: Set> -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Subscriptions = Map, Subscription> - -export type Unsubscribe = () => void - -type Watch = ( - selector: RootStateSelector, - callback: WatchCallback -) => Unsubscribe - -interface Watcher extends Middleware { - watch: Watch -} - -const nil = Symbol('nil') - -const createWatcher = (): Watcher => { - const subscriptions: Subscriptions = new Map() - let hasUninitializedSubscription = false - let stackCount = 0 - - const watcher: Watcher = api => next => action => { - if (hasUninitializedSubscription) { - const startState = api.getState() - subscriptions.forEach((subscription, selector) => { - if (subscription.selectedState === nil) { - subscription.selectedState = selector(startState) - } - }) - hasUninitializedSubscription = false - } - stackCount += 1 - const result = next(action) - stackCount -= 1 - if (stackCount === 0) { - const state = api.getState() - subscriptions.forEach((subscription, selector) => { - const currentSelectedState = selector(state) - if (currentSelectedState !== subscription.selectedState) { - subscription.callbacks.forEach(cb => cb(currentSelectedState, api)) - subscription.selectedState = currentSelectedState - } - }) - } - return result - } - - watcher.watch = (selector, callback) => { - if (!subscriptions.has(selector)) { - subscriptions.set(selector, { - selectedState: nil, - callbacks: new Set() - }) - hasUninitializedSubscription = true - } - const { callbacks } = subscriptions.get(selector)! - callbacks.add(callback) - const unsubscribe: Unsubscribe = () => { - if (callbacks.delete(callback) && callbacks.size === 0) { - subscriptions.delete(selector) - } - } - return unsubscribe - } - - return watcher -} - -export const watcher = createWatcher() -export const { watch } = watcher diff --git a/src/features/assembler/assemblerSlice.ts b/src/features/assembler/assemblerSlice.ts index 2ea2ce27..14f9948f 100644 --- a/src/features/assembler/assemblerSlice.ts +++ b/src/features/assembler/assemblerSlice.ts @@ -43,6 +43,9 @@ export const assemblerSlice = createSlice({ export const selectAssembledSource = (state: RootState): string => state.assembler.source +export const selectIsAssembled = (state: RootState): boolean => + state.assembler.source !== initialState.source + export const selectAddressToStatementMap = (state: RootState): Partial => state.assembler.addressToStatementMap diff --git a/src/features/controller/hooks.ts b/src/features/controller/hooks.ts index 68964669..d792b50b 100644 --- a/src/features/controller/hooks.ts +++ b/src/features/controller/hooks.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' +import { debounceTime, filter, first } from 'rxjs' import type { RootStateSelector, Store } from '@/app/store' -import { listenAction } from '@/app/actionListener' -import { watch } from '@/app/watcher' +import { subscribe } from '@/app/subscribe' import { useStore } from '@/app/hooks' import { selectRuntimeConfiguration, @@ -25,6 +25,7 @@ import { lineRangesOverlap } from '@/features/editor/codemirror/text' import { createAssemble } from '@/features/assembler/assemble' import { selectAssembledSource, + selectIsAssembled, selectAddressToStatementMap, setAssemblerState, resetAssemblerState @@ -298,8 +299,8 @@ class Controller { this.store.dispatch(setWaitingForKeyboardInput(true)) break } - this.unsubscribeSetSuspended = listenAction( - setSuspended, + this.unsubscribeSetSuspended = subscribe( + this.store.onAction(setSuspended).pipe(first()), // payload must be false async () => { this.unsubscribeSetSuspended = undefined @@ -308,8 +309,7 @@ class Controller { this.resumeMainLoop() } await this.step() - }, - { once: true } + } ) } else { // wrong port @@ -410,37 +410,35 @@ export const useController = (): Controller => { const controller = useSingleton(() => new Controller(store)) useEffect(() => { - return listenAction(setEditorInput, controller.resetSelf) + return subscribe(store.onAction(setEditorInput), controller.resetSelf) }, []) useEffect(() => { - let assembleTimeoutId: number | undefined - return listenAction(setAutoAssemble, (shouldAutoAssemble, api) => { - if (assembleTimeoutId !== undefined) { - window.clearTimeout(assembleTimeoutId) - assembleTimeoutId = undefined - } - if (shouldAutoAssemble && selectAssembledSource(api.getState()) === '') { - assembleTimeoutId = window.setTimeout(() => { - controller.assemble() - assembleTimeoutId = undefined - }, UPDATE_TIMEOUT_MS) - } - }) + return subscribe( + store.onAction(setAutoAssemble).pipe( + debounceTime(UPDATE_TIMEOUT_MS), + filter(shouldAutoAssemble => shouldAutoAssemble && !selectIsAssembled(store.getState())) + ), + controller.assemble + ) }, []) useEffect(() => { - return listenAction(setAssemblerState, controller.resetSelf) + return subscribe(store.onAction(setAssemblerState), controller.resetSelf) }, []) useEffect(() => { - return watch(selectRuntimeConfiguration, async (_, api) => { - // `setSuspended` action listener will resume the main loop with new configuration - // so we skip calling `stopAndRun` if cpu is suspended - if (selectIsRunning(api.getState()) && !selectIsSuspended(api.getState())) { - await controller.stopAndRun() - } - }) + return subscribe( + store.onState(selectRuntimeConfiguration).pipe( + filter(() => { + const state = store.getState() + // `setSuspended` action listener will resume the main loop with new configuration + // so we skip calling `stopAndRun` if cpu is suspended + return selectIsRunning(state) && !selectIsSuspended(state) + }) + ), + controller.stopAndRun + ) }, []) return controller diff --git a/src/features/editor/hooks.ts b/src/features/editor/hooks.ts index be7ac009..41ca60a8 100644 --- a/src/features/editor/hooks.ts +++ b/src/features/editor/hooks.ts @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react' +import { filter } from 'rxjs' import { addUpdateListener } from '@codemirror-toolkit/extensions' import { rangeSetsEqual, mapRangeSetToArray } from '@codemirror-toolkit/utils' -import { listenAction } from '@/app/actionListener' -import { watch } from '@/app/watcher' +import { subscribe } from '@/app/subscribe' import { useStore, useSelector } from '@/app/hooks' import { MessageType, @@ -71,8 +71,9 @@ export const useSyncInput = (): void => { }, []) useViewEffect(view => { - return listenAction(setEditorInput, ({ value, isFromFile }) => { - if (isFromFile) { + return subscribe( + store.onAction(setEditorInput).pipe(filter(({ isFromFile }) => isFromFile)), + ({ value }) => { view.dispatch( syncFromState({ changes: { @@ -83,14 +84,19 @@ export const useSyncInput = (): void => { }) ) } - }) + ) }, []) } export const useAutoFocus = (): void => { + const store = useStore() + useViewEffect(view => { - return listenAction(setEditorInput, ({ value, isFromFile }) => { - if (isFromFile && value === template.content) { + return subscribe( + store + .onAction(setEditorInput) + .pipe(filter(({ value, isFromFile }) => isFromFile && value === template.content)), + () => { view.focus() const { title, content } = template const titleIndex = content.indexOf(title) @@ -101,7 +107,7 @@ export const useAutoFocus = (): void => { } }) } - }) + ) }, []) } @@ -125,8 +131,8 @@ export const useAutoAssemble = (): void => { }, []) useEffect(() => { - return listenAction(setEditorInput, ({ value, isFromFile }, api) => { - if (selectAutoAssemble(api.getState())) { + return subscribe(store.onAction(setEditorInput), ({ value, isFromFile }) => { + if (selectAutoAssemble(store.getState())) { if (isFromFile) { window.setTimeout(() => { assemble(value) @@ -151,7 +157,7 @@ export const useAssemblerError = (): void => { }, []) useViewEffect(view => { - return watch(selectAssemblerErrorRange, errorRange => { + return subscribe(store.onState(selectAssemblerErrorRange), errorRange => { const hasError = errorRange !== undefined view.dispatch({ effects: WavyUnderlineEffect.of({ @@ -164,16 +170,19 @@ export const useAssemblerError = (): void => { } export const useHighlightLine = (): void => { + const store = useStore() + useEffect(() => { - return listenAction(setEditorInput, ({ isFromFile }, api) => { - if (isFromFile) { - api.dispatch(clearEditorHighlightRange()) + return subscribe( + store.onAction(setEditorInput).pipe(filter(({ isFromFile }) => isFromFile)), + () => { + store.dispatch(clearEditorHighlightRange()) } - }) + ) }, []) useViewEffect(view => { - return watch(selectEditorHighlightLinePos(view), linePos => { + return subscribe(store.onState(selectEditorHighlightLinePos(view)), linePos => { const shouldAddHighlight = linePos !== undefined view.dispatch({ effects: shouldAddHighlight @@ -272,6 +281,8 @@ const errorToMessage = (error: Error): EditorMessage => { } export const useMessage = (): EditorMessage | null => { + const store = useStore() + const assemblerError = useSelector(selectAssemblerError) const runtimeError = useSelector(selectCpuFault) @@ -281,31 +292,34 @@ export const useMessage = (): EditorMessage | null => { const messageTimeoutIdRef = useRef() useEffect(() => { - return listenAction(setEditorMessage, (_, api) => { + return subscribe(store.onAction(setEditorMessage), () => { if (messageTimeoutIdRef.current !== undefined) { window.clearTimeout(messageTimeoutIdRef.current) } messageTimeoutIdRef.current = window.setTimeout(() => { - api.dispatch(clearEditorMessage()) + store.dispatch(clearEditorMessage()) messageTimeoutIdRef.current = undefined }, MESSAGE_DURATION_MS) }) }, []) useEffect(() => { - return listenAction(setCpuHalted, (_, api) => { - api.dispatch(setEditorMessage(haltedMessage)) + return subscribe(store.onAction(setCpuHalted), () => { + store.dispatch(setEditorMessage(haltedMessage)) }) }, []) useEffect(() => { - return listenAction(resetCpuState, (_, api) => { - if (selectEditorMessage(api.getState()) === haltedMessage) { + return subscribe( + store + .onAction(resetCpuState) + .pipe(filter(() => selectEditorMessage(store.getState()) === haltedMessage)), + () => { window.clearTimeout(messageTimeoutIdRef.current) messageTimeoutIdRef.current = undefined - api.dispatch(clearEditorMessage()) + store.dispatch(clearEditorMessage()) } - }) + ) }, []) if (error !== null) { diff --git a/src/features/io/SevenSegmentDisplay.tsx b/src/features/io/SevenSegmentDisplay.tsx index 83f7e9ea..b804e3f0 100644 --- a/src/features/io/SevenSegmentDisplay.tsx +++ b/src/features/io/SevenSegmentDisplay.tsx @@ -1,7 +1,8 @@ import { memo, useState, useEffect } from 'react' import { createNextState } from '@reduxjs/toolkit' import DeviceCard from './DeviceCard' -import { listenAction } from '@/app/actionListener' +import { useStore } from '@/app/hooks' +import { subscribe } from '@/app/subscribe' import { IoDeviceName, resetIoState } from './ioSlice' import { useIoDevice } from './hooks' import { range } from '@/common/utils' @@ -135,12 +136,14 @@ const DATA_DIGIT_COUNT = 14 const initialData = new Array(DATA_DIGIT_COUNT).fill(0) const SevenSegmentDisplay = (): JSX.Element | null => { + const store = useStore() + // an array of 14 digits, // elements with even index represent the left part const [data, setData] = useState(initialData) useEffect(() => { - return listenAction(resetIoState, () => { + return subscribe(store.onAction(resetIoState), () => { setData(initialData) }) }, []) diff --git a/src/features/io/hooks.ts b/src/features/io/hooks.ts index 55d6535c..6592f583 100644 --- a/src/features/io/hooks.ts +++ b/src/features/io/hooks.ts @@ -1,6 +1,6 @@ import { useEffect, useCallback, useMemo } from 'react' -import { listenAction } from '@/app/actionListener' -import { Unsubscribe, watch } from '@/app/watcher' +import { filter } from 'rxjs' +import { Unsubscribe, subscribe } from '@/app/subscribe' import { useStore, useSelector } from '@/app/hooks' import { IoDeviceName, @@ -29,7 +29,7 @@ export const useIoDevice = (deviceName: IoDeviceName): IoDevice => { type DataListener = (data: number[]) => void const subscribeData = useCallback( (listener: DataListener) => { - return watch(selectData, listener) + return subscribe(store.onState(selectData), listener) }, [deviceName] ) @@ -43,11 +43,12 @@ export const useIoDevice = (deviceName: IoDeviceName): IoDevice => { useEffect(() => { if (!isVisible) { - return listenAction(setIoDeviceData, ({ name: targetDeviceName }) => { - if (targetDeviceName === deviceName) { - toggleVisible() - } - }) + return subscribe( + store + .onAction(setIoDeviceData) + .pipe(filter(({ name: targetDeviceName }) => targetDeviceName === deviceName)), + toggleVisible + ) } }, [isVisible, deviceName]) @@ -55,12 +56,13 @@ export const useIoDevice = (deviceName: IoDeviceName): IoDevice => { } export const useVisualDisplayUnit = (): ReturnType => { + const store = useStore() const device = useIoDevice(IoDeviceName.VisualDisplayUnit) useEffect(() => { - return listenAction(setMemoryDataFrom, (_, api) => { - const memoryData = selectMemoryData(api.getState()) - api.dispatch(setVduDataFrom(memoryData)) + return subscribe(store.onAction(setMemoryDataFrom), () => { + const memoryData = selectMemoryData(store.getState()) + store.dispatch(setVduDataFrom(memoryData)) }) }, []) diff --git a/yarn.lock b/yarn.lock index 0a000249..0a87c0ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2930,6 +2930,7 @@ __metadata: react: 18.2.0 react-dom: 18.2.0 react-redux: 8.1.2 + rxjs: 8.0.0-alpha.10 ts-jest: 28.0.8 ts-standardx: 0.8.4 ts-toolbelt: 9.6.0 @@ -7269,6 +7270,13 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:8.0.0-alpha.10": + version: 8.0.0-alpha.10 + resolution: "rxjs@npm:8.0.0-alpha.10" + checksum: 93a7c3d8cbf96e47123c8c691642adcbe84743665aa0b69f98567b27ebe76c1aaf24e3b463bd7427d34f7e1341dfa229319c0eee2f2ea124d7df8598b93f1115 + languageName: node + linkType: hard + "safe-array-concat@npm:^1.0.0": version: 1.0.0 resolution: "safe-array-concat@npm:1.0.0"