Skip to content

Commit 961a187

Browse files
committed
perform dynamic analysis to warn about cycles in Effects
1 parent 51000fd commit 961a187

File tree

9 files changed

+255
-52
lines changed

9 files changed

+255
-52
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"test": "ava && flow focus-check ./test/test.flow.js && ./test/bad-cases/run.sh"
2626
},
2727
"dependencies": {
28-
"typed-rx-emitter": "^1.1.1"
28+
"typed-rx-emitter": "^1.1.2"
2929
},
3030
"devDependencies": {
3131
"@types/jsdom": "^11.0.4",

src/index.js.flow

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ type Lifted<State, T> = {
1212
value: T
1313
}
1414

15+
export type Options = {
16+
isDevMode: boolean
17+
}
18+
1519
export interface Store<State: Object> {
1620
get<K: $Keys<State>>(key: K): $ElementType<State, K>;
1721
set<K: $Keys<State>>(key: K): (value: $ElementType<State, K>) => void;
@@ -33,17 +37,21 @@ declare export class StoreDefinition<State: Object> implements Store<State> {
3337
set<K: $Keys<State>>(key: K): (value: $ElementType<State, K>) => void;
3438
on<K: $Keys<State>>(key: K): Observable<$ElementType<State, K>>;
3539
onAll(): Observable<$Values<Undux<State>>>;
40+
getCurrentSnapshot(): StoreSnapshot<State>;
3641
getState(): $ReadOnly<State>;
3742
}
3843

39-
declare export function createStore<State: Object>(initialState: State): StoreDefinition<State>
44+
declare export function createStore<State: Object>(
45+
initialState: State,
46+
options?: Options
47+
): StoreDefinition<State>
4048
export type Plugin<State: Object> = (store: StoreDefinition<State>) => StoreDefinition<State>
4149
declare export var withLogger: Plugin<Object>
4250
declare export var withReduxDevtools: Plugin<Object>
4351

4452
declare export function connect<State: Object>(
4553
store: StoreDefinition<State>
46-
): <Props: {store: Store<State>}>(
54+
): <S: Store<State>, Props: {store: S}>(
4755
Component: React.ComponentType<Props>
4856
) =>
49-
Class<React.Component<$Diff<Props, {store: Store<State>}>>>
57+
Class<React.Component<$Diff<Props, {store: S}>>>

src/index.ts

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import * as RxJS from 'rxjs'
22
import { Emitter } from 'typed-rx-emitter'
33
import { mapValues } from './utils'
44

5+
const CYCLE_ERROR_MESSAGE = '[undux] Error: Cyclical dependency detected. '
6+
+ 'This may cause a stack overflow unless you fix it. \n'
7+
+ 'The culprit is the following sequence of .set calls, '
8+
+ 'called from one or more of your Undux Effects: '
9+
510
export type Undux<State extends object> = {
611
[K in keyof State]: {
712
key: K
@@ -14,50 +19,69 @@ export interface Store<State extends object> {
1419
get<K extends keyof State>(key: K): State[K]
1520
set<K extends keyof State>(key: K): (value: State[K]) => void
1621
on<K extends keyof State>(key: K): RxJS.Observable<State[K]>
17-
onAll<K extends keyof State>(): RxJS.Observable<Undux<State>[keyof State]>
22+
onAll(): RxJS.Observable<Undux<State>[keyof State]>
1823
getState(): Readonly<State>
1924
}
2025

2126
export class StoreSnapshot<State extends object> implements Store<State> {
2227
constructor(
2328
private state: State,
24-
private store: StoreDefinition<State>
29+
private storeDefinition: StoreDefinition<State>
2530
) { }
2631
get<K extends keyof State>(key: K) {
2732
return this.state[key]
2833
}
2934
set<K extends keyof State>(key: K) {
30-
return this.store.set(key)
35+
return this.storeDefinition.set(key)
3136
}
3237
on<K extends keyof State>(key: K) {
33-
return this.store.on(key)
38+
return this.storeDefinition.on(key)
3439
}
35-
onAll<K extends keyof State>() {
36-
return this.store.onAll()
40+
onAll() {
41+
return this.storeDefinition.onAll()
3742
}
3843
getState() {
3944
return Object.freeze(this.state)
4045
}
4146
}
4247

48+
export type Options = {
49+
isDevMode: boolean
50+
}
51+
52+
let DEFAULT_OPTIONS: Readonly<Options> = {
53+
isDevMode: false
54+
}
55+
4356
export class StoreDefinition<State extends object> implements Store<State> {
44-
private store: StoreSnapshot<State>
45-
private alls: Emitter<Undux<State>> = new Emitter
46-
private emitter: Emitter<State> = new Emitter
57+
private storeSnapshot: StoreSnapshot<State>
58+
private alls: Emitter<Undux<State>>
59+
private emitter: Emitter<State>
4760
private setters: {
4861
readonly [K in keyof State]: (value: State[K]) => void
4962
}
50-
constructor(state: State) {
63+
constructor(state: State, options: Options) {
64+
65+
let emitterOptions = {
66+
isDevMode: options.isDevMode,
67+
onCycle(chain: (string | number | symbol)[]) {
68+
console.error(CYCLE_ERROR_MESSAGE + chain.join(' -> '))
69+
}
70+
}
71+
72+
// Initialize emitters
73+
this.alls = new Emitter(emitterOptions)
74+
this.emitter = new Emitter(emitterOptions)
5175

5276
// Set initial state
53-
this.store = new StoreSnapshot(state, this)
77+
this.storeSnapshot = new StoreSnapshot(state, this)
5478

5579
// Cache setters
5680
this.setters = mapValues(state, (v, key) =>
5781
(value: typeof v) => {
58-
let previousValue = this.store.get(key)
59-
this.store = new StoreSnapshot(
60-
Object.assign({}, this.store.getState(), { [key]: value }),
82+
let previousValue = this.storeSnapshot.get(key)
83+
this.storeSnapshot = new StoreSnapshot(
84+
Object.assign({}, this.storeSnapshot.getState(), { [key]: value }),
6185
this
6286
)
6387
this.emitter.emit(key, value)
@@ -68,24 +92,28 @@ export class StoreDefinition<State extends object> implements Store<State> {
6892
on<K extends keyof State>(key: K): RxJS.Observable<State[K]> {
6993
return this.emitter.on(key)
7094
}
71-
onAll<K extends keyof State>(): RxJS.Observable<Undux<State>[keyof State]> {
95+
onAll(): RxJS.Observable<Undux<State>[keyof State]> {
7296
return this.alls.all()
7397
}
7498
get<K extends keyof State>(key: K) {
75-
return this.store.get(key)
99+
return this.storeSnapshot.get(key)
76100
}
77101
set<K extends keyof State>(key: K) {
78102
return this.setters[key]
79103
}
104+
getCurrentSnapshot() {
105+
return this.storeSnapshot
106+
}
80107
getState() {
81-
return this.store.getState()
108+
return this.storeSnapshot.getState()
82109
}
83110
}
84111

85112
export function createStore<State extends object>(
86-
initialState: State
113+
initialState: State,
114+
options: Options = DEFAULT_OPTIONS
87115
): StoreDefinition<State> {
88-
return new StoreDefinition<State>(initialState)
116+
return new StoreDefinition<State>(initialState, options)
89117
}
90118

91119
export type Plugin<State extends object> =

src/react.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,36 @@ import { Subscription } from 'rxjs'
44
import { Store, StoreDefinition, StoreSnapshot } from './'
55
import { equals, getDisplayName } from './utils'
66

7-
export type Diff<T extends string, U extends string> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T]
8-
export type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] }
7+
export type Diff<T, U> = Pick<T, Exclude<keyof T, keyof U>>
98

109
export function connect<StoreState extends object>(store: StoreDefinition<StoreState>) {
1110
return function <
1211
Props,
1312
PropsWithStore extends { store: Store<StoreState> } & Props = { store: Store<StoreState> } & Props
14-
>(
15-
Component: React.ComponentType<PropsWithStore>
16-
): React.ComponentClass<Omit<PropsWithStore, 'store'>> {
13+
>(
14+
Component: React.ComponentType<PropsWithStore>
15+
): React.ComponentClass<Diff<PropsWithStore, { store: Store<StoreState> }>> {
1716

1817
type State = {
1918
store: StoreSnapshot<StoreState>
2019
subscription: Subscription
2120
}
2221

23-
return class extends React.Component<Omit<PropsWithStore, 'store'>, State> {
22+
return class extends React.Component<Diff<PropsWithStore, { store: Store<StoreState> }>, State> {
2423
static displayName = `withStore(${getDisplayName(Component)})`
2524
state = {
26-
store: store['store'],
25+
store: store.getCurrentSnapshot(),
2726
subscription: store.onAll().subscribe(({ key, previousValue, value }) => {
2827
if (equals(previousValue, value)) {
2928
return false
3029
}
31-
this.setState({ store: store['store'] })
30+
this.setState({ store: store.getCurrentSnapshot() })
3231
})
3332
}
3433
componentWillUnmount() {
3534
this.state.subscription.unsubscribe()
3635
}
37-
shouldComponentUpdate(props: Omit<PropsWithStore, 'store'>, state: State) {
36+
shouldComponentUpdate(props: Readonly<Diff<PropsWithStore, { store: Store<StoreState> }>>, state: State) {
3837
return state.store !== this.state.store
3938
|| Object.keys(props).some(_ => (props as any)[_] !== (this.props as any)[_])
4039
}

test/cyclical-dependencies.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { test } from 'ava'
2+
import * as React from 'react'
3+
import { Simulate } from 'react-dom/test-utils'
4+
import { connect, createStore, Store } from '../src'
5+
import { withElement } from './testUtils'
6+
7+
test('[cyclical dependencies] it should show a console error when there is a cycle (1)', t => {
8+
t.plan(1)
9+
console.error = (e: string) =>
10+
t.regex(e, /Cyclical dependency detected/)
11+
let store = createStore({ a: 1 }, { isDevMode: true })
12+
store.on('a').subscribe(a =>
13+
store.set('a')(a)
14+
)
15+
store.set('a')(2)
16+
})
17+
18+
test('[cyclical dependencies] it should show a console error when there is a cycle (2)', t => {
19+
t.plan(1)
20+
console.error = (e: string) =>
21+
t.regex(e, /Cyclical dependency detected/)
22+
let store = createStore({ a: 1, b: 2 }, { isDevMode: true })
23+
store.on('a').subscribe(a =>
24+
store.set('b')(a)
25+
)
26+
store.on('b').subscribe(a =>
27+
store.set('a')(a)
28+
)
29+
store.set('a')(2)
30+
})
31+
32+
test('[cyclical dependencies] it should show a console error when there is a cycle (3)', t => {
33+
t.plan(1)
34+
console.error = (e: string) =>
35+
t.regex(e, /Cyclical dependency detected/)
36+
let store = createStore({ a: 1, b: 2 }, { isDevMode: true })
37+
store.on('a').subscribe(a =>
38+
store.set('b')(a)
39+
)
40+
store.on('b').subscribe(a =>
41+
store.set('a')(a)
42+
)
43+
store.set('b')(3)
44+
})
45+
46+
test('[cyclical dependencies] it should show a console error when there is a cycle (4)', t => {
47+
48+
t.plan(1)
49+
console.error = (e: string) =>
50+
t.regex(e, /Cyclical dependency detected/)
51+
52+
let store = createStore({ a: 1 }, { isDevMode: true })
53+
let withStore = connect(store)
54+
55+
type State = {
56+
a: number
57+
}
58+
59+
type Props = {
60+
store: Store<State>
61+
}
62+
63+
store.on('a').subscribe(a =>
64+
store.set('a')(2)
65+
)
66+
67+
let A = withStore(({ store }: Props) =>
68+
<button onClick={() => store.set('a')(2)} />
69+
)
70+
71+
withElement(A, _ => {
72+
Simulate.click(_.querySelector('button')!)
73+
})
74+
})
75+
76+
test('[cyclical dependencies] it should show a console error when there is a cycle (5)', t => {
77+
78+
t.plan(1)
79+
console.error = (e: string) =>
80+
t.regex(e, /Cyclical dependency detected/)
81+
82+
let storeA = createStore({ a: 1 }, { isDevMode: true })
83+
let storeB = createStore({ b: 2 }, { isDevMode: true })
84+
let withStoreA = connect(storeA)
85+
let withStoreB = connect(storeB)
86+
87+
storeA.on('a').subscribe(a =>
88+
storeB.set('b')(a)
89+
)
90+
storeB.on('b').subscribe(b =>
91+
storeA.set('a')(b)
92+
)
93+
94+
type StateA = {
95+
a: number
96+
}
97+
type StateB = {
98+
b: number
99+
}
100+
101+
type PropsA = {
102+
store: Store<StateA>
103+
}
104+
type PropsB = {
105+
store: Store<StateB>
106+
}
107+
108+
let A = withStoreA(() =>
109+
<B />
110+
)
111+
let B = withStoreB(({ store: storeB }: PropsB) =>
112+
<button onClick={() => storeB.set('b')(1)} />
113+
)
114+
115+
withElement(A, _ => {
116+
Simulate.click(_.querySelector('button')!)
117+
})
118+
})
119+
120+
test('[cyclical dependencies] it should show a console error when there is a cycle (6)', t => {
121+
122+
t.plan(1)
123+
console.error = (e: string) =>
124+
t.regex(e, /Cyclical dependency detected/)
125+
126+
let storeA = createStore({ a: 1 }, { isDevMode: true })
127+
let storeB = createStore({ b: 2 }, { isDevMode: true })
128+
let withStoreA = connect(storeA)
129+
let withStoreB = connect(storeB)
130+
131+
storeA.on('a').subscribe(a =>
132+
storeB.set('b')(a)
133+
)
134+
storeB.on('b').subscribe(b =>
135+
storeA.set('a')(b)
136+
)
137+
138+
type StateA = {
139+
a: number
140+
}
141+
type StateB = {
142+
b: number
143+
}
144+
145+
type PropsA = {
146+
store: Store<StateA>
147+
}
148+
type PropsB = {
149+
store: Store<StateB>
150+
storeA: Store<StateA>
151+
}
152+
153+
let A = withStoreA(({ store }: PropsA) =>
154+
<B storeA={store} />
155+
)
156+
let B = withStoreB(({ storeA }: PropsB) =>
157+
<button onClick={() => storeA.set('a')(1)} />
158+
)
159+
160+
withElement(A, _ => {
161+
Simulate.click(_.querySelector('button')!)
162+
})
163+
})

test/test.flow.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ let withEffects: Plugin<State> = store => {
3030
}
3131

3232
let store = withEffects(withReduxDevtools(withLogger(createStore(initialState))))
33+
let debugStore = createStore(initialState, { isDevMode: true })
34+
35+
let state = store.getState().users.concat(4)
36+
let snapshot = store.getCurrentSnapshot().get('users').concat(4)
3337

3438
type Props = {|
3539
foo: number,

0 commit comments

Comments
 (0)