diff --git a/frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx b/frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx index 6902508b..fc3aad89 100644 --- a/frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx +++ b/frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/components/auth/auth.tsx b/frontend/apps/remark42/app/components/auth/auth.tsx index 12f9b31f..bd98e6ac 100644 --- a/frontend/apps/remark42/app/components/auth/auth.tsx +++ b/frontend/apps/remark42/app/components/auth/auth.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx index 9459d0f6..54124001 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx index 37d7ebd3..63e51e82 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx index 5e650742..8b0eb867 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx @@ -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' })), })); diff --git a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.tsx b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.tsx index c504508d..410f84af 100644 --- a/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.tsx +++ b/frontend/apps/remark42/app/components/comment-form/__subscribe-by-telegram/comment-form__subscribe-by-telegram.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/components/comment/comment-votes.tsx b/frontend/apps/remark42/app/components/comment/comment-votes.tsx index 76da83e0..168fee82 100644 --- a/frontend/apps/remark42/app/components/comment/comment-votes.tsx +++ b/frontend/apps/remark42/app/components/comment/comment-votes.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/components/root/root.tsx b/frontend/apps/remark42/app/components/root/root.tsx index 5bb065d0..ab29254b 100644 --- a/frontend/apps/remark42/app/components/root/root.tsx +++ b/frontend/apps/remark42/app/components/root/root.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/components/thread/thread.tsx b/frontend/apps/remark42/app/components/thread/thread.tsx index 2478d02f..89c856f3 100644 --- a/frontend/apps/remark42/app/components/thread/thread.tsx +++ b/frontend/apps/remark42/app/components/thread/thread.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/hooks/useAction.ts b/frontend/apps/remark42/app/hooks/useAction.ts index 3f051588..64165aba 100644 --- a/frontend/apps/remark42/app/hooks/useAction.ts +++ b/frontend/apps/remark42/app/hooks/useAction.ts @@ -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 */ diff --git a/frontend/apps/remark42/app/hooks/useTheme.ts b/frontend/apps/remark42/app/hooks/useTheme.ts index 38eec0f4..ae8d13de 100644 --- a/frontend/apps/remark42/app/hooks/useTheme.ts +++ b/frontend/apps/remark42/app/hooks/useTheme.ts @@ -1,4 +1,4 @@ -import { useSelector } from 'react-redux'; +import { useSelector } from 'store/context'; import { StoreState } from 'store'; import { Theme } from 'common/types'; diff --git a/frontend/apps/remark42/app/remark.tsx b/frontend/apps/remark42/app/remark.tsx index c129071a..5df11131 100644 --- a/frontend/apps/remark42/app/remark.tsx +++ b/frontend/apps/remark42/app/remark.tsx @@ -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'; diff --git a/frontend/apps/remark42/app/store/context.test.tsx b/frontend/apps/remark42/app/store/context.test.tsx new file mode 100644 index 00000000..179ed3fd --- /dev/null +++ b/frontend/apps/remark42/app/store/context.test.tsx @@ -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 {value}; +} + +describe('useSelector', () => { + it('renders the value at mount', () => { + const store = createStore(reducer); + render( + + + + ); + expect(screen.getByTestId('value').textContent).toBe('0'); + }); + + it('re-renders on a store change', () => { + const store = createStore(reducer); + render( + + + + ); + 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 {value}; + } + + render( + + + + ); + + 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 {value}; + } + + render( + + + + ); + + expect(store.getState().value).toBe(1); + expect(screen.getByTestId('value').textContent).toBe('1'); + }); +}); diff --git a/frontend/apps/remark42/app/store/context.tsx b/frontend/apps/remark42/app/store/context.tsx new file mode 100644 index 00000000..815f7669 --- /dev/null +++ b/frontend/apps/remark42/app/store/context.tsx @@ -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(null); + +export function Provider({ + store, + children, +}: { + store: Store; + children?: ComponentChildren; +}) { + return {children}; +} + +function useStore(): Store { + const store = useContext(StoreContext); + if (!store) { + throw new Error('store accessed outside of a Provider'); + } + return store as unknown as Store; +} + +export function useDispatch(): D { + return useStore().dispatch as D; +} + +export function useSelector(selector: (state: S) => R, equalityFn?: (a: R, b: R) => boolean): R { + const store = useStore(); + 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)[key], (b as Record)[key]) + ) + ); +} + +export type TypedUseSelectorHook = (selector: (state: S) => R, equalityFn?: (a: R, b: R) => boolean) => R; diff --git a/frontend/apps/remark42/app/store/index.ts b/frontend/apps/remark42/app/store/index.ts index 8f2ba6ce..761c4cb9 100644 --- a/frontend/apps/remark42/app/store/index.ts +++ b/frontend/apps/remark42/app/store/index.ts @@ -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'; diff --git a/frontend/apps/remark42/app/tests/utils.tsx b/frontend/apps/remark42/app/tests/utils.tsx index 415aacac..fba4ea2e 100644 --- a/frontend/apps/remark42/app/tests/utils.tsx +++ b/frontend/apps/remark42/app/tests/utils.tsx @@ -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'; diff --git a/frontend/apps/remark42/package.json b/frontend/apps/remark42/package.json index 647b7c49..fa2cd597 100644 --- a/frontend/apps/remark42/package.json +++ b/frontend/apps/remark42/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 31d8b310..5b825974 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: {}