Skip to content

Commit 0cd6edf

Browse files
Merge pull request cerebral#24 from cerebral/computed
feat(overmind): add computed concept
2 parents 6faeb25 + 442b980 commit 0cd6edf

File tree

6 files changed

+248
-13
lines changed

6 files changed

+248
-13
lines changed

packages/demos/todomvc/src/app/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,20 @@ const app = new App(
1212
state,
1313
actions,
1414
providers,
15+
computed: {
16+
test: (foo: number) => (state) => state.count + foo,
17+
},
1518
},
1619
{
1720
devtools: 'localhost:1234',
1821
}
1922
)
2023

21-
export type Connect = TConnect<typeof app.state, typeof app.actions>
24+
export type Connect = TConnect<
25+
typeof app.state,
26+
typeof app.actions,
27+
typeof app.computed
28+
>
2229

2330
export const connect = app.connect
2431

packages/demos/todomvc/src/components/AddTodo/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Wrapper, Input, Button, Form } from './elements'
44

55
const AddTodo: React.SFC<Connect> = ({ app }) => (
66
<Wrapper>
7-
{app.state.count}
7+
{app.computed.test(5)}
88
<Form onSubmit={app.actions.addTodo}>
99
<Input
1010
placeholder="I need to..."
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import App, { computed } from './'
2+
3+
describe.only('Computed', () => {
4+
test('should instantiate app with computed', () => {
5+
const app = new App({
6+
state: {
7+
foo: 'bar',
8+
},
9+
computed: {
10+
test: (foo: number) => (state) => state.foo + foo,
11+
},
12+
})
13+
expect(app.computed.test(123)).toEqual('bar123')
14+
})
15+
test('should not recalculate when not dirty', () => {
16+
let runCount = 0
17+
const app = new App({
18+
state: {
19+
foo: 'bar',
20+
},
21+
computed: {
22+
test: (foo: number) => (state) => {
23+
runCount++
24+
return state.foo + foo
25+
},
26+
},
27+
})
28+
app.computed.test(123)
29+
app.computed.test(123)
30+
expect(runCount).toEqual(1)
31+
})
32+
test('should create new cache entry when args change', () => {
33+
let runCount = 0
34+
const app = new App({
35+
state: {
36+
foo: 'bar',
37+
},
38+
computed: {
39+
test: (foo: number) => (state) => {
40+
runCount++
41+
return state.foo + foo
42+
},
43+
},
44+
})
45+
app.computed.test(123)
46+
app.computed.test(321)
47+
expect(runCount).toEqual(2)
48+
})
49+
test('should flag as dirty when state changes', () => {
50+
let runCount = 0
51+
const app = new App({
52+
state: {
53+
foo: 'bar',
54+
},
55+
computed: {
56+
test: (foo: number) => (state) => {
57+
runCount++
58+
return state.foo + foo
59+
},
60+
},
61+
actions: (action) => ({
62+
changeFoo: action().mutation((_, state) => (state.foo = 'bar2')),
63+
}),
64+
})
65+
app.computed.test(123)
66+
app.actions.changeFoo()
67+
expect(app.computed.test(123)).toEqual('bar2123')
68+
expect(runCount).toEqual(2)
69+
})
70+
test('should use factory to adjust cache limit', () => {
71+
let runCount = 0
72+
type State = {
73+
foo: string
74+
}
75+
const app = new App({
76+
state: {
77+
foo: 'bar',
78+
},
79+
computed: {
80+
test: computed(
81+
(foo: number) => (state: State) => {
82+
runCount++
83+
return state.foo + foo
84+
},
85+
{
86+
cacheLimit: 1,
87+
}
88+
),
89+
},
90+
})
91+
app.computed.test(123)
92+
app.computed.test(432)
93+
app.computed.test(123)
94+
expect(runCount).toEqual(3)
95+
})
96+
})
97+
98+
/*
99+
- Should expose to actions as provider
100+
*/
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import ProxyStateTree from 'proxy-state-tree'
2+
3+
type ComputedOptions = {
4+
cacheLimit?: number
5+
}
6+
7+
type Cache = {
8+
isDirty: boolean
9+
proxyStateTreeListener: any
10+
value: any
11+
paths: Set<string>
12+
updateCount: number
13+
}
14+
15+
export class Computed {
16+
cb: (config: any) => (state: object) => void
17+
cacheLimit: number = 10
18+
cacheKeys: Cache[] = []
19+
cache: Map<any, Cache> = new Map()
20+
constructor(cb, options: ComputedOptions = {}) {
21+
this.cb = cb
22+
this.cacheLimit = options.cacheLimit || this.cacheLimit
23+
}
24+
evaluate(actionChain, proxyStateTree: ProxyStateTree, path) {
25+
return (config) => {
26+
let cache = this.cache.get(config)
27+
28+
if (!cache) {
29+
cache = {
30+
isDirty: true,
31+
proxyStateTreeListener: null,
32+
value: undefined,
33+
paths: new Set<string>(),
34+
updateCount: 0,
35+
}
36+
this.cache.set(config, cache)
37+
this.cacheKeys.push(config)
38+
if (this.cacheKeys.length > this.cacheLimit) {
39+
const cacheKey = this.cacheKeys.shift()
40+
this.cache.get(cacheKey).proxyStateTreeListener.dispose()
41+
this.cache.delete(cacheKey)
42+
}
43+
}
44+
45+
if (cache && cache.isDirty) {
46+
const trackId = proxyStateTree.startPathsTracking()
47+
cache.value = this.cb(config)(proxyStateTree.get())
48+
cache.isDirty = false
49+
cache.paths = proxyStateTree.clearPathsTracking(trackId)
50+
if (cache.proxyStateTreeListener) {
51+
cache.proxyStateTreeListener.update(cache.paths)
52+
} else {
53+
cache.proxyStateTreeListener = proxyStateTree.addMutationListener(
54+
cache.paths,
55+
() => {
56+
cache.isDirty = true
57+
}
58+
)
59+
}
60+
actionChain.emit('computed', {
61+
path,
62+
paths: Array.from(cache.paths),
63+
updateCount: cache.updateCount,
64+
value: cache.value,
65+
limit: this.cacheLimit,
66+
cacheKeysCount: this.cacheKeys.length,
67+
cacheKeyIndex: this.cacheKeys.indexOf(config),
68+
})
69+
cache.updateCount++
70+
71+
// Tracks the paths for the consumer of this derived value
72+
for (let path of cache.paths) {
73+
proxyStateTree.addTrackingPath(path)
74+
}
75+
76+
return cache.value
77+
} else if (cache) {
78+
return cache.value
79+
}
80+
}
81+
}
82+
}
83+
84+
export default function computed<Config, NewValue>(
85+
cb: (config: Config) => (state: object) => NewValue,
86+
options?: ComputedOptions
87+
): (config: Config) => (state: object) => NewValue {
88+
return new Computed(cb, options) as any
89+
}

packages/node_modules/overmind/src/index.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import createActionFactory, {
55
Action,
66
NoValueAction,
77
} from './createActionFactory'
8+
import { Computed } from './computed'
89
export { default as compose } from './compose'
910
export { default as derived } from './derived'
11+
export { default as computed } from './computed'
1012

11-
type Configuration<State, Providers, Actions> = {
13+
type Configuration<State, Providers, Actions, Computed> = {
1214
state?: State
1315
providers?: Providers
1416
actions?: Actions
17+
computed?: Computed
1518
}
1619

1720
type Options = {
@@ -39,7 +42,10 @@ export default class App<
3942
| {
4043
[namespace: string]: ActionsCallback<Providers, State>
4144
}
42-
| ActionsCallback<Providers, State>
45+
| ActionsCallback<Providers, State>,
46+
Computed extends {
47+
[key: string]: (...args: any[]) => (state: State) => any
48+
}
4349
> {
4450
private proxyStateTree: ProxyStateTree
4551
devtools: Devtools
@@ -51,8 +57,9 @@ export default class App<
5157
? ReturnType<Actions>
5258
: any
5359
state: State
60+
computed: Computed
5461
constructor(
55-
configuration: Configuration<State, Providers, Actions>,
62+
configuration: Configuration<State, Providers, Actions, Computed>,
5663
options: Options = {}
5764
) {
5865
const proxyStateTree = new ProxyStateTree(configuration.state || {}, {
@@ -115,6 +122,24 @@ export default class App<
115122
}),
116123
{}
117124
) as any)
125+
this.computed = Object.keys(configuration.computed || {}).reduce(
126+
(aggr, key) =>
127+
Object.assign(aggr, {
128+
[key]:
129+
configuration.computed[key] instanceof Computed
130+
? (configuration.computed[key] as any).evaluate(
131+
actionChain,
132+
proxyStateTree,
133+
key
134+
)
135+
: new Computed(configuration.computed[key]).evaluate(
136+
actionChain,
137+
proxyStateTree,
138+
key
139+
),
140+
}),
141+
{}
142+
) as Computed
118143
this.proxyStateTree = proxyStateTree
119144

120145
if (options.devtools && typeof window !== 'undefined') {
@@ -180,6 +205,12 @@ export default class App<
180205
data,
181206
})
182207
)
208+
actionChain.on('computed', (data) =>
209+
devtools.send({
210+
type: 'computed',
211+
data,
212+
})
213+
)
183214
this.devtools = devtools
184215
}
185216
trackState() {

packages/node_modules/overmind/src/views/react.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ export type IReactComponent<P = any> =
77
| React.ComponentClass<P>
88
| React.ClassicComponentClass<P>
99

10-
export type TConnect<State = {}, Actions = {}> = {
10+
export type TConnect<State = {}, Actions = {}, Computed = {}> = {
1111
app: {
1212
state: State
1313
actions: Actions
14+
computed: Computed
1415
}
1516
}
1617

@@ -30,8 +31,11 @@ export default class ReactApp<
3031
| {
3132
[namespace: string]: ActionsCallback<Providers, State>
3233
}
33-
| ActionsCallback<Providers, State>
34-
> extends App<State, Providers, Actions> {
34+
| ActionsCallback<Providers, State>,
35+
Computed extends {
36+
[key: string]: (...args: any[]) => (state: State) => any
37+
}
38+
> extends App<State, Providers, Actions, Computed> {
3539
connect = <
3640
Props,
3741
ConnectedActions = Actions extends {
@@ -42,11 +46,13 @@ export default class ReactApp<
4246
? ReturnType<Actions>
4347
: any
4448
>(
45-
Component: IReactComponent<Props & TConnect<State, ConnectedActions>>
49+
Component: IReactComponent<
50+
Props & TConnect<State, ConnectedActions, Computed>
51+
>
4652
): IReactComponent<
4753
Omit<
48-
Props & TConnect<State, ConnectedActions>,
49-
keyof TConnect<State, ConnectedActions>
54+
Props & TConnect<State, ConnectedActions, Computed>,
55+
keyof TConnect<State, ConnectedActions, Computed>
5056
>
5157
> => {
5258
const componentId = nextComponentId++
@@ -108,8 +114,8 @@ export default class ReactApp<
108114

109115
return class extends React.PureComponent<
110116
Omit<
111-
Props & TConnect<State, ConnectedActions>,
112-
keyof TConnect<State, ConnectedActions>
117+
Props & TConnect<State, ConnectedActions, Computed>,
118+
keyof TConnect<State, ConnectedActions, Computed>
113119
>
114120
> {
115121
__mutationListener: any
@@ -137,6 +143,7 @@ export default class ReactApp<
137143
app: {
138144
state: instance.state,
139145
actions: instance.actions,
146+
computed: instance.computed,
140147
__componentInstanceId: this.__componentInstanceId,
141148
},
142149
}),
@@ -173,6 +180,7 @@ export default class ReactApp<
173180
app: {
174181
state: instance.state,
175182
actions: instance.actions,
183+
computed: instance.computed,
176184
__componentInstanceId: this.__componentInstanceId,
177185
},
178186
}) as any)

0 commit comments

Comments
 (0)