Skip to content

Commit

Permalink
2.1.0: Type changes and perf/bug fixes. (#66)
Browse files Browse the repository at this point in the history
Removed SubscribeOptions & UseStoreSubscribeOptions types.
Added Subscriber type.
Improve performance by consolidating sparse array.
Fix potential issue with concurrent mode.
  • Loading branch information
JeremyRH committed Oct 11, 2019
1 parent 4a8e0ed commit d82e103
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 97 deletions.
18 changes: 9 additions & 9 deletions .size-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"dist/index.js": {
"bundled": 3774,
"minified": 1232,
"gzipped": 589,
"bundled": 4474,
"minified": 1116,
"gzipped": 579,
"treeshaked": {
"rollup": {
"code": 14,
Expand All @@ -14,14 +14,14 @@
}
},
"dist/index.cjs.js": {
"bundled": 4707,
"minified": 1493,
"gzipped": 678
"bundled": 5152,
"minified": 1414,
"gzipped": 647
},
"dist/index.iife.js": {
"bundled": 4934,
"minified": 1329,
"gzipped": 619
"bundled": 5387,
"minified": 1288,
"gzipped": 599
},
"dist/shallow.js": {
"bundled": 646,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zustand",
"private": true,
"version": "2.0.0",
"version": "2.1.0",
"description": "🐻 Bear necessities for state management in React",
"main": "index.cjs.js",
"module": "index.js",
Expand Down
184 changes: 104 additions & 80 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,39 @@
import { useEffect, useLayoutEffect, useReducer, useRef } from 'react'

export type State = Record<string | number | symbol, any>
export interface StateListener<T> {
(state: T): void
(state: null, error: Error): void
}
export type StateSelector<T extends State, U> = (state: T) => U
export type PartialState<T extends State> =
| Partial<T>
| ((state: T) => Partial<T>)
export type EqualityChecker<T> = (state: T, newState: any) => boolean
export interface UseStoreSubscribeOptions<T extends State, U> {
selector: StateSelector<T, U>
equalityFn: EqualityChecker<U>
currentSlice: U
listenerIndex: number
subscribeError?: Error
}
export type SubscribeOptions<T extends State, U> = Partial<
UseStoreSubscribeOptions<T, U>
>
export type StateCreator<T extends State> = (
set: SetState<T>,
get: GetState<T>,
api: StoreApi<T>
) => T
export type StateSelector<T extends State, U> = (state: T) => U
export interface StateListener<T> {
(state: T): void
(state: null, error: Error): void
}
export type SetState<T extends State> = (partial: PartialState<T>) => void
export type GetState<T extends State> = () => T
export interface Subscriber<T extends State, U> {
callback: () => void
currentSlice: U
equalityFn: EqualityChecker<U>
errored: boolean
index: number
listener: StateListener<U>
selector: StateSelector<T, U>
}
export type Subscribe<T extends State> = <U>(
listener: StateListener<U>,
options?: SubscribeOptions<T, U>
subscriber: Subscriber<T, U>
) => () => void
export type ApiSubscribe<T extends State> = <U>(
listener: StateListener<U>,
selector?: StateSelector<T, U>,
equalityFn?: EqualityChecker<U>
) => () => void
export type EqualityChecker<T> = (state: T, newState: any) => boolean
export type Destroy = () => void
export interface UseStore<T extends State> {
(): T
Expand All @@ -48,112 +46,138 @@ export interface StoreApi<T extends State> {
destroy: Destroy
}

const forceUpdateReducer = (state: number) => state + 1
// For server-side rendering: https://github.com/react-spring/zustand/pull/34
const useIsoLayoutEffect =
typeof window === 'undefined' ? useEffect : useLayoutEffect

export default function create<TState extends State>(
createState: StateCreator<TState>
): [UseStore<TState>, StoreApi<TState>] {
// All listeners are wrapped in a function with the signature: () => void
const listeners: (() => void)[] = []
let state: TState
let renderCount = 0

// Returns an int for a component based on render order.
function useRenderId() {
const renderIdRef = useRef<number>()
if (!renderIdRef.current) {
renderIdRef.current = renderCount++
}
return renderIdRef.current
}
let subscribers: Subscriber<TState, any>[] = []
let subscriberCount = 0

const setState: SetState<TState> = partial => {
const partialState =
typeof partial === 'function' ? partial(state) : partial
if (partialState !== state) {
state = Object.assign({}, state, partialState)
listeners.forEach(listener => listener())
// Reset subscriberCount because we will be removing holes from the
// subscribers array and changing the length which should be the same as
// subscriberCount.
subscriberCount = 0
// Create a dense array by removing holes from the subscribers array.
// Holes are not iterated by Array.prototype.filter.
subscribers = subscribers.filter(subscriber => {
subscriber.index = subscriberCount++
return true
})

// Call all subscribers only after the subscribers array has been changed
// to a dense array. Subscriber callbacks cannot be called above in
// subscribers.filter because the callbacks can cause a synchronous
// increment of subscriberCount if not batched.
subscribers.forEach(subscriber => subscriber.callback())
}
}

const getState: GetState<TState> = () => state

const subscribe: Subscribe<TState> = <StateSlice>(
const getSubscriber = <StateSlice>(
listener: StateListener<StateSlice>,
options: SubscribeOptions<TState, StateSlice> = {}
selector: StateSelector<TState, StateSlice> = getState,
equalityFn: EqualityChecker<StateSlice> = Object.is
): Subscriber<TState, StateSlice> => ({
callback: () => {},
currentSlice: selector(state),
equalityFn,
errored: false,
index: subscriberCount++,
listener,
selector,
})

const subscribe: Subscribe<TState> = <StateSlice>(
subscriber: Subscriber<TState, StateSlice>
) => {
if (!('currentSlice' in options)) {
options.currentSlice = (options.selector || getState)(state)
}
// subscribe can be called externally without passing in a listenerIndex so
// we need to assign it a default index.
const { listenerIndex = renderCount++ } = options
const listenerWrapper = () => {
// Access the current values of the options object in listenerWrapper.
// We rely on this because options is mutated in useStore.
const { selector = getState, equalityFn = Object.is } = options
subscriber.callback = () => {
// Selector or equality function could throw but we don't want to stop
// the listener from being called.
// https://github.com/react-spring/zustand/pull/37
try {
const newStateSlice = selector(state)
if (!equalityFn(options.currentSlice as StateSlice, newStateSlice)) {
listener((options.currentSlice = newStateSlice))
const newStateSlice = subscriber.selector(state)
if (!subscriber.equalityFn(subscriber.currentSlice, newStateSlice)) {
subscriber.listener((subscriber.currentSlice = newStateSlice))
}
} catch (error) {
options.subscribeError = error
listener(null, error)
subscriber.errored = true
subscriber.listener(null, error)
}
}
listeners[listenerIndex] = listenerWrapper
// Intentially using delete because shortening the length of the listeners
// array would result in listenerIndex not accessing the correct listener.
// This means listeners should be considered a sparce array.
return () => delete listeners[listenerIndex]
// subscriber.index is set during the render phase in order to store the
// subscibers in a top-down order. The subscribers array will become a
// sparse array when an index is skipped (due to an interrupted render) or
// a component unmounts and the subscriber is deleted. It's converted back
// to a dense array in setState.
subscribers[subscriber.index] = subscriber

// Delete creates a hole and preserves the array length. If we used
// Array.prototype.splice, subscribers with a greater subscriber.index
// would no longer match their actual index in subscribers.
return () => delete subscribers[subscriber.index]
}

const apiSubscribe: ApiSubscribe<TState> = (listener, selector, equalityFn) =>
subscribe(listener, { selector, equalityFn })
const apiSubscribe: ApiSubscribe<TState> = <StateSlice>(
listener: StateListener<StateSlice>,
selector?: StateSelector<TState, StateSlice>,
equalityFn?: EqualityChecker<StateSlice>
) => subscribe(getSubscriber(listener, selector, equalityFn))

const destroy: Destroy = () => (listeners.length = 0)
const destroy: Destroy = () => (subscribers = [])

const useStore = <StateSlice>(
const useStore: UseStore<TState> = <StateSlice>(
selector: StateSelector<TState, StateSlice> = getState,
equalityFn: EqualityChecker<StateSlice> = Object.is
) => {
const listenerIndex = useRenderId()
const optionsRef = useRef<UseStoreSubscribeOptions<TState, StateSlice>>()
if (!optionsRef.current) {
optionsRef.current = {
selector,
equalityFn,
currentSlice: selector(state),
listenerIndex,
}
const forceUpdate = useReducer(c => c + 1, 0)[1]
const subscriberRef = useRef<Subscriber<TState, StateSlice>>()

if (!subscriberRef.current) {
subscriberRef.current = getSubscriber(forceUpdate, selector, equalityFn)
}
const options = optionsRef.current

// Update state slice if selector has changed or subscriber errored.
if (selector !== options.selector || options.subscribeError) {
const newStateSlice = selector(state)
if (!equalityFn(options.currentSlice, newStateSlice)) {
options.currentSlice = newStateSlice
}
const subscriber = subscriberRef.current
let newStateSlice: StateSlice | undefined
let hasNewStateSlice = false

// The selector or equalityFn need to be called during the render phase if
// they change. We also want legitimate errors to be visible so we re-run
// them if they errored in the subscriber.
if (
subscriber.selector !== selector ||
subscriber.equalityFn !== equalityFn ||
subscriber.errored
) {
// Using local variables to avoid mutations in the render phase.
newStateSlice = selector(state)
hasNewStateSlice = !equalityFn(subscriber.currentSlice, newStateSlice)
}

// Syncing changes in useEffect.
useIsoLayoutEffect(() => {
options.selector = selector
options.equalityFn = equalityFn
options.subscribeError = undefined
if (hasNewStateSlice) {
subscriber.currentSlice = newStateSlice as StateSlice
}
subscriber.selector = selector
subscriber.equalityFn = equalityFn
subscriber.errored = false
})

const forceUpdate = useReducer(forceUpdateReducer, 0)[1]
useIsoLayoutEffect(() => subscribe(forceUpdate, options), [])
useIsoLayoutEffect(() => subscribe(subscriber), [])

return options.currentSlice
return hasNewStateSlice
? (newStateSlice as StateSlice)
: subscriber.currentSlice
}

const api = { setState, getState, subscribe: apiSubscribe, destroy }
Expand Down
17 changes: 10 additions & 7 deletions tests/test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import create, {
StateSelector,
PartialState,
EqualityChecker,
SubscribeOptions,
Subscriber,
StateCreator,
SetState,
GetState,
Expand Down Expand Up @@ -562,11 +562,14 @@ it('can use exposed types', () => {
},
})

const subscribeOptions: SubscribeOptions<ExampleState, number> = {
selector: s => s.num,
equalityFn: (a, b) => a < b,
const subscriber: Subscriber<ExampleState, number> = {
callback: () => {},
currentSlice: 1,
subscribeError: new Error(),
equalityFn: Object.is,
errored: false,
index: 0,
listener(n: number) {},
selector,
}

function checkAllTypes(
Expand All @@ -582,7 +585,7 @@ it('can use exposed types', () => {
equalityFn: EqualityChecker<ExampleState>,
stateCreator: StateCreator<ExampleState>,
useStore: UseStore<ExampleState>,
subscribeOptions: SubscribeOptions<ExampleState, number>
subscribeOptions: Subscriber<ExampleState, number>
) {
expect(true).toBeTruthy()
}
Expand All @@ -600,6 +603,6 @@ it('can use exposed types', () => {
equlaityFn,
stateCreator,
useStore,
subscribeOptions
subscriber
)
})

0 comments on commit d82e103

Please sign in to comment.