Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
658c5e3
fix(useSelector): add `NoInfer` to equality fn in `UseSelector` type …
veksa Jul 20, 2026
d1bbb89
feat!: remove `alternate-renderers` entry point (#2336)
veksa Jul 24, 2026
22315e6
patch1
veksa May 13, 2024
786403f
perf(subscription): make notify time-slicing actually yield and drop …
veksa Jul 28, 2026
7aa7871
fix(subscription): keep a single in-flight drain so mid-notification …
veksa Jul 28, 2026
5c6367c
bench: measure notification-pass main-thread blocking (sync vs time-s…
veksa Jul 28, 2026
1e2d523
bench: add React scheduler parity comparison and analysis
veksa Jul 28, 2026
5b183b4
feat(subscription): drive notify with a vendored React scheduler + yi…
veksa Jul 28, 2026
40f9bca
bench: measure worst-case input latency vs React useSyncExternalStore…
veksa Jul 28, 2026
0fee643
bench: add results from official react-redux-benchmarks (heavy + triv…
veksa Jul 28, 2026
0d70de1
chore: drop use-sync-external-store in favour of vendored implementation
veksa Aug 7, 2026
776fc6e
chore: publish only dist, drop src from the package tarball
veksa Aug 7, 2026
0006d29
chore: adopt isolatedDeclarations and upgrade to TypeScript 7.0.2
veksa Aug 7, 2026
3e99cc3
fix(subscription): drain the first notification chunk synchronously
veksa Aug 7, 2026
2b92e16
inc version
veksa Aug 7, 2026
2f6de33
revert: keep the whole notification pass off the dispatch task
veksa Aug 7, 2026
dd9050d
inc version
veksa Aug 7, 2026
673bb4b
style: format fork sources with oxfmt
veksa Oct 16, 2026
cd3e160
feat(useSelector): report the first read of each top-level state key …
veksa Oct 16, 2026
d9576a5
docs(Provider): document onStateAccess
veksa Oct 16, 2026
23bb8f9
inc version
veksa Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ jobs:

# Note: We currently expect "FalseCJS" failures for Node16 + `moduleResolution: "node16",
- name: Run are-the-types-wrong
run: pnpm exec attw ./package.tgz --format table --ignore-rules false-cjs --exclude-entrypoints alternate-renderers
run: pnpm exec attw ./package.tgz --format table --ignore-rules false-cjs

test-published-artifact:
name: Test Published Artifact ${{ matrix.example }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ react-redux-*/
redux-toolkit/
.vscode/

.idea/

.cache/
.pnp.*
*.tgz
Expand Down
100 changes: 100 additions & 0 deletions benchmark/OFFICIAL-HARNESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Results on the official `reduxjs/react-redux-benchmarks`

The standalone scripts in this folder measure the notification pass in isolation.
This file records runs on Redux's **own** benchmark harness, in a real headless
Chromium, comparing published `react-redux@9.2.0` against this branch.

Environment: Ryzen 7 7840HS, headless Chromium (Playwright), React 19, medians
over repeated runs. The key harness metric is `dispatchTimingMiddleware`
(`src/common/dispatch-timing.ts`) — the synchronous time spent inside
`store.dispatch`, which on baseline includes the entire subscriber notification.

## Scenario A — `many-components-heavy-selector` (5,000 components)

A custom scenario where each component runs a non-trivial selector (a real
derive, not a bare `state.counter` read), so the `O(subscribers)` fan-out itself
is the long task. This is the clearest picture of the change:

| metric | `9.2.0` | this branch |
|---|--:|--:|
| **synchronous `dispatch` (avg)** — the notification fan-out | `8.9 ms` | **`0.05 ms`** |
| React p50 update (commit) | `12.1 ms` | **`5.6 ms`** |
| React p95 update — longest blocking commit | `20.3 ms` | **`7.0 ms`** |
| render passes over the run | ~76 | ~690 |
| dispatches processed | ~303 | ~398 |

The fan-out leaves the synchronous dispatch path (`8.9 → 0.05 ms`), and because
each chunk only feeds React ~5 ms of work before yielding, React's commits are
capped at the chunk budget too (p50 `5.6 ms`, p95 `7.0 ms` vs `12–20 ms`). Same
total work, split into ~9× more, individually short commits.

## Scenario B — `many-components-same-slice` (30,000 components, trivial selector)

The stock scenario, bumped to 30k components. Selector is `state => state.counter`,
so the fan-out is cheap and the long task is dominated by React's commit — this
shows the effect from the render side:

| metric | `9.2.0` | this branch |
|---|--:|--:|
| synchronous `dispatch` (avg) | `6.46 ms` | `0.10 ms` |
| React avg update (commit) | `70.6 ms` | `23.7 ms` |
| React p95 update — longest blocking commit | `96.9 ms` | `40.5 ms` |
| render passes over the run | 22 | 64 |
| dispatches processed in 20s | 20 | 36 |

## Reproducing

```bash
# 1. build this branch and stage it for the harness
cd react-redux && yarn build
cp -r dist package.json <benchmarks>/.yalc/react-redux/

# 2. in the benchmarks repo, add an alias in package.json dependencies:
# "react-redux-9.2.0-optimized": "file:.yalc/react-redux"
pnpm install
pnpm exec playwright install chromium
pnpm build

# 3. run
pnpm exec tsx ./runBenchmarks.ts \
-s many-components-heavy-selector -v 9.2.0 9.2.0-optimized -l 15 --json
```

### `many-components-heavy-selector` scenario source

`src/scenarios/many-components-heavy-selector/App.tsx`:

```tsx
import React from 'react'
import { useSelector } from 'react-redux'
import * as c from './constants'

interface RootState { counter: number }

function heavySelect(state: RootState, index: number): number {
let acc = 0
for (let k = 0; k < c.SELECTOR_WORK; k++) {
acc += ((index + k) ^ state.counter) & 0xff
}
return acc & 0xffff
}

function Component({ index }: { index: number }) {
const value = useSelector((state: RootState) => heavySelect(state, index))
return <div>{value}</div>
}

export default function App() {
return (
<div>
{Array.from({ length: c.NUMBER_OF_COMPONENTS }).map((_, index) => (
<Component key={index} index={index} />
))}
</div>
)
}
```

`constants.ts`: `NUMBER_OF_COMPONENTS = 5000`, `SELECTOR_WORK = 1500`.
`stateSlice.ts` / `index.tsx` are copied from `many-components-same-slice`
(a `counter` slice dispatched via `setInterval(increment, 13)`).
196 changes: 196 additions & 0 deletions benchmark/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Notification pass: main-thread blocking benchmark

Measures the cost that the `feature/optimize-subscription` branch targets: the
length of the **single long task** produced when the store notifies its
subscribers.

react-redux notifies every subscriber **synchronously in one pass**. On a large
app that pass is one uninterrupted long task — the main thread can't paint or
handle input until the last subscriber returns. That blocking time is precisely
what **INP** (Interaction to Next Paint) penalizes.

This branch slices the pass into **~5 ms chunks** and yields to the event loop
between them (`src/utils/listeners.ts` + `src/utils/batch.ts`), so the *longest
uninterrupted block* stays around the 5 ms budget no matter how many subscribers
there are.

---

## TL;DR

| subscribers | sync — longest task | sliced — longest task | reduction |
|------------:|--------------------:|----------------------:|----------:|
| 1,000 | `2.6 ms` | `2.6 ms` | ~0% |
| 5,000 | `13.1 ms` | `5.0 ms` | −62% |
| 10,000 | `26.4 ms` | `5.0 ms` | −81% |
| 20,000 | `52.9 ms` | `5.0 ms` | −90% |
| 50,000 | `134.3 ms` | `5.0 ms` | −96% |
| 100,000 | `265.1 ms` | `5.0 ms` | −98% |

Below ~one frame of work the two are identical (no regression). Once the
synchronous pass grows past a frame, the sliced longest task flatlines at the
budget while the synchronous one grows linearly.

---

## Longest blocking task (lower is better)

```
0ms 50ms 100ms 150ms 200ms 250ms
|----------|----------|----------|----------|----------|
1,000 sync ▏ 2.6ms
sliced ▏ 2.6ms

5,000 sync ██ 13.1ms
sliced ▏ 5.0ms

10,000 sync █████ 26.4ms
sliced ▏ 5.0ms

20,000 sync ██████████ 52.9ms ← crosses the 50ms "long task" line
sliced ▏ 5.0ms

50,000 sync █████████████████████████ 134.3ms
sliced ▏ 5.0ms

100,000 sync ████████████████████████████████████████████████ 265.1ms
sliced ▏ 5.0ms
```

> The browser flags any task **≥ 50 ms** as a "long task": input arriving during
> it is delayed by up to that long. At 20k+ subscribers the synchronous pass is a
> long task on its own; the sliced version keeps every task under one frame, so
> input can be serviced between chunks.

---

## The trade-off (stated honestly)

Slicing does **not** make the total work cheaper — it makes it *interruptible*.
Total wall-clock time goes **up**, because the pass now yields between chunks:

| subscribers | sync total | sliced total (wall) | chunks |
|------------:|-----------:|--------------------:|-------:|
| 10,000 | `26.4 ms`| `30.4 ms`| 14 |
| 20,000 | `52.9 ms`| `136.2 ms`| 29 |
| 50,000 | `134.3 ms`| `339.3 ms`| 69 |
| 100,000 | `265.1 ms`| `674.3 ms`| 135 |

That extra wall-clock time is **not** idle waiting — it is exactly the window in
which the main thread is free to paint and respond to input. You trade a longer
"time to fully settle" for a responsive UI throughout. For a background store
update (the common case) that is the right trade; for a synchronous read-after-
dispatch it is not, which is why this must ship as **opt-in**, not the default.

> ⚠️ The `sliced total` column is **pessimistic** here: in Node the yield uses
> `setImmediate`, whose per-tick latency inflates under load. The branch itself
> yields via `MessageChannel`, which fires far sooner in a browser. The
> **longest-task** metric — the one that matters for INP — is independent of the
> scheduler and holds in both.

---

## Why existing solutions don't cover this

| Mechanism | Axis it optimizes | Caps the pass length? |
|---|---|---|
| RTK `autoBatchEnhancer` (issue [#1511]) | **number** of notification passes (coalesces many dispatches → one pass) | ❌ the single pass is still fully synchronous |
| Concurrent stores prototype ([#2263]) | the React **render** phase (transition-compatible updates) | ❌ the subscriber-notification pass is untouched |
| **This branch** | **length** of one notification pass | ✅ time-sliced to the ~5 ms budget |

The three are orthogonal. `autoBatch` turns *N* long tasks into *one* long task;
it does nothing about that one task being 265 ms. This work is what shortens it.

[#1511]: https://github.com/reduxjs/react-redux/issues/1511
[#2263]: https://github.com/reduxjs/react-redux/pull/2263

---

## Reproducing

```bash
node benchmark/notify-bench.mjs
```

The time-sliced loop in the benchmark is a faithful copy of the branch's
`createListenerCollection().notify()` (5 ms budget, `Date.now()` sampled per
iteration, a macrotask yield between chunks). Each cell is the median of 7 runs.

**Environment:** Node `v24.11.1` · AMD Ryzen 7 7840HS (16 threads) · Windows 11 ·
`WORK = 4000` (≈ a few µs per subscriber, modeling a non-trivial memoized
selector recompute + shallow-equality check). Absolute numbers scale with
per-subscriber cost and hardware; the **shape** — flat vs. linear — does not.

---

## Appendix — parity with React's scheduler

The batching idea is borrowed from React's `scheduler` package
([`packages/scheduler/src/forks/Scheduler.js`][sched]). Worth stating clearly:
the three defects this branch had to fix are **regressions in the adaptation** —
React's scheduler does not have any of them, and there is nothing to upstream to
React itself. The fixes bring `react-redux` in line with React's proven design.

| Defect in the branch's first cut | How React's scheduler avoids it |
|---|---|
| **Frozen clock** — `const date = new Date()` sampled once, so the 5 ms budget never expires and the loop never yields | Resets `startTime = getCurrentTime()` on every task (`Scheduler.js:507`); `shouldYieldToHost` compares a **live** `getCurrentTime() - startTime` against `frameInterval` (`:464‑471`) |
| **New `MessageChannel` + `uuid` per yield** | A single `channel` is created once at module init and reused via `port.postMessage(null)` (`:549‑554`) |
| **Per-`notify()` cursor** → parallel re-entrant drains | `requestHostCallback` guards with `if (!isMessageLoopRunning)` (`:564`); leftover work re-schedules *itself* via `hasMoreWork` (`:519‑522`) |

Our budget (`CHUNK_BUDGET_MS = 5`) is exactly React's `frameYieldMs = 5`
(`SchedulerFeatureFlags.js:11`).

Running the same workload under three strategies — the buggy original, the fixed
branch, and React's `shouldYieldToHost` semantics — the fixed version tracks
React exactly, while the frozen-clock original collapses to a single long task:

| subscribers | `original` (frozen clock) | `fixed` (this branch) | `react` semantics |
|------------:|--------------------------:|----------------------:|------------------:|
| 1,000 | `2.6 ms` | `2.6 ms` | `2.7 ms` |
| 10,000 | `26.4 ms` | `5.0 ms` | `5.0 ms` |
| 50,000 | `337.0 ms` | `5.0 ms` | `5.0 ms` |
| 100,000 | `684.3 ms` | `5.1 ms` | `5.1 ms` |

_(longest task, ms; median of 7 runs; `fixed` and `react` also produce the same
chunk count — e.g. 136 vs 137 at 100k.)_ Reproduce with
`node benchmark/vs-react-scheduler.mjs`.

**Early yield on pending input (implemented).** React can yield *early*, before
the 5 ms budget, when a paint is pending (`needsPaint` / `requestPaint`,
`Scheduler.js:459‑462`, `474‑479`) — a signal set by React's own commit phase
that a library doesn't have. `src/utils/scheduler.ts` reproduces that early
yield for the one signal a library *does* have: `navigator.scheduling
.isInputPending()`. An in-progress notification then releases the main thread
the moment the user interacts, instead of holding it for the rest of the frame.
Note this benefit is **browser-only** — Node has no `isInputPending`, so the
benchmarks above (which cap at the 5 ms budget) don't reflect it; in a real
browser the within-chunk wait drops from "up to 5 ms" toward "as soon as input
registers."

[sched]: https://github.com/facebook/react/blob/main/packages/scheduler/src/forks/Scheduler.js

---

## Responsiveness — worst-case input latency

The metrics above measure task length. This measures the consequence directly:
how long a user interaction that arrives **during** a notification pass has to
wait before the main thread can service it.

A high-frequency heartbeat (a stand-in for the event loop servicing an
interaction) ticks throughout the run; the largest gap between ticks is the
longest the main thread was blocked — i.e. the worst-case input latency. This is
measured, not asserted (`benchmark/responsiveness.mjs`).

| subscribers | `react-uSES` (synchronous) | `time-sliced` (this branch) | improvement |
|------------:|---------------------------:|----------------------------:|------------:|
| 1,000 | `2.6 ms` | `2.8 ms` | ~0% |
| 10,000 | `26.2 ms` | `5.0 ms` | −81% |
| 50,000 | `133.1 ms` | `5.1 ms` | −96% |
| 100,000 | `265.9 ms` | `5.1 ms` | −98% |

`react-uSES` models what react-redux does today on React's built-in
`useSyncExternalStore`: the subscriber notification runs synchronously in one
uninterrupted task, so an interaction can wait the full length of that task. The
time-sliced pass keeps that wait at ~one frame no matter how many subscribers
there are — and, in a browser, `isInputPending` trims it further still.
Loading
Loading