Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Safe plugin #36

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -33,7 +33,7 @@
"@types/react-dom": "^16.0.5",
"ava": "^0.25.0",
"concurrently": "^3.5.1",
"flow-bin": "^0.72.0",
"flow-bin": "^0.73.0",
"immutable": "^3.8.2",
"jsdom": "^11.10.0",
"prettier": "^1.12.1",
Expand Down
31 changes: 25 additions & 6 deletions src/index.js.flow
Expand Up @@ -6,20 +6,23 @@ export type Undux<Actions: Object> = $ObjMap<Actions, Lift<Actions>>

type Lift<Actions: Object> = <V>(value: V) => Lifted<Actions, V>

type Lifted<Actions, T> = {
type Lifted<Actions, T> = {|
key: $Keys<Actions>,
previousValue: T,
value: T
}
|}

export interface Store<Actions: Object> {
export interface SafeStore<Actions: Object> {
get<K: $Keys<Actions>>(key: K): $ElementType<Actions, K>;
set<K: $Keys<Actions>>(key: K): (value: $ElementType<Actions, K>) => void;
on<K: $Keys<Actions>>(key: K): Observable<$ElementType<Actions, K>>;
onAll(): Observable<$Values<Undux<Actions>>>;
getState(): $ReadOnly<Actions>;
}

export interface Store<Actions: Object> extends SafeStore<Actions> {
set<K: $Keys<Actions>>(key: K): (value: $ElementType<Actions, K>) => void;
}

declare export class StoreSnapshot<Actions: Object> implements Store<Actions> {
get<K: $Keys<Actions>>(key: K): $ElementType<Actions, K>;
set<K: $Keys<Actions>>(key: K): (value: $ElementType<Actions, K>) => void;
Expand All @@ -36,13 +39,29 @@ declare export class StoreDefinition<Actions: Object> implements Store<Actions>
getState(): $ReadOnly<Actions>;
}

declare export class SafeStoreDefinition<Actions: Object> implements SafeStore<Actions> {
get<K: $Keys<Actions>>(key: K): $ElementType<Actions, K>;
on<K: $Keys<Actions>>(key: K): Observable<$ElementType<Actions, K>>;
onAll(): Observable<$Values<Undux<Actions>>>;
getState(): $ReadOnly<Actions>;
}

declare export function createStore<Actions: Object>(initialState: Actions): StoreDefinition<Actions>
export type Plugin<Actions: Object> = (store: StoreDefinition<Actions>) => StoreDefinition<Actions>

declare export function createSafeStore<Actions: Object>(initialState: Actions): SafeStoreDefinition<Actions>

export type Plugin<Actions: Object> = (
store: StoreDefinition<Actions>
) => StoreDefinition<Actions>

export type SafePlugin<Actions: Object> = (
store: SafeStoreDefinition<Actions>
) => SafeStoreDefinition<Actions>
declare export var withLogger: Plugin<Object>
declare export var withReduxDevtools: Plugin<Object>

declare export function connect<Actions: Object>(
store: StoreDefinition<Actions>
store: StoreDefinition<Actions> | SafeStoreDefinition<Actions>
): <Props: {store: Store<Actions>}>(
Component: React.ComponentType<Props>
) =>
Expand Down
12 changes: 11 additions & 1 deletion src/index.ts
@@ -1,7 +1,7 @@
import * as RxJS from 'rxjs'
import { Emitter } from 'typed-rx-emitter'
import { withReduxDevtools } from './plugins/reduxDevtools'
import { mapValues } from './utils'
import { mapValues, Omit } from './utils'

export type Undux<Actions extends object> = {
[K in keyof Actions]: {
Expand Down Expand Up @@ -89,9 +89,19 @@ export function createStore<Actions extends object>(
return new StoreDefinition<Actions>(initialState)
}

export function createSafeStore<Actions extends object>(
initialState: Actions
): Omit<StoreDefinition<Actions>, 'set'> {
return new StoreDefinition<Actions>(initialState)
}

export type Plugin<Actions extends object> =
(store: StoreDefinition<Actions>) => StoreDefinition<Actions>

export type SafePlugin<Actions extends object> =
(store: Omit<StoreDefinition<Actions>, 'set'>) =>
Omit<StoreDefinition<Actions>, 'set'>

export * from './plugins/logger'
export * from './plugins/reduxDevtools'
export * from './react'
15 changes: 7 additions & 8 deletions src/react.tsx
Expand Up @@ -2,12 +2,11 @@ import * as React from 'react'
import { ComponentClass } from 'react'
import { Subscription } from 'rxjs'
import { Store, StoreDefinition, StoreSnapshot } from './'
import { equals, getDisplayName } from './utils'
import { equals, getDisplayName, Omit } from './utils'

export type Diff<T extends string, U extends string> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T]
export type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] }

export function connect<Actions extends object>(store: StoreDefinition<Actions>) {
export function connect<Actions extends object>(
store: StoreDefinition<Actions> | Omit<StoreDefinition<Actions>, 'set'>
) {
return function <
Props,
PropsWithStore extends { store: Store<Actions> } & Props = { store: Store<Actions> } & Props
Expand All @@ -23,12 +22,12 @@ export function connect<Actions extends object>(store: StoreDefinition<Actions>)
return class extends React.Component<Omit<PropsWithStore, 'store'>, State> {
static displayName = `withStore(${getDisplayName(Component)})`
state = {
store: store['store'],
subscription: store.onAll().subscribe(({ key, previousValue, value }) => {
store: (store as StoreDefinition<Actions>)['store'],
subscription: (store as StoreDefinition<Actions>).onAll().subscribe(({ key, previousValue, value }) => {
if (equals(previousValue, value)) {
return false
}
this.setState({ store: store['store'] })
this.setState({ store: (store as StoreDefinition<Actions>)['store'] })
})
}
componentWillUnmount() {
Expand Down
3 changes: 3 additions & 0 deletions src/utils.ts
@@ -1,5 +1,8 @@
import { ComponentType } from 'react'

export type Diff<T extends string, U extends string> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T]
export type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] }

/**
* TODO: Avoid diffing by passing individual values into a React component
* rather than the whole `store`, and letting React and `shouldComponentUpdate`
Expand Down
1 change: 1 addition & 0 deletions test/bad-cases/run.sh
Expand Up @@ -2,6 +2,7 @@
! ./node_modules/.bin/flow focus-check test/bad-cases/badkey.on.flow.js
! ./node_modules/.bin/flow focus-check test/bad-cases/badkey.set.flow.js
! ./node_modules/.bin/flow focus-check test/bad-cases/badval.set.flow.js
! ./node_modules/.bin/flow focus-check test/bad-cases/safestore.flow.js
! ./node_modules/.bin/flow focus-check test/bad-cases/stateless.badkey.get.flow.js
! ./node_modules/.bin/flow focus-check test/bad-cases/stateless.badkey.get2.flow.js
! ./node_modules/.bin/flow focus-check test/bad-cases/stateless.badkey.get3.flow.js
Expand Down
61 changes: 61 additions & 0 deletions test/bad-cases/safestore.flow.js
@@ -0,0 +1,61 @@
// @flow
import { connect, createStore, createSafeStore, withLogger, withReduxDevtools } from '../../dist/src'
import type { Plugin, SafePlugin, Store } from '../../dist/src'
import * as React from 'react'
import { debounceTime, filter } from 'rxjs/operators'

type State = {|
isTrue: boolean,
users: string[]
|}

let initialState: State = {
isTrue: true,
users: []
}

let withSafeEffects: SafePlugin<State> = store => {
store.onAll().subscribe(({ key, value, previousValue }) => {
key.toUpperCase()
if (typeof previousValue === 'boolean' || typeof value === 'boolean') {
!previousValue
!value
} else {
previousValue.slice(0, 1)
value.slice(0, 1)
}
})
store.on('users').subscribe(_ => {
_.slice(0, 1)
store.set('isTrue')(!store.get('isTrue')) // ERROR
})
return store
}

let safeStore = withSafeEffects(createSafeStore(initialState))

type Props = {|
foo: number,
bar: string
|}

type StoreProps = {|
store: Store<State>
|}

type PropsWithStore = {|
...StoreProps,
...Props
|}

let S = connect(safeStore)(class extends React.Component<PropsWithStore> {
render() {
return <div>
{this.props.store.get('isTrue') ? 'True' : 'False'}
{this.props.foo}
{this.props.bar}
<button onClick={this.props.store.set('isTrue')(true)} />
</div>
}
})
let s = <S foo={1} bar='baz' />
85 changes: 85 additions & 0 deletions test/effects.tsx
@@ -0,0 +1,85 @@
import { test } from 'ava'
import * as React from 'react'
import { Simulate } from 'react-dom/test-utils'
import { connect, createStore, Plugin, SafePlugin } from '../src'
import { withElement } from './testUtils'

test('[effects] Plugin', t => {
type State = {
a: number
b: number
}
t.plan(3)
let withStore: Plugin<State> = store => {
store.onAll().subscribe(_ => {
t.is(_.key, 'a')
t.is(_.value, 3)
t.is(_.previousValue, 1)
})
return store
}
let store = withStore(createStore<State>({
a: 1,
b: 2
}))
store.set('a')(3)
})

test('[effects] SafePlugin', t => {
type State = {
a: number
b: number
}
t.plan(15)
let withStore: SafePlugin<State> = store => {
let i = 0
store.onAll().subscribe(_ => {
switch (i) {
case 0:
t.is(_.key, 'a')
t.is(_.value, 2)
t.is(_.previousValue, 1)
break
case 1:
t.is(_.key, 'b')
t.is(_.value, 3)
t.is(_.previousValue, 2)
break
case 2:
t.is(_.key, 'b')
t.is(_.value, 4)
t.is(_.previousValue, 3)
}
i++
})
return store
}
let store = withStore(createStore<State>({
a: 1,
b: 2
}))
let MyComponent = connect(store)(({ store }) =>
<>
a = {store.get('a')}
b = {store.get('b')}
<button id='a' onClick={() => store.set('a')(store.get('a') + 1)}>a + 1</button>
<button id='b' onClick={() => store.set('b')(store.get('b') + 1)}>b + 1</button>
</>
)
withElement(MyComponent, _ => {
// a
Simulate.click(_.querySelector('#a')!)
t.is(store.get('a'), 2)
t.regex(_.innerHTML, /a = 2/)

// b
Simulate.click(_.querySelector('#b')!)
t.is(store.get('b'), 3)
t.regex(_.innerHTML, /b = 3/)

// b again
Simulate.click(_.querySelector('#b')!)
t.is(store.get('b'), 4)
t.regex(_.innerHTML, /b = 4/)
})
})
2 changes: 1 addition & 1 deletion test/stateful.tsx
Expand Up @@ -89,7 +89,7 @@ test('[stateful] it should call .on().subscribe() with the current value', t =>
})
)

test('[statelful] it should call .onAll().subscribe() with the key, current value, and previous value', t =>
test('[stateful] it should call .onAll().subscribe() with the key, current value, and previous value', t =>
withElement(MyComponentWithLens, _ => {
t.plan(3)
store.onAll().subscribe(_ => {
Expand Down
40 changes: 37 additions & 3 deletions test/test.flow.js
@@ -1,6 +1,6 @@
// @flow
import { connect, createStore, withLogger, withReduxDevtools } from '../dist/src'
import type { Plugin, Store } from '../dist/src'
import { connect, createStore, createSafeStore, withLogger, withReduxDevtools } from '../dist/src'
import type { Plugin, SafePlugin, Store } from '../dist/src'
import * as React from 'react'
import { debounceTime, filter } from 'rxjs/operators'

Expand All @@ -25,7 +25,10 @@ let withEffects: Plugin<State> = store => {
value.slice(0, 1)
}
})
store.on('users').subscribe(_ => _.slice(0, 1))
store.on('users').subscribe(_ => {
_.slice(0, 1)
store.set('isTrue')(!store.get('isTrue'))
})
return store
}

Expand Down Expand Up @@ -119,3 +122,34 @@ store.onAll().subscribe(_ => {
_.previousValue === false
_.value === true
})

/////////////////// SafePlugin ///////////////////

let withSafeEffects: SafePlugin<State> = store => {
store.onAll().subscribe(({ key, value, previousValue }) => {
key.toUpperCase()
if (typeof previousValue === 'boolean' || typeof value === 'boolean') {
!previousValue
!value
} else {
previousValue.slice(0, 1)
value.slice(0, 1)
}
})
store.on('users').subscribe(_ => _.slice(0, 1))
return store
}

let safeStore = withSafeEffects(createSafeStore(initialState))

let S = connect(safeStore)(class extends React.Component<PropsWithStore> {
render() {
return <div>
{this.props.store.get('isTrue') ? 'True' : 'False'}
{this.props.foo}
{this.props.bar}
<button onClick={this.props.store.set('isTrue')(true)} />
</div>
}
})
let s = <S foo={1} bar='baz' />
1 change: 1 addition & 0 deletions test/test.ts
@@ -1,3 +1,4 @@
import './effects'
import './immutable'
import './stateful'
import './stateless'
Expand Down
6 changes: 3 additions & 3 deletions yarn.lock
Expand Up @@ -1313,9 +1313,9 @@ find-up@^2.0.0, find-up@^2.1.0:
dependencies:
locate-path "^2.0.0"

flow-bin@^0.72.0:
version "0.72.0"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.72.0.tgz#12051180fb2db7ccb728fefe67c77e955e92a44d"
flow-bin@^0.73.0:
version "0.73.0"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.73.0.tgz#da1b90a02b0ef9c439f068c2fc14968db83be425"

fn-name@^2.0.0:
version "2.0.1"
Expand Down