Skip to content

Commit fad30d6

Browse files
authored
Rewrite Subscription as a closure factory for byte shaving (#1755)
1 parent 50018ca commit fad30d6

File tree

9 files changed

+96
-84
lines changed

9 files changed

+96
-84
lines changed

.babelrc.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ module.exports = {
1212
// Use the equivalent of `babel-preset-modules`
1313
bugfixes: true,
1414
modules: false,
15+
loose: true,
1516
},
1617
],
1718
'@babel/preset-typescript',
1819
],
1920
plugins: [
2021
['@babel/proposal-decorators', { legacy: true }],
2122
'@babel/transform-react-jsx',
23+
['@babel/plugin-proposal-class-properties', { loose: true }],
24+
['@babel/plugin-proposal-private-methods', { loose: true }],
25+
['@babel/plugin-proposal-private-property-in-object', { loose: true }],
2226
cjs && ['@babel/transform-modules-commonjs'],
2327
[
2428
'@babel/transform-runtime',

src/components/Context.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react'
22
import { Action, AnyAction, Store } from 'redux'
33
import type { FixTypeLater } from '../types'
4-
import type Subscription from '../utils/Subscription'
4+
import type { Subscription } from '../utils/Subscription'
55

66
export interface ReactReduxContextValue<
77
SS = FixTypeLater,
@@ -11,9 +11,8 @@ export interface ReactReduxContextValue<
1111
subscription: Subscription
1212
}
1313

14-
export const ReactReduxContext = /*#__PURE__*/ React.createContext<ReactReduxContextValue | null>(
15-
null
16-
)
14+
export const ReactReduxContext =
15+
/*#__PURE__*/ React.createContext<ReactReduxContextValue | null>(null)
1716

1817
if (process.env.NODE_ENV !== 'production') {
1918
ReactReduxContext.displayName = 'ReactRedux'

src/components/Provider.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { Context, ReactNode, useMemo } from 'react'
22
import { ReactReduxContext, ReactReduxContextValue } from './Context'
3-
import Subscription from '../utils/Subscription'
3+
import { createSubscription } from '../utils/Subscription'
44
import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect'
55
import type { FixTypeLater } from '../types'
66
import { Action, AnyAction, Store } from 'redux'
@@ -21,7 +21,7 @@ interface ProviderProps<A extends Action = AnyAction> {
2121

2222
function Provider({ store, context, children }: ProviderProps) {
2323
const contextValue = useMemo(() => {
24-
const subscription = new Subscription(store)
24+
const subscription = createSubscription(store)
2525
subscription.onStateChange = subscription.notifyNestedSubs
2626
return {
2727
store,

src/components/connectAdvanced.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import hoistStatics from 'hoist-non-react-statics'
22
import React, { useContext, useMemo, useRef, useReducer } from 'react'
33
import { isValidElementType, isContextConsumer } from 'react-is'
4-
import Subscription from '../utils/Subscription'
4+
import { createSubscription } from '../utils/Subscription'
55
import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect'
66

77
import { ReactReduxContext } from './Context'
@@ -334,7 +334,7 @@ export default function connectAdvanced(
334334

335335
// This Subscription's source should match where store came from: props vs. context. A component
336336
// connected to the store via props shouldn't use subscription from context, or vice versa.
337-
const subscription = new Subscription(
337+
const subscription = createSubscription(
338338
store,
339339
didStoreComeFromProps ? null : contextValue.subscription
340340
)
@@ -343,9 +343,8 @@ export default function connectAdvanced(
343343
// the middle of the notification loop, where `subscription` will then be null. This can
344344
// probably be avoided if Subscription's listeners logic is changed to not call listeners
345345
// that have been unsubscribed in the middle of the notification loop.
346-
const notifyNestedSubs = subscription.notifyNestedSubs.bind(
347-
subscription
348-
)
346+
const notifyNestedSubs =
347+
subscription.notifyNestedSubs.bind(subscription)
349348

350349
return [subscription, notifyNestedSubs]
351350
}, [store, didStoreComeFromProps, contextValue])

src/hooks/useSelector.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { useReducer, useRef, useMemo, useContext, useDebugValue } from 'react'
22
import { useReduxContext as useDefaultReduxContext } from './useReduxContext'
3-
import Subscription from '../utils/Subscription'
3+
import { createSubscription, Subscription } from '../utils/Subscription'
44
import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect'
55
import { ReactReduxContext } from '../components/Context'
66
import { AnyAction, Store } from 'redux'
77
import { DefaultRootState } from '../types'
88

9-
type EqualityFn<T> = (a: T | undefined, b: T | undefined) => boolean;
9+
type EqualityFn<T> = (a: T | undefined, b: T | undefined) => boolean
1010

1111
const refEquality: EqualityFn<any> = (a, b) => a === b
1212

13-
type TSelector<S, R> = (state: S) => R;
13+
type TSelector<S, R> = (state: S) => R
1414

1515
function useSelectorWithStoreAndSubscription<TStoreState, TSelectedState>(
1616
selector: TSelector<TStoreState, TSelectedState>,
@@ -20,10 +20,10 @@ function useSelectorWithStoreAndSubscription<TStoreState, TSelectedState>(
2020
): TSelectedState {
2121
const [, forceRender] = useReducer((s) => s + 1, 0)
2222

23-
const subscription = useMemo(() => new Subscription(store, contextSub), [
24-
store,
25-
contextSub,
26-
])
23+
const subscription = useMemo(
24+
() => createSubscription(store, contextSub),
25+
[store, contextSub]
26+
)
2727

2828
const latestSubscriptionCallbackError = useRef<Error>()
2929
const latestSelector = useRef<TSelector<TStoreState, TSelectedState>>()
@@ -107,13 +107,21 @@ function useSelectorWithStoreAndSubscription<TStoreState, TSelectedState>(
107107
* @param {React.Context} [context=ReactReduxContext] Context passed to your `<Provider>`.
108108
* @returns {Function} A `useSelector` hook bound to the specified context.
109109
*/
110-
export function createSelectorHook(context = ReactReduxContext): <TState = DefaultRootState, Selected = unknown>(selector: (state: TState) => Selected, equalityFn?: EqualityFn<Selected>) => Selected {
110+
export function createSelectorHook(
111+
context = ReactReduxContext
112+
): <TState = DefaultRootState, Selected = unknown>(
113+
selector: (state: TState) => Selected,
114+
equalityFn?: EqualityFn<Selected>
115+
) => Selected {
111116
const useReduxContext =
112117
context === ReactReduxContext
113118
? useDefaultReduxContext
114119
: () => useContext(context)
115-
116-
return function useSelector<TState, Selected extends unknown>(selector: (state: TState) => Selected, equalityFn: EqualityFn<Selected> = refEquality): Selected {
120+
121+
return function useSelector<TState, Selected extends unknown>(
122+
selector: (state: TState) => Selected,
123+
equalityFn: EqualityFn<Selected> = refEquality
124+
): Selected {
117125
if (process.env.NODE_ENV !== 'production') {
118126
if (!selector) {
119127
throw new Error(`You must pass a selector to useSelector`)

src/utils/Subscription.ts

Lines changed: 55 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { getBatch } from './batch'
44
// well as nesting subscriptions of descendant components, so that we can ensure the
55
// ancestor components re-render before descendants
66

7+
type VoidFunc = () => void
8+
79
type Listener = {
8-
callback: () => void
10+
callback: VoidFunc
911
next: Listener | null
1012
prev: Listener | null
1113
}
@@ -77,55 +79,73 @@ function createListenerCollection() {
7779

7880
type ListenerCollection = ReturnType<typeof createListenerCollection>
7981

80-
export default class Subscription {
81-
private store: any
82-
private parentSub?: Subscription
83-
private unsubscribe?: () => void
84-
private listeners?: ListenerCollection
85-
public onStateChange?: () => void
82+
export interface Subscription {
83+
addNestedSub: (listener: VoidFunc) => VoidFunc
84+
notifyNestedSubs: VoidFunc
85+
handleChangeWrapper: VoidFunc
86+
isSubscribed: () => boolean
87+
onStateChange?: VoidFunc
88+
trySubscribe: VoidFunc
89+
tryUnsubscribe: VoidFunc
90+
getListeners: () => ListenerCollection
91+
}
8692

87-
constructor(store: any, parentSub?: Subscription) {
88-
this.store = store
89-
this.parentSub = parentSub
90-
this.unsubscribe = undefined
91-
this.listeners = undefined
93+
const nullListeners = {
94+
notify() {},
95+
get: () => [],
96+
} as unknown as ListenerCollection
9297

93-
this.handleChangeWrapper = this.handleChangeWrapper.bind(this)
94-
}
98+
export function createSubscription(store: any, parentSub?: Subscription) {
99+
let unsubscribe: VoidFunc | undefined
100+
let listeners: ListenerCollection = nullListeners
95101

96-
addNestedSub(listener: () => void) {
97-
this.trySubscribe()
98-
return this.listeners?.subscribe(listener)
102+
function addNestedSub(listener: () => void) {
103+
trySubscribe()
104+
return listeners.subscribe(listener)
99105
}
100106

101-
notifyNestedSubs() {
102-
this.listeners?.notify()
107+
function notifyNestedSubs() {
108+
listeners.notify()
103109
}
104110

105-
handleChangeWrapper() {
106-
this.onStateChange?.()
111+
function handleChangeWrapper() {
112+
if (subscription.onStateChange) {
113+
subscription.onStateChange()
114+
}
107115
}
108116

109-
isSubscribed() {
110-
return Boolean(this.unsubscribe)
117+
function isSubscribed() {
118+
return Boolean(unsubscribe)
111119
}
112120

113-
trySubscribe() {
114-
if (!this.unsubscribe) {
115-
this.unsubscribe = this.parentSub
116-
? this.parentSub.addNestedSub(this.handleChangeWrapper)
117-
: this.store.subscribe(this.handleChangeWrapper)
121+
function trySubscribe() {
122+
if (!unsubscribe) {
123+
unsubscribe = parentSub
124+
? parentSub.addNestedSub(handleChangeWrapper)
125+
: store.subscribe(handleChangeWrapper)
118126

119-
this.listeners = createListenerCollection()
127+
listeners = createListenerCollection()
120128
}
121129
}
122130

123-
tryUnsubscribe() {
124-
if (this.unsubscribe) {
125-
this.unsubscribe()
126-
this.unsubscribe = undefined
127-
this.listeners?.clear()
128-
this.listeners = undefined
131+
function tryUnsubscribe() {
132+
if (unsubscribe) {
133+
unsubscribe()
134+
unsubscribe = undefined
135+
listeners.clear()
136+
listeners = nullListeners
129137
}
130138
}
139+
140+
const subscription: Subscription = {
141+
addNestedSub,
142+
notifyNestedSubs,
143+
handleChangeWrapper,
144+
isSubscribed,
145+
trySubscribe,
146+
tryUnsubscribe,
147+
getListeners: () => listeners,
148+
}
149+
150+
return subscription
131151
}

src/utils/bindActionCreators.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,10 @@
1-
import {
2-
ActionCreator,
3-
ActionCreatorsMapObject,
4-
AnyAction,
5-
Dispatch,
6-
} from 'redux'
7-
8-
function bindActionCreator<A extends AnyAction = AnyAction>(
9-
actionCreator: ActionCreator<A>,
10-
dispatch: Dispatch
11-
) {
12-
return function (this: any, ...args: any[]) {
13-
return dispatch(actionCreator.apply(this, args))
14-
}
15-
}
1+
import { ActionCreatorsMapObject, Dispatch } from 'redux'
162

173
export default function bindActionCreators(
18-
actionCreators: ActionCreator<any> | ActionCreatorsMapObject,
4+
actionCreators: ActionCreatorsMapObject,
195
dispatch: Dispatch
206
) {
21-
if (typeof actionCreators === 'function') {
22-
return bindActionCreator(actionCreators, dispatch)
23-
}
24-
25-
const boundActionCreators: ActionCreatorsMapObject = {}
7+
const boundActionCreators: ActionCreatorsMapObject<any> = {}
268
for (const key in actionCreators) {
279
const actionCreator = actionCreators[key]
2810
if (typeof actionCreator === 'function') {

test/hooks/useSelector.spec.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,11 @@ describe('React', () => {
101101
</ProviderMock>
102102
)
103103

104-
expect(rootSubscription.listeners.get().length).toBe(1)
104+
expect(rootSubscription.getListeners().get().length).toBe(1)
105105

106106
store.dispatch({ type: '' })
107107

108-
expect(rootSubscription.listeners.get().length).toBe(2)
108+
expect(rootSubscription.getListeners().get().length).toBe(2)
109109
})
110110

111111
it('unsubscribes when the component is unmounted', () => {
@@ -129,11 +129,11 @@ describe('React', () => {
129129
</ProviderMock>
130130
)
131131

132-
expect(rootSubscription.listeners.get().length).toBe(2)
132+
expect(rootSubscription.getListeners().get().length).toBe(2)
133133

134134
store.dispatch({ type: '' })
135135

136-
expect(rootSubscription.listeners.get().length).toBe(1)
136+
expect(rootSubscription.getListeners().get().length).toBe(1)
137137
})
138138

139139
it('notices store updates between render and store subscription effect', () => {

test/utils/Subscription.spec.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Subscription from '../../src/utils/Subscription'
1+
import { createSubscription } from '../../src/utils/Subscription'
22

33
describe('Subscription', () => {
44
let notifications
@@ -9,13 +9,13 @@ describe('Subscription', () => {
99
notifications = []
1010
store = { subscribe: () => jest.fn() }
1111

12-
parent = new Subscription(store)
12+
parent = createSubscription(store)
1313
parent.onStateChange = () => {}
1414
parent.trySubscribe()
1515
})
1616

1717
function subscribeChild(name) {
18-
const child = new Subscription(store, parent)
18+
const child = createSubscription(store, parent)
1919
child.onStateChange = () => notifications.push(name)
2020
child.trySubscribe()
2121
return child

0 commit comments

Comments
 (0)