Replace react-redux with a preact context binding (#2175)

* Replace react-redux with a preact context binding

One of the two packages holding the @preact/compat alias in place, and
the contained one: the store is plain redux, and the only react-redux
import inside it was a single line re-exporting typed hooks.

* Drop the now-unused react-redux types

* Subscribe before paint and check once on subscribe

Previously, useSelector subscribed to the store inside useEffect, which
runs after paint. A dispatch landing between render and that effect was
never delivered, since the listener did not exist yet, so the component
kept rendering a stale value until some later unrelated dispatch happened
to differ from the stale ref.

Subscribing in useLayoutEffect narrows the window to before paint, and
running the check once immediately on subscribe closes it, which is what
react-redux does for the same reason.

Adds the first tests for the binding, one of which fails without this
change: the store holds 1 while the DOM still shows 0.

* Only re-check on subscribe when the state actually moved

The subscribe-time check ran unconditionally, so it re-ran the selector
at mount. A selector building a fresh object fails Object.is against the
value the render already computed, which forced a second render of every
connected component: ConnectedRoot and every ConnectedComment, so around
201 extra renders for a 200-comment thread.

Reducers return a new root object on every change, so an unchanged state
reference means no dispatch was missed and the check has nothing to find.
Comparing against the state the render used keeps the property the check
exists for while dropping the extra render.

The race test still exercises the guarded path, since its dispatch
produces a new state object, and a new test pins the mount case: it fails
without the guard.

Raised by umputun in review.
This commit is contained in:
Dmitry Verkhoturov
2026-08-21 00:47:08 -05:00
committed by GitHub
parent 931f2db4e3
commit a91e322d5c
18 changed files with 214 additions and 93 deletions
@@ -1,7 +1,7 @@
import { mount } from 'enzyme';
import createMockStore from 'redux-mock-store';
import { Middleware } from 'redux';
import { Provider } from 'react-redux';
import { Provider } from 'store/context';
import { IntlProvider } from 'react-intl';
import type { User } from 'common/types';
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import { h, Fragment, JSX } from 'preact';
import { useState, useRef } from 'preact/hooks';
import { useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { useDispatch } from 'store/context';
import { setUser } from 'store/user/actions';
import { Input } from 'components/input';
@@ -1,6 +1,6 @@
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { Provider } from 'react-redux';
import { Provider } from 'store/context';
import { Middleware } from 'redux';
import createMockStore from 'redux-mock-store';
import { IntlProvider } from 'react-intl';
@@ -1,6 +1,6 @@
import { h, FunctionComponent, Fragment } from 'preact';
import { useState, useCallback, useRef } from 'preact/hooks';
import { useSelector, useDispatch } from 'react-redux';
import { useSelector, useDispatch } from 'store/context';
import clsx from 'clsx';
import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl';
@@ -4,7 +4,7 @@ import { SubscribeByRSS, createSubscribeUrl } from '.';
import styles from './subscribe-by-rss.module.css';
jest.mock('react-redux', () => ({
jest.mock('store/context', () => ({
useSelector: jest.fn((fn) => fn({ theme: 'light' })),
}));
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import { h, FunctionComponent, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { useSelector } from 'react-redux';
import { useSelector } from 'store/context';
import { useIntl, defineMessages } from 'react-intl';
import { User } from 'common/types';
@@ -3,7 +3,7 @@ import { h } from 'preact';
import { useState } from 'preact/hooks';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { useDispatch } from 'store/context';
import { patchComment } from 'store/comments/actions';
import { putCommentVote } from 'common/api';
import { StaticStore } from 'common/static-store';
@@ -1,5 +1,5 @@
import { h, Component, Fragment } from 'preact';
import { useSelector } from 'react-redux';
import { useSelector } from 'store/context';
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
import clsx from 'clsx';
@@ -1,5 +1,5 @@
import { h, FunctionComponent, type AriaRole } from 'preact';
import { shallowEqual } from 'react-redux';
import { shallowEqual } from 'store/context';
import { useCallback } from 'preact/hooks';
import clsx from 'clsx';
import { useIntl } from 'react-intl';
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useMemo } from 'preact/compat';
import { useDispatch } from 'react-redux';
import { useDispatch } from 'store/context';
import { BoundActionCreator, BoundActionCreators } from 'utils/actionBinder';
/** binds actions to dispatch */
+1 -1
View File
@@ -1,4 +1,4 @@
import { useSelector } from 'react-redux';
import { useSelector } from 'store/context';
import { StoreState } from 'store';
import { Theme } from 'common/types';
+1 -1
View File
@@ -1,6 +1,6 @@
import { h, render } from 'preact';
import { bindActionCreators } from 'redux';
import { Provider } from 'react-redux';
import { Provider } from 'store/context';
import { IntlProvider } from 'react-intl';
import { loadLocale } from 'utils/loadLocale';
@@ -0,0 +1,91 @@
import { h } from 'preact';
import { render, screen, act } from '@testing-library/preact';
import { createStore } from 'redux';
import { Provider, useSelector } from './context';
type State = { value: number };
function reducer(state: State = { value: 0 }, action: { type: string }): State {
return action.type === 'bump' ? { value: state.value + 1 } : state;
}
function Display() {
const value = useSelector((s: State) => s.value);
return <span data-testid="value">{value}</span>;
}
describe('useSelector', () => {
it('renders the value at mount', () => {
const store = createStore(reducer);
render(
<Provider store={store}>
<Display />
</Provider>
);
expect(screen.getByTestId('value').textContent).toBe('0');
});
it('re-renders on a store change', () => {
const store = createStore(reducer);
render(
<Provider store={store}>
<Display />
</Provider>
);
act(() => {
store.dispatch({ type: 'bump' });
});
expect(screen.getByTestId('value').textContent).toBe('1');
});
// the subscribe-time check must not re-run a selector that builds a fresh object,
// or every connected component renders twice at mount
it('renders once at mount with an object-building selector', () => {
const store = createStore(reducer);
let renders = 0;
function Counting() {
const { value } = useSelector((s: State) => ({ value: s.value }));
renders += 1;
return <span data-testid="value">{value}</span>;
}
render(
<Provider store={store}>
<Counting />
</Provider>
);
expect(renders).toBe(1);
expect(screen.getByTestId('value').textContent).toBe('0');
});
it('does not miss a dispatch that lands between render and subscribe', () => {
const store = createStore(reducer);
// dispatch from inside the component body, which runs during render and so
// before the subscribing effect. this is the window a real async resolution
// can land in, and a subscription created afterwards never hears about it.
let dispatched = false;
function Racing() {
const value = useSelector((s: State) => s.value);
if (!dispatched) {
dispatched = true;
store.dispatch({ type: 'bump' });
}
return <span data-testid="value">{value}</span>;
}
render(
<Provider store={store}>
<Racing />
</Provider>
);
expect(store.getState().value).toBe(1);
expect(screen.getByTestId('value').textContent).toBe('1');
});
});
@@ -0,0 +1,109 @@
import { createContext, h, type ComponentChildren } from 'preact';
import { useContext, useLayoutEffect, useRef, useState } from 'preact/hooks';
import type { Store, Dispatch, Action } from 'redux';
/**
* Minimal store binding over preact context, replacing react-redux.
*
* Only what the widget uses: a provider, dispatch, and a selector with an optional
* equality function.
*/
const StoreContext = createContext<Store | null>(null);
export function Provider<S, A extends Action>({
store,
children,
}: {
store: Store<S, A>;
children?: ComponentChildren;
}) {
return <StoreContext.Provider value={store as unknown as Store}>{children}</StoreContext.Provider>;
}
function useStore<S, A extends Action>(): Store<S, A> {
const store = useContext(StoreContext);
if (!store) {
throw new Error('store accessed outside of a Provider');
}
return store as unknown as Store<S, A>;
}
export function useDispatch<D extends Dispatch = Dispatch>(): D {
return useStore().dispatch as D;
}
export function useSelector<S, R>(selector: (state: S) => R, equalityFn?: (a: R, b: R) => boolean): R {
const store = useStore<S, Action>();
const [, setTick] = useState(0);
const state = store.getState();
const selected = selector(state);
// refs keep the subscription stable while always comparing against the latest
// render's selector and value, so a changed selector cannot resurrect a stale result
const selectorRef = useRef(selector);
const equalityRef = useRef(equalityFn);
const selectedRef = useRef(selected);
const stateRef = useRef(state);
selectorRef.current = selector;
equalityRef.current = equalityFn;
selectedRef.current = selected;
stateRef.current = state;
useLayoutEffect(() => {
const checkForUpdates = () => {
const next = selectorRef.current(store.getState());
const equal = equalityRef.current
? equalityRef.current(selectedRef.current, next)
: Object.is(selectedRef.current, next);
if (!equal) {
selectedRef.current = next;
setTick((n) => n + 1);
}
};
const unsubscribe = store.subscribe(checkForUpdates);
// a dispatch landing between this render and the subscription above is not
// delivered, since the listener did not exist yet. check once on subscribe so
// that update cannot be lost until some later, unrelated dispatch.
//
// only when the state actually moved, though: reducers return a new root
// object on every change, so an unchanged reference means nothing was missed.
// checking unconditionally would re-run the selector, and one building a fresh
// object fails Object.is against the render's value and forces a second render
// of every connected component at mount
if (store.getState() !== stateRef.current) {
checkForUpdates();
}
return unsubscribe;
}, [store]);
return selected;
}
export function shallowEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) {
return true;
}
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
return (
aKeys.length === bKeys.length &&
aKeys.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) &&
Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
)
);
}
export type TypedUseSelectorHook<S> = <R>(selector: (state: S) => R, equalityFn?: (a: R, b: R) => boolean) => R;
+1 -1
View File
@@ -1,5 +1,5 @@
import { createStore, applyMiddleware, AnyAction, compose, combineReducers } from 'redux';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import { useDispatch, useSelector, type TypedUseSelectorHook } from './context';
import thunk, { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { rootProvider } from './reducers';
import { ACTIONS } from './actions';
+1 -1
View File
@@ -1,7 +1,7 @@
import { h, ComponentChild } from 'preact';
import { IntlProvider } from 'react-intl';
import { render as originalRender } from '@testing-library/preact';
import { Provider } from 'react-redux';
import { Provider } from 'store/context';
import en from 'locales/en.json';
import { mockStore } from '__stubs__/store';
-2
View File
@@ -39,7 +39,6 @@
"react": "npm:@preact/compat@^18.3.2",
"react-dom": "npm:@preact/compat@^18.3.2",
"react-intl": "6.0.5",
"react-redux": "^8.0.2",
"redux": "^4.2.0",
"redux-thunk": "^2.4.1"
},
@@ -66,7 +65,6 @@
"@types/lodash-es": "^4.17.12",
"@types/node": "^18.0.1",
"@types/node-emoji": "^1.8.1",
"@types/react-redux": "^7.1.34",
"@types/redux-mock-store": "^1.5.0",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/webpack-env": "^1.18.8",
-77
View File
@@ -122,9 +122,6 @@ importers:
react-intl:
specifier: 6.0.5
version: 6.0.5(@preact/compat@18.3.2(preact@10.29.8))(typescript@5.9.3)
react-redux:
specifier: ^8.0.2
version: 8.1.3(@preact/compat@18.3.2(preact@10.29.8))(@preact/compat@18.3.2(preact@10.29.8))(@types/react@18.3.31)(redux@4.2.1)
redux:
specifier: ^4.2.0
version: 4.2.1
@@ -198,9 +195,6 @@ importers:
'@types/node-emoji':
specifier: ^1.8.1
version: 1.8.2
'@types/react-redux':
specifier: ^7.1.34
version: 7.1.34
'@types/redux-mock-store':
specifier: ^1.5.0
version: 1.5.0
@@ -1926,18 +1920,12 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react-redux@7.1.34':
resolution: {integrity: sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==}
'@types/react@16.14.70':
resolution: {integrity: sha512-DM5Q7rSx9G6QYcVvMgxvEurL5P06OxcDNUXrLxlpBzG4ccUewcBCmsztYbxJBobzO8RIwwmjoaD5OsKqdHDuYQ==}
'@types/react@18.3.31':
resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/redux-mock-store@1.5.0':
resolution: {integrity: sha512-jcscBazm6j05Hs6xYCca6psTUBbFT2wqMxT7wZEHAYFxHB/I8jYk7d5msrHUlDiSL02HdTqTmkK2oIV8i3C8DA==}
@@ -1977,9 +1965,6 @@ packages:
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/use-sync-external-store@0.0.3':
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
'@types/webpack-env@1.18.8':
resolution: {integrity: sha512-G9eAoJRMLjcvN4I08wB5I7YofOb/kaJNd5uoCMX+LbKXTPCF+ZIHuqTnFaK9Jz1rgs035f9JUPUhNFtqgucy/A==}
@@ -5589,27 +5574,6 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-redux@8.1.3:
resolution: {integrity: sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==}
peerDependencies:
'@types/react': ^16.8 || ^17.0 || ^18.0
'@types/react-dom': ^16.8 || ^17.0 || ^18.0
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
react-native: '>=0.59'
redux: ^4 || ^5.0.0-beta.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
react-dom:
optional: true
react-native:
optional: true
redux:
optional: true
read-pkg-up@7.0.1:
resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==}
engines: {node: '>=8'}
@@ -6385,11 +6349,6 @@ packages:
file-loader:
optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -8483,11 +8442,6 @@ snapshots:
'@types/react': 18.3.31
hoist-non-react-statics: 3.3.2
'@types/hoist-non-react-statics@3.3.7(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
hoist-non-react-statics: 3.3.2
'@types/html-minifier-terser@6.1.0': {}
'@types/http-errors@2.0.5': {}
@@ -8553,13 +8507,6 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react-redux@7.1.34':
dependencies:
'@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.17)
'@types/react': 19.2.17
hoist-non-react-statics: 3.3.2
redux: 4.2.1
'@types/react@16.14.70':
dependencies:
'@types/prop-types': 15.7.15
@@ -8571,10 +8518,6 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/react@19.2.17':
dependencies:
csstype: 3.2.3
'@types/redux-mock-store@1.5.0':
dependencies:
redux: 4.2.1
@@ -8621,8 +8564,6 @@ snapshots:
'@types/tough-cookie@4.0.5': {}
'@types/use-sync-external-store@0.0.3': {}
'@types/webpack-env@1.18.8': {}
'@types/ws@8.18.1':
@@ -12830,20 +12771,6 @@ snapshots:
react-is@18.3.1: {}
react-redux@8.1.3(@preact/compat@18.3.2(preact@10.29.8))(@preact/compat@18.3.2(preact@10.29.8))(@types/react@18.3.31)(redux@4.2.1):
dependencies:
'@babel/runtime': 7.29.7
'@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.31)
'@types/use-sync-external-store': 0.0.3
hoist-non-react-statics: 3.3.2
react: '@preact/compat@18.3.2(preact@10.29.8)'
react-is: 18.3.1
use-sync-external-store: 1.6.0(@preact/compat@18.3.2(preact@10.29.8))
optionalDependencies:
'@types/react': 18.3.31
react-dom: '@preact/compat@18.3.2(preact@10.29.8)'
redux: 4.2.1
read-pkg-up@7.0.1:
dependencies:
find-up: 4.1.0
@@ -13772,10 +13699,6 @@ snapshots:
optionalDependencies:
file-loader: 6.2.0(webpack@5.108.3)
use-sync-external-store@1.6.0(@preact/compat@18.3.2(preact@10.29.8)):
dependencies:
react: '@preact/compat@18.3.2(preact@10.29.8)'
util-deprecate@1.0.2: {}
utila@0.4.0: {}