-
Notifications
You must be signed in to change notification settings - Fork 406
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 unsubscribed
- Loading branch information
Showing
6 changed files
with
234 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
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,121 @@ | ||
import { Injectable } from '@angular/core'; | ||
import { TestBed } from '@angular/core/testing'; | ||
import { State, Action, Store, provideStore } from '@ngxs/store'; | ||
|
||
import { createPromiseTestHelper } from '../helpers/promise-test-helper'; | ||
|
||
// This test essentially shows that microtasks are not cancelable in JavaScript. | ||
// Therefore, using actions that return promises cannot be used with the | ||
// `cancelUncompleted` option, as this will result in the functionality being | ||
// executed twice (once by the executing action and again by the newly dispatched | ||
// action). Any third-party code that returns promises is also not cancelable. | ||
describe('Canceling promises', () => { | ||
const recorder: string[] = []; | ||
|
||
class MyActionAwait { | ||
static readonly type = '[MyState] My action await'; | ||
} | ||
|
||
class MyActionThen { | ||
static readonly type = '[MyState] My action then'; | ||
} | ||
|
||
const { promise: promiseAwaitReady, markPromiseResolved: markPromiseAwaitReady } = | ||
createPromiseTestHelper(); | ||
|
||
const { promise: promiseThenReady, markPromiseResolved: markPromiseThenReady } = | ||
createPromiseTestHelper(); | ||
|
||
@State<string>({ | ||
name: 'myState', | ||
defaults: 'STATE_VALUE' | ||
}) | ||
@Injectable() | ||
class MyState { | ||
@Action(MyActionAwait, { cancelUncompleted: true }) | ||
async handleActionAwait() { | ||
recorder.push('before promise await ready'); | ||
await promiseAwaitReady; | ||
recorder.push('after promise await ready'); | ||
} | ||
|
||
@Action(MyActionThen, { cancelUncompleted: true }) | ||
handleActionThen() { | ||
recorder.push('before promise then ready'); | ||
return promiseThenReady.then(() => { | ||
recorder.push('after promise then ready'); | ||
}); | ||
} | ||
} | ||
|
||
beforeEach(() => { | ||
recorder.length = 0; | ||
|
||
TestBed.configureTestingModule({ | ||
providers: [provideStore([MyState])] | ||
}); | ||
}); | ||
|
||
it('canceling promises using `await`', async () => { | ||
// Arrange | ||
const store = TestBed.inject(Store); | ||
|
||
// Act | ||
store.dispatch(new MyActionAwait()); | ||
|
||
// 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 MyActionAwait()); | ||
|
||
// Assert | ||
expect(recorder).toEqual(['before promise await ready', 'before promise await ready']); | ||
|
||
// Act | ||
markPromiseAwaitReady(); | ||
await promiseAwaitReady; | ||
|
||
// Assert | ||
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', | ||
'after promise await ready' | ||
]); | ||
}); | ||
|
||
it('canceling promises using `then(...)`', async () => { | ||
// Arrange | ||
const store = TestBed.inject(Store); | ||
|
||
// Act | ||
store.dispatch(new MyActionThen()); | ||
|
||
// 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 MyActionThen()); | ||
|
||
// Assert | ||
expect(recorder).toEqual(['before promise then ready', 'before promise then ready']); | ||
|
||
// Act | ||
markPromiseThenReady(); | ||
await promiseThenReady; | ||
|
||
// Assert | ||
expect(recorder).toEqual([ | ||
'before promise then ready', | ||
'before promise then ready', | ||
'after promise then ready', | ||
'after promise then ready' | ||
]); | ||
}); | ||
}); |
44 changes: 44 additions & 0 deletions
44
packages/store/tests/issues/writing-to-state-after-unsubscribe.spec.ts
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,44 @@ | ||
import { Injectable } from '@angular/core'; | ||
import { TestBed } from '@angular/core/testing'; | ||
import { State, Action, Store, provideStore, StateContext } from '@ngxs/store'; | ||
|
||
describe('Writing to state after action handler has been unsubscribed', () => { | ||
class Increment { | ||
static readonly type = '[Counter] Increment'; | ||
} | ||
|
||
@State({ | ||
name: 'counter', | ||
defaults: 0 | ||
}) | ||
@Injectable() | ||
class CounterState { | ||
@Action(Increment, { cancelUncompleted: true }) | ||
async handleActionAwait(ctx: StateContext<number>) { | ||
await Promise.resolve(); | ||
ctx.setState(counter => counter + 1); | ||
} | ||
} | ||
|
||
const testSetup = () => { | ||
TestBed.configureTestingModule({ | ||
providers: [provideStore([CounterState])] | ||
}); | ||
|
||
return TestBed.inject(Store); | ||
}; | ||
|
||
it('should not write to state if the action has been canceled', async () => { | ||
// Arrange | ||
const store = testSetup(); | ||
|
||
// Act | ||
store.dispatch(new Increment()); | ||
store.dispatch(new Increment()); | ||
store.dispatch(new Increment()); | ||
await Promise.resolve(); | ||
|
||
// Assert | ||
expect(store.snapshot()).toEqual({ counter: 1 }); | ||
}); | ||
}); |