diff --git a/src/appHost.tsx b/src/appHost.tsx index 703144e..0f30ca1 100644 --- a/src/appHost.tsx +++ b/src/appHost.tsx @@ -129,6 +129,7 @@ export function createAppHost(initialEntryPointsOrPackages: EntryPointOrPackage[ let isInstallingEntryPoints: boolean = false let isStoreSubscribersNotifyInProgress = false let isObserversNotifyInProgress = false + let shouldFlushMemoizationSync = false const entryPointsInstallationEndCallbacks: Map void> = new Map() verifyLayersUniqueness(options.layers) @@ -665,6 +666,9 @@ miss: ${memoizedWithMissHit.miss} }, notifyObserversIsRunning => { isObserversNotifyInProgress = notifyObserversIsRunning + }, + updateShouldFlushMemoizationSync => { + shouldFlushMemoizationSync = updateShouldFlushMemoizationSync } ) store.subscribe(() => { @@ -675,7 +679,7 @@ miss: ${memoizedWithMissHit.miss} }) store.syncSubscribe(() => { shouldFlushMemoization = true - if (isStoreSubscribersNotifyInProgress || isObserversNotifyInProgress) { + if (isStoreSubscribersNotifyInProgress || isObserversNotifyInProgress || shouldFlushMemoizationSync) { shouldFlushMemoization = false flushMemoizedForState() } diff --git a/src/throttledStore.tsx b/src/throttledStore.tsx index bb57a94..40db6d7 100644 --- a/src/throttledStore.tsx +++ b/src/throttledStore.tsx @@ -1,8 +1,8 @@ -import { Reducer, createStore, Store, ReducersMapObject, combineReducers, AnyAction, Dispatch, Action, Unsubscribe } from 'redux' +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 _ from 'lodash' -import { AppHost, ExtensionSlot, ReducersMapObjectContributor, ObservableState, StateObserver, Shell, SlotKey } from './API' import { contributeInstalledShellsState } from './installedShellsState' import { interceptAnyObject } from './interceptAnyObject' import { invokeSlotCallbacks } from './invokeSlotCallbacks' @@ -47,7 +47,7 @@ export interface StateContribution extends Store { hasPendingSubscribers(): boolean flush(config?: { excecutionType: 'scheduled' | 'immediate' | 'default' }): void - deferSubscriberNotifications(action: () => K | Promise): Promise + deferSubscriberNotifications(action: () => K | Promise, shouldDispatchClearCache?: boolean): Promise } export interface PrivateThrottledStore extends ThrottledStore { @@ -157,7 +157,8 @@ export const createThrottledStore = ( requestAnimationFrame: Window['requestAnimationFrame'], cancelAnimationFrame: Window['cancelAnimationFrame'], updateIsSubscriptionNotifyInProgress: (isSubscriptionNotifyInProgress: boolean) => void, - updateIsObserversNotifyInProgress: (isObserversNotifyInProgress: boolean) => void + updateIsObserversNotifyInProgress: (isObserversNotifyInProgress: boolean) => void, + updateShouldFlushMemoizationSync: (shouldFlushMemoizationSync: boolean) => void ): PrivateThrottledStore => { let pendingBroadcastNotification = false let pendingObservableNotifications: Set | undefined @@ -279,17 +280,19 @@ export const createThrottledStore = ( observableNotify: onObservableNotify, resetPendingNotifications: resetAllPendingNotifications, hasPendingSubscribers: () => pendingBroadcastNotification, - deferSubscriberNotifications: async action => { + 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() } } diff --git a/test/connectWithShell.spec.tsx b/test/connectWithShell.spec.tsx index ed2915f..7ceb6d5 100644 --- a/test/connectWithShell.spec.tsx +++ b/test/connectWithShell.spec.tsx @@ -1,22 +1,22 @@ import _ from 'lodash' import React, { FunctionComponent, ReactElement, useEffect } from 'react' -import { AppHost, EntryPoint, Shell, SlotKey, ObservableState, AnySlotKey } from '../src/API' +import { act, create, ReactTestInstance, ReactTestRenderer } from 'react-test-renderer' +import { AnyAction } from 'redux' +import { ObservedSelectorsMap, observeWithShell } from '../src' +import { AnySlotKey, AppHost, EntryPoint, ObservableState, Shell, SlotKey } from '../src/API' import { + collectAllTexts, + connectWithShell, + connectWithShellAndObserve, createAppHost, mockPackage, mockShellStateKey, MockState, renderInHost, - connectWithShell, - connectWithShellAndObserve, - withThrowOnError, TOGGLE_MOCK_VALUE, - collectAllTexts + withThrowOnError } from '../testKit' -import { ReactTestRenderer, act, create, ReactTestInstance } from 'react-test-renderer' -import { AnyAction } from 'redux' -import { ObservedSelectorsMap, observeWithShell } from '../src' interface MockPackageState { [mockShellStateKey]: MockState @@ -59,7 +59,9 @@ describe('connectWithShell', () => { const PureComp = ({ shellName }: { shellName: string }) =>
{shellName}
const mapStateToProps = (s: Shell) => ({ shellName: s.name }) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { parentWrapper } = renderInShellContext() expect(collectAllTexts(parentWrapper)).toContain(mockPackage.name) @@ -128,7 +130,9 @@ describe('connectWithShell', () => { return
{JSON.stringify(obj)}
} - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() @@ -382,7 +386,9 @@ describe('connectWithShell', () => { // Assert - outer component re-rendered and passed new ownProps to inner component expect(mapStateOuterCompSpy).toHaveBeenCalledTimes(2) expect(outerComponentRenderSpy).toHaveBeenCalledTimes(2) - expect(innerCompShouldComponentUpdateSpy).toHaveBeenCalledWith({ num: 2 }) + expect(innerCompShouldComponentUpdateSpy).toHaveBeenCalledWith({ + num: 2 + }) // Assert - should not trigger mapDispatchToProps or re-render of inner component even though it's ownProps have changed expect(mapDispatchInnerCompSpy).toHaveBeenCalledTimes(1) @@ -497,7 +503,9 @@ describe('connectWithShell', () => { MockPackageState, PureCompWithChildrenOwnProps, PureCompWithChildrenStateProps - >(mapStateToProps, undefined, getBoundShell(), { allowOutOfEntryPoint: true })(PureCompWithChildren) + >(mapStateToProps, undefined, getBoundShell(), { + allowOutOfEntryPoint: true + })(PureCompWithChildren) const { testKit } = renderInShellContext( @@ -521,7 +529,9 @@ describe('connectWithShell', () => { } }) const PureComp: FunctionComponent<{}> = () =>
TEST
- const ConnectedComp = connectWithShell(undefined, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(undefined, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) // act const { testKit } = renderInHost(, host, shell) @@ -542,7 +552,9 @@ describe('connectWithShell', () => { } }) const PureComp: FunctionComponent<{}> = () =>
TEST
- const ConnectedComp = connectWithShell(undefined, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(undefined, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) // act @@ -572,7 +584,9 @@ describe('connectWithShell', () => { const PureComp: FunctionComponent<{}> = () => ( {aspect =>
{aspect.theNumber}
}
) - const ConnectedComp = connectWithShell(undefined, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(undefined, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) // act @@ -601,7 +615,10 @@ describe('connectWithShell-useCases', () => { interface SecondStateAPI { getValueTwo(): string } - const SecondStateAPI: SlotKey = { name: 'TWO_API', public: true } + const SecondStateAPI: SlotKey = { + name: 'TWO_API', + public: true + } interface FirstObservableAPI { observables: { three: ObservableState } @@ -609,7 +626,10 @@ describe('connectWithShell-useCases', () => { interface FirstObservableSelectors { getValueThree(): string } - const FirstObservableAPI: SlotKey = { name: 'THREE_API', public: true } + const FirstObservableAPI: SlotKey = { + name: 'THREE_API', + public: true + } interface SecondObservableAPI { observables: { four: ObservableState } @@ -617,7 +637,10 @@ describe('connectWithShell-useCases', () => { interface SecondObservableSelectors { getValueFour(): string } - const SecondObservableAPI: SlotKey = { name: 'FOUR_API', public: true } + const SecondObservableAPI: SlotKey = { + name: 'FOUR_API', + public: true + } const entryPointWithState: EntryPoint = { name: 'ONE', @@ -791,7 +814,9 @@ describe('connectWithShell-useCases', () => { it('should not notify subscribers when deferring notifications', async () => { const { host, shell, renderInShellContext } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { @@ -819,7 +844,9 @@ describe('connectWithShell-useCases', () => { it('should not have pending subscribers when starting to defer notifications', async () => { const { host, shell, renderInShellContext } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { @@ -839,7 +866,9 @@ describe('connectWithShell-useCases', () => { it('should notify subscribers of state changes before deferring notifications', async () => { const { host, shell, renderInShellContext } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { @@ -857,7 +886,9 @@ describe('connectWithShell-useCases', () => { it('should notify after action failed while deferring notifications', async () => { const { host, shell, renderInShellContext } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { @@ -884,7 +915,9 @@ describe('connectWithShell-useCases', () => { it('should flush while deferring notifications if immediate flush was called during that action', async () => { const { host, shell, renderInShellContext } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { @@ -906,7 +939,9 @@ describe('connectWithShell-useCases', () => { it('should support nested defered notification actions', async () => { const { host, shell, renderInShellContext } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { @@ -937,9 +972,39 @@ describe('connectWithShell-useCases', () => { expect(renderSpy).toHaveBeenCalledTimes(2) }) + it('should clear state memoization on dispatch when deferring notifications and setting shouldDispatchClearCache', async () => { + const { host, shell } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) + let numberOfCalls = 0 + const originalFn = jest.fn(() => ++numberOfCalls) + const memoizedFn = shell.memoizeForState(originalFn, () => '*') as _.MemoizedFunction + const clearCacheSpy = jest.spyOn(memoizedFn.cache, 'clear') + + await host.getStore().deferSubscriberNotifications(() => { + host.getStore().dispatch({ type: 'MOCK' }) + }, true) + + expect(clearCacheSpy).toHaveBeenCalledTimes(1) + }) + + it('should not clear state memoization on dispatch when deferring notifications without setting shouldDispatchClearCache', async () => { + const { host, shell } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) + let numberOfCalls = 0 + const originalFn = jest.fn(() => ++numberOfCalls) + const memoizedFn = shell.memoizeForState(originalFn, () => '*') as _.MemoizedFunction + const clearCacheSpy = jest.spyOn(memoizedFn.cache, 'clear') + + await host.getStore().deferSubscriberNotifications(() => { + host.getStore().dispatch({ type: 'MOCK' }) + }) + + expect(clearCacheSpy).toHaveBeenCalledTimes(0) + }) + it('should not mount connected component on props update', () => { const { host, shell, renderInShellContext } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { throw new Error('Connected component failed to render') @@ -956,7 +1021,9 @@ describe('connectWithShell-useCases', () => { it('should update component on change in regular state', () => { const { host, shell, renderInShellContext } = createMocks(entryPointWithState, [entryPointSecondStateWithAPI]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { @@ -995,7 +1062,9 @@ describe('connectWithShell-useCases', () => { entryPointSecondStateWithAPI, entryPointFirstObservable ]) - const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { allowOutOfEntryPoint: true })(PureComp) + const ConnectedComp = connectWithShell(mapStateToProps, undefined, shell, { + allowOutOfEntryPoint: true + })(PureComp) const { testKit } = renderInShellContext() if (!testKit) { @@ -1210,7 +1279,10 @@ describe('observeWithShellPureComponent', () => { getStringValue(): string getNumberValue(): number } - const ObservableAPI: SlotKey = { name: 'OBSERVABLE_API', public: true } + const ObservableAPI: SlotKey = { + name: 'OBSERVABLE_API', + public: true + } interface ActualObservableState { stringValue: string numberValue: number