import _ from 'lodash' import { Action, AnyAction, Dispatch, Reducer, ReducersMapObject, Store, Unsubscribe, combineReducers, createStore } from 'redux' import { devToolsEnhancer } from 'redux-devtools-extension' import { AppHost, ExtensionSlot, ObservableState, ReducersMapObjectContributor, Shell, SlotKey, StateObserver } from './API' import { AppHostServicesProvider } from './appHostServices' import { contributeInstalledShellsState } from './installedShellsState' import { interceptAnyObject } from './interceptAnyObject' import { invokeSlotCallbacks } from './invokeSlotCallbacks' type ReducerNotificationScope = 'broadcasting' | 'observable' interface ShellsReducersMap { [shellName: string]: ReducersMapObject } interface AnyShellAction extends AnyAction { __shellName?: string } function createTimeOutPublisher(notify: () => void) { let id: undefined | NodeJS.Timeout = undefined return () => { if (id === undefined) { id = setTimeout(() => { id = undefined notify() }, 0) } return () => { if (id === undefined) { return } clearTimeout(id) id = undefined } } } function createAnimationFramePublisher(notify: () => void) { let id: undefined | number = undefined return () => { if (id === undefined) { id = requestAnimationFrame(() => { id = undefined notify() }) } return () => { if (id === undefined) { return } cancelAnimationFrame(id) id = undefined } } } type Subscriber = () => void export interface StateContribution { reducerFactory: ReducersMapObjectContributor notificationScope: ReducerNotificationScope observable?: AnyPrivateObservableState } export interface ThrottledStore extends Store { hasPendingSubscribers(): boolean flush(config?: { excecutionType: 'scheduled' | 'immediate' | 'default' }): void deferSubscriberNotifications(action: () => K | Promise, shouldDispatchClearCache?: boolean): Promise } export interface PrivateThrottledStore extends ThrottledStore { broadcastNotify(): void observableNotify(observer: AnyPrivateObservableState): void resetPendingNotifications(): void syncSubscribe(listener: () => void): Unsubscribe dispatchWithShell(shell: Shell): Dispatch } export interface PrivateObservableState extends ObservableState { notify(): void } export type AnyPrivateObservableState = PrivateObservableState const buildStoreReducer = ( contributedState: ExtensionSlot, broadcastNotify: PrivateThrottledStore['broadcastNotify'], observableNotify: PrivateThrottledStore['observableNotify'], shouldScopeReducers?: boolean ): Reducer => { function withNotifyAction( originalReducersMap: ReducersMapObject, notifyAction: () => void, storeShellName?: string ): ReducersMapObject { const decorateReducer = (originalReducer: Reducer): Reducer => { return (state0, action: AnyShellAction) => { if (shouldScopeReducers && state0 && storeShellName && action.__shellName && storeShellName !== action.__shellName) { return state0 } const state1 = originalReducer(state0, action) if (state1 !== state0) { notifyAction() } return state1 } } const wrapper = interceptAnyObject(originalReducersMap, (name, func) => { const originalReducer = func as Reducer return decorateReducer(originalReducer) }) return wrapper } function withBroadcastOrObservableNotify( { notificationScope, reducerFactory, observable }: StateContribution, storeShellName: string ): ReducersMapObject { const originalReducersMap = reducerFactory() if (notificationScope === 'broadcasting') { return withNotifyAction(originalReducersMap, broadcastNotify, storeShellName) } if (!observable) { // should never happen; would be an internal bug throw new Error( `getPerShellReducersMapObject: notificationScope=observable but 'observable' is falsy, in shell '${storeShellName}'` ) } return withNotifyAction(originalReducersMap, () => observableNotify(observable)) } function getPerShellReducersMapObject(): ShellsReducersMap { return contributedState.getItems().reduce((map: ShellsReducersMap, item) => { const shellName = item.shell.name map[shellName] = { ...map[shellName], ...withBroadcastOrObservableNotify(item.contribution, shellName) } return map }, {}) } function getCombinedShellReducers(): ReducersMapObject { const shellsReducerMaps = getPerShellReducersMapObject() const combinedReducersMap = _.mapValues(shellsReducerMaps, singleMap => combineReducers(singleMap)) return combinedReducersMap } function buildReducersMapObject(): ReducersMapObject { // TODO: get rid of builtInReducersMaps const builtInReducersMaps: ReducersMapObject = { ...contributeInstalledShellsState() } return { ...builtInReducersMaps, ...getCombinedShellReducers() } } const reducersMap = buildReducersMapObject() const combinedReducer = combineReducers(reducersMap) return combinedReducer } export const updateThrottledStore = ( host: AppHost & AppHostServicesProvider, store: PrivateThrottledStore, contributedState: ExtensionSlot ): void => { const newReducer = buildStoreReducer(contributedState, store.broadcastNotify, store.observableNotify, host.options.shouldScopeReducers) store.replaceReducer(newReducer) store.resetPendingNotifications() } export const createThrottledStore = ( host: AppHost & AppHostServicesProvider, contributedState: ExtensionSlot, updateIsSubscriptionNotifyInProgress: (isSubscriptionNotifyInProgress: boolean) => void, updateIsObserversNotifyInProgress: (isObserversNotifyInProgress: boolean) => void, updateShouldFlushMemoizationSync: (shouldFlushMemoizationSync: boolean) => void ): PrivateThrottledStore => { let pendingBroadcastNotification = false let pendingObservableNotifications: Set | undefined let isDeferrringNotifications = false let pendingFlush = false const onBroadcastNotify = () => { pendingBroadcastNotification = true } const onObservableNotify = (observable: AnyPrivateObservableState) => { if (!pendingObservableNotifications) { pendingObservableNotifications = new Set() } pendingObservableNotifications.add(observable) } const resetAllPendingNotifications = () => { pendingBroadcastNotification = false pendingObservableNotifications = undefined } const reducer = buildStoreReducer(contributedState, onBroadcastNotify, onObservableNotify, host.options.shouldScopeReducers) const store: Store = host.options.enableReduxDevtoolsExtension ? createStore(reducer, devToolsEnhancer({ name: 'repluggable' })) : createStore(reducer) const invoke = (f: Subscriber) => f() let broadcastSubscribers: Subscriber[] = [] const subscribe = (subscriber: Subscriber) => { broadcastSubscribers = _.concat(broadcastSubscribers, subscriber) return () => { broadcastSubscribers = _.without(broadcastSubscribers, subscriber) } } const notifySubscribers = () => { if (pendingBroadcastNotification || !pendingObservableNotifications) { host.getAppHostServicesShell().log.monitor('ThrottledStore.notifySubscribers', {}, () => _.forEach(broadcastSubscribers, invoke) ) } } const notifyObservers = () => { if (pendingObservableNotifications) { pendingObservableNotifications.forEach(observable => { observable.notify() }) } } const notifyAll = () => { try { updateIsObserversNotifyInProgress(true) notifyObservers() updateIsSubscriptionNotifyInProgress(true) notifySubscribers() } finally { resetAllPendingNotifications() updateIsSubscriptionNotifyInProgress(false) updateIsObserversNotifyInProgress(false) } } const scheduledNotifyAll = () => { if (isDeferrringNotifications) { return } notifyAll() } const notifyAllOnPublish = typeof window === 'undefined' ? createTimeOutPublisher(scheduledNotifyAll) : createAnimationFramePublisher(scheduledNotifyAll) let cancelRender = _.noop store.subscribe(() => { cancelRender = notifyAllOnPublish() }) const flush = (config = { excecutionType: 'default' }) => { if (isDeferrringNotifications && config.excecutionType !== 'immediate') { pendingFlush = true return } if (config.excecutionType !== 'scheduled') { cancelRender() } notifyAll() } const dispatch: Dispatch = action => { return store.dispatch(action) } const toShellAction = (shell: Shell, action: T): T => ({ ...action, __shellName: shell.name }) const executePendingActions = () => { if (pendingFlush) { pendingFlush = false flush() } else if (pendingBroadcastNotification || pendingObservableNotifications) { notifyAll() } } const result: PrivateThrottledStore = { ...store, subscribe, syncSubscribe: store.subscribe, dispatch, dispatchWithShell: shell => action => dispatch(toShellAction(shell, action)), flush, broadcastNotify: onBroadcastNotify, observableNotify: onObservableNotify, resetPendingNotifications: resetAllPendingNotifications, hasPendingSubscribers: () => pendingBroadcastNotification, deferSubscriberNotifications: async (action, shouldDispatchClearCache) => { if (isDeferrringNotifications) { return action() } try { executePendingActions() isDeferrringNotifications = true shouldDispatchClearCache && updateShouldFlushMemoizationSync(isDeferrringNotifications) const functionResult = await action() return functionResult } finally { isDeferrringNotifications = false shouldDispatchClearCache && updateShouldFlushMemoizationSync(isDeferrringNotifications) executePendingActions() } } } resetAllPendingNotifications() return result } export const createObservable = ( shell: Shell, uniqueName: string, selectorFactory: (state: TState) => TSelector ): PrivateObservableState => { const subscribersSlotKey: SlotKey> = { name: uniqueName } const observersSlot = shell.declareSlot(subscribersSlotKey) const createSelector = (): TSelector => { return selectorFactory(shell.getStore().getState()) } return { subscribe(fromShell, callback) { observersSlot.contribute(fromShell, callback) return () => { observersSlot.discardBy(item => item.contribution === callback) } }, notify() { const newSelector = createSelector() invokeSlotCallbacks(observersSlot, newSelector) }, current: createSelector } }