-
Notifications
You must be signed in to change notification settings - Fork 405
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(store): prevent writing to state once action handler is unsubscri…
…bed (#2231) In this commit, we update the implementation of action invocation. The key addition is that we now prevent writing to the state whenever an action handler is unsubscribed (completed or cancelled). Since we have a "unique" state context object for each action being invoked, we can set its `setState` and `patchState` functions to no-ops, essentially making them do nothing when invoked.
- Loading branch information
Showing
6 changed files
with
194 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
export function createPromiseTestHelper<T = void>() { | ||
type MarkResolvedFn = (result: T | PromiseLike<T>) => void; | ||
type MarkRejectedFn = (reason?: any) => void; | ||
let resolveFn: MarkResolvedFn = () => {}; | ||
let rejectFn: MarkRejectedFn = () => {}; | ||
|
||
const promise = new Promise<T>((resolve, reject) => { | ||
resolveFn = resolve; | ||
rejectFn = reject; | ||
}); | ||
return { | ||
promise, | ||
markPromiseResolved(...args: Parameters<MarkResolvedFn>) { | ||
resolveFn(...args); | ||
resolveFn = () => {}; | ||
}, | ||
markPromiseRejected(reason?: any) { | ||
rejectFn(reason); | ||
rejectFn = () => {}; | ||
} | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
import { Injectable } from '@angular/core'; | ||
import { TestBed } from '@angular/core/testing'; | ||
import { State, Action, Store, provideStore, StateContext } from '@ngxs/store'; | ||
|
||
import { createPromiseTestHelper } from '../helpers/promise-test-helper'; | ||
|
||
describe('Canceling promises (preventing state writes)', () => { | ||
const recorder: string[] = []; | ||
|
||
class IncrementWithAwait { | ||
static readonly type = 'Increment with await'; | ||
} | ||
|
||
class IncrementWithThen { | ||
static readonly type = 'Increment with then'; | ||
} | ||
|
||
const { promise: promiseAwaitReady, markPromiseResolved: markPromiseAwaitReady } = | ||
createPromiseTestHelper(); | ||
|
||
const { promise: promiseThenReady, markPromiseResolved: markPromiseThenReady } = | ||
createPromiseTestHelper(); | ||
|
||
@State<number>({ | ||
name: 'counter', | ||
defaults: 0 | ||
}) | ||
@Injectable() | ||
class CounterState { | ||
@Action(IncrementWithAwait, { cancelUncompleted: true }) | ||
async incrementWithAwait(ctx: StateContext<number>) { | ||
recorder.push('before promise await ready'); | ||
await promiseAwaitReady; | ||
recorder.push('after promise await ready'); | ||
ctx.setState(value => value + 1); | ||
recorder.push(`value: ${ctx.getState()}`); | ||
} | ||
|
||
@Action(IncrementWithThen, { cancelUncompleted: true }) | ||
incrementWithThen(ctx: StateContext<number>) { | ||
recorder.push('before promise then ready'); | ||
return promiseThenReady.then(() => { | ||
recorder.push('after promise then ready'); | ||
ctx.setState(value => value + 1); | ||
recorder.push(`value: ${ctx.getState()}`); | ||
}); | ||
} | ||
} | ||
|
||
beforeEach(() => { | ||
recorder.length = 0; | ||
|
||
TestBed.configureTestingModule({ | ||
providers: [provideStore([CounterState])] | ||
}); | ||
}); | ||
|
||
it('canceling promises using `await`', async () => { | ||
// Arrange | ||
const store = TestBed.inject(Store); | ||
|
||
// Act | ||
store.dispatch(new IncrementWithAwait()); | ||
|
||
// Assert | ||
expect(recorder).toEqual(['before promise await ready']); | ||
|
||
// Act (dispatch another action to cancel the previous one) | ||
// The promise is not resolved yet, as thus `await` is not executed. | ||
store.dispatch(new IncrementWithAwait()); | ||
|
||
// Assert | ||
expect(recorder).toEqual(['before promise await ready', 'before promise await ready']); | ||
|
||
// Act | ||
markPromiseAwaitReady(); | ||
await promiseAwaitReady; | ||
|
||
// Assert | ||
expect(store.snapshot()).toEqual({ counter: 1 }); | ||
expect(recorder).toEqual([ | ||
'before promise await ready', | ||
'before promise await ready', | ||
// Note that once the promise is resolved, the await has been executed, | ||
// and both microtasks have also been executed (`recorder.push(...)` is a | ||
// microtask because it is created by `await`). | ||
'after promise await ready', | ||
// Value has not been updated in the state. | ||
'value: 0', | ||
'after promise await ready', | ||
'value: 1' | ||
]); | ||
}); | ||
|
||
it('canceling promises using `then(...)`', async () => { | ||
// Arrange | ||
const store = TestBed.inject(Store); | ||
|
||
// Act | ||
store.dispatch(new IncrementWithThen()); | ||
|
||
// Assert | ||
expect(recorder).toEqual(['before promise then ready']); | ||
|
||
// Act (dispatch another action to cancel the previous one) | ||
// The promise is not resolved yet, as thus `then(...)` is not executed. | ||
store.dispatch(new IncrementWithThen()); | ||
|
||
// Assert | ||
expect(recorder).toEqual(['before promise then ready', 'before promise then ready']); | ||
|
||
// Act | ||
markPromiseThenReady(); | ||
await promiseThenReady; | ||
|
||
// Assert | ||
expect(store.snapshot()).toEqual({ counter: 1 }); | ||
expect(recorder).toEqual([ | ||
'before promise then ready', | ||
'before promise then ready', | ||
'after promise then ready', | ||
// Value has not been updated in the state. | ||
'value: 0', | ||
'after promise then ready', | ||
'value: 1' | ||
]); | ||
}); | ||
}); |