diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa4abb145..0f79a0605 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 07a2482db..3072bfb6b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ react-redux-*/ redux-toolkit/ .vscode/ +.idea/ + .cache/ .pnp.* *.tgz diff --git a/benchmark/OFFICIAL-HARNESS.md b/benchmark/OFFICIAL-HARNESS.md new file mode 100644 index 000000000..1a551c830 --- /dev/null +++ b/benchmark/OFFICIAL-HARNESS.md @@ -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 /.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
{value}
+} + +export default function App() { + return ( +
+ {Array.from({ length: c.NUMBER_OF_COMPONENTS }).map((_, index) => ( + + ))} +
+ ) +} +``` + +`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)`). diff --git a/benchmark/RESULTS.md b/benchmark/RESULTS.md new file mode 100644 index 000000000..e2034bd4d --- /dev/null +++ b/benchmark/RESULTS.md @@ -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. diff --git a/benchmark/notify-bench.mjs b/benchmark/notify-bench.mjs new file mode 100644 index 000000000..e9c9ae585 --- /dev/null +++ b/benchmark/notify-bench.mjs @@ -0,0 +1,135 @@ +// Benchmark: main-thread blocking of a single store notification pass. +// +// react-redux notifies every subscriber synchronously in one pass. On a large +// app that pass is a single long task that blocks input until it finishes, +// which is exactly what INP measures. The `feature/optimize-subscription` +// branch slices that pass into ~5ms chunks and yields to the event loop +// between them, so the *longest uninterrupted block* stays small. +// +// This script compares the two strategies. The time-sliced loop is a faithful +// copy of `src/utils/listeners.ts` (5ms budget, `Date.now()` sampled per +// iteration, a macrotask yield between chunks). Run: `node benchmark/notify-bench.mjs`. + +const CHUNK_BUDGET_MS = 5 + +// Macrotask yield — stands in for the branch's MessageChannel scheduler +// (`src/utils/batch.ts`); setImmediate is the same event-loop phase in Node. +const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve)) + +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b) + const m = s.length >> 1 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2 +} + +// A subscriber callback that mimics the cost of one `useSelector` check: +// read state, run a selector, do a shallow-equality comparison. `WORK` scales +// that cost so a single check lands around a few microseconds — the ballpark of +// a non-trivial memoized selector recompute + equality on a real app. +const WORK = 4000 +const state = { items: Array.from({ length: 256 }, (_, i) => i), tick: 0 } + +function makeCallback(i) { + let last = -1 + return () => { + let acc = 0 + for (let k = 0; k < WORK; k++) { + acc += state.items[(i + k) & 255] ^ state.tick + } + const next = acc & 0xffff + const changed = next !== last + last = next + return changed + } +} + +function makeListeners(n) { + return Array.from({ length: n }, (_, i) => makeCallback(i)) +} + +// --- Strategy A: synchronous single pass (current upstream behavior) -------- +function runSync(listeners) { + const start = performance.now() + for (let i = 0; i < listeners.length; i++) listeners[i]() + const dur = performance.now() - start + // One pass == one task, so the longest blocking task is the whole thing. + return { longestTaskMs: dur, totalMs: dur, chunks: 1 } +} + +// --- Strategy B: time-sliced pass (feature/optimize-subscription) ----------- +async function runSliced(listeners) { + const wallStart = performance.now() + let i = 0 + let longestChunk = 0 + let chunks = 0 + + while (i < listeners.length) { + const chunkStart = performance.now() + const finishTime = Date.now() + CHUNK_BUDGET_MS + while (i < listeners.length && Date.now() < finishTime) { + listeners[i]() + i++ + } + const chunkDur = performance.now() - chunkStart + if (chunkDur > longestChunk) longestChunk = chunkDur + chunks++ + if (i < listeners.length) await yieldToEventLoop() + } + + return { + longestTaskMs: longestChunk, + totalMs: performance.now() - wallStart, + chunks, + } +} + +async function bench(n, repeats = 7) { + const listeners = makeListeners(n) + + // warm up JIT + runSync(listeners) + await runSliced(listeners) + + const sync = [] + const sliced = [] + const slicedTotal = [] + let chunks = 0 + + for (let r = 0; r < repeats; r++) { + state.tick++ + sync.push(runSync(listeners).longestTaskMs) + + state.tick++ + const b = await runSliced(listeners) + sliced.push(b.longestTaskMs) + slicedTotal.push(b.totalMs) + chunks = b.chunks + } + + return { + n, + syncLongest: median(sync), + slicedLongest: median(sliced), + slicedTotal: median(slicedTotal), + chunks, + } +} + +const fmt = (x) => x.toFixed(2) + +const rows = [] +for (const n of [1_000, 5_000, 10_000, 20_000, 50_000, 100_000]) { + rows.push(await bench(n)) +} + +console.log('\nsubscribers | sync longest task | sliced longest task | reduction | sliced chunks | sliced total wall') +console.log('-----------:|------------------:|--------------------:|----------:|--------------:|-----------------:') +for (const r of rows) { + const reduction = ((1 - r.slicedLongest / r.syncLongest) * 100).toFixed(1) + console.log( + `${r.n.toLocaleString().padStart(11)} | ${fmt(r.syncLongest).padStart(14)} ms | ${fmt(r.slicedLongest).padStart(16)} ms | ${(reduction + '%').padStart(9)} | ${String(r.chunks).padStart(13)} | ${fmt(r.slicedTotal).padStart(14)} ms`, + ) +} + +// machine-readable, for pasting into the results doc +console.log('\nJSON:' + JSON.stringify(rows)) diff --git a/benchmark/responsiveness.mjs b/benchmark/responsiveness.mjs new file mode 100644 index 000000000..6b807b12e --- /dev/null +++ b/benchmark/responsiveness.mjs @@ -0,0 +1,122 @@ +// Responsiveness benchmark: worst-case input latency during a notification pass. +// +// react-redux on React's built-in `useSyncExternalStore` (v9) notifies every +// subscriber SYNCHRONOUSLY in one pass. While that pass runs, the event loop is +// blocked — a user interaction that arrives mid-pass can't be serviced until it +// finishes. That wait is what INP measures. +// +// We measure it directly. A high-frequency "heartbeat" (a stand-in for the +// event loop's ability to service an interaction) ticks throughout the run; the +// largest gap between consecutive ticks is the longest the main thread was +// unavailable — i.e. the worst-case input latency. +// +// * `react-uSES` — synchronous single pass (what react-redux does today). +// * `time-sliced` — this branch: yield every ~5ms. +// +// Both use setImmediate to schedule, which is exactly what React's own +// scheduler uses under Node (Scheduler.js:531). Run: `node benchmark/responsiveness.mjs`. + +const BUDGET_MS = 5 +const now = () => performance.now() +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b) + const m = s.length >> 1 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2 +} +const nextTask = () => new Promise((resolve) => setImmediate(resolve)) + +const WORK = 4000 +const state = { items: Array.from({ length: 256 }, (_, i) => i), tick: 0 } +function makeListeners(n) { + return Array.from({ length: n }, (_, i) => { + let last = -1 + return () => { + let acc = 0 + for (let k = 0; k < WORK; k++) acc += state.items[(i + k) & 255] ^ state.tick + const next = acc & 0xffff + const changed = next !== last + last = next + return changed + } + }) +} + +// React useSyncExternalStore: synchronous notification, one uninterrupted task. +function runReactUSES(listeners) { + for (let i = 0; i < listeners.length; i++) listeners[i]() +} + +// This branch: drain in ~5ms chunks, yielding to the event loop between them. +async function runTimeSliced(listeners) { + let i = 0 + while (i < listeners.length) { + const startTime = now() + do { + listeners[i]() + i++ + } while (i < listeners.length && now() - startTime < BUDGET_MS) + if (i < listeners.length) await nextTask() + } +} + +// Run `pass` while a heartbeat ticks; return the largest gap between ticks. +async function worstInputLatency(pass, listeners) { + const beats = [] + let running = true + const beat = () => { + beats.push(now()) + if (running) setImmediate(beat) + } + setImmediate(beat) + + // let the heartbeat settle + for (let i = 0; i < 5; i++) await nextTask() + + await pass(listeners) + + running = false + await nextTask() + await nextTask() + + let maxGap = 0 + for (let i = 1; i < beats.length; i++) { + maxGap = Math.max(maxGap, beats[i] - beats[i - 1]) + } + return maxGap +} + +async function bench(n, repeats = 7) { + const listeners = makeListeners(n) + await runTimeSliced(listeners) // warm up + + const pick = async (pass) => { + const out = [] + for (let r = 0; r < repeats; r++) { + state.tick++ + out.push(await worstInputLatency(pass, listeners)) + } + return median(out) + } + + return { + n, + reactUSES: await pick(runReactUSES), + timeSliced: await pick(runTimeSliced), + } +} + +const f = (x) => x.toFixed(2).padStart(10) +const rows = [] +for (const n of [1_000, 10_000, 50_000, 100_000]) rows.push(await bench(n)) + +console.log('\n WORST-CASE INPUT LATENCY (ms) — max main-thread block during a pass') +console.log('subscribers | react-uSES (sync) | time-sliced (this branch) | improvement') +console.log('-----------:|------------------:|--------------------------:|-----------:') +for (const r of rows) { + const imp = ((1 - r.timeSliced / r.reactUSES) * 100).toFixed(1) + console.log( + `${String(r.n).padStart(11)} | ${f(r.reactUSES)} ms | ${f(r.timeSliced)} ms | ${(imp + '%').padStart(9)}`, + ) +} +console.log('\nJSON:' + JSON.stringify(rows)) +process.exit(0) diff --git a/benchmark/vs-react-scheduler.mjs b/benchmark/vs-react-scheduler.mjs new file mode 100644 index 000000000..f6a963927 --- /dev/null +++ b/benchmark/vs-react-scheduler.mjs @@ -0,0 +1,163 @@ +// Parity check: our sliced notify vs. React's scheduler on the same workload. +// +// The batching idea comes from React's `scheduler` package +// (packages/scheduler/src/forks/Scheduler.js). This compares three strategies +// running the SAME N callbacks, reporting the longest uninterrupted task: +// +// 1. `original` — the branch's first cut, with the frozen-clock bug +// (`const date = new Date()` sampled once). The 5ms budget +// never expires, so it never actually yields. +// 2. `fixed` — the current branch: 5ms budget sampled live via Date.now(), +// single shared MessageChannel, single in-flight drain. +// 3. `react` — React's scheduler semantics: frameInterval = frameYieldMs +// (5ms), `startTime` reset per task, `getCurrentTime()` +// sampled live, one message loop (Scheduler.js:456-568). +// +// The point: `fixed` matches `react`; `original` degrades to one long task. +// Run: `node benchmark/vs-react-scheduler.mjs` + +const BUDGET_MS = 5 // === React's frameYieldMs + +// One shared scheduler (matches src/utils/batch.ts and Scheduler.js:549) — a +// single MessageChannel reused via a queue, NOT a new channel per yield. +let queue = [] +const channel = new MessageChannel() +channel.port1.onmessage = () => { + const pending = queue + queue = [] + for (const cb of pending) cb() +} +const scheduleTask = (cb) => { + queue.push(cb) + if (queue.length === 1) channel.port2.postMessage(null) +} +const yieldToHost = () => new Promise((resolve) => scheduleTask(resolve)) + +const now = () => performance.now() +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b) + const m = s.length >> 1 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2 +} + +const WORK = 4000 +const state = { items: Array.from({ length: 256 }, (_, i) => i), tick: 0 } +function makeListeners(n) { + return Array.from({ length: n }, (_, i) => { + let last = -1 + return () => { + let acc = 0 + for (let k = 0; k < WORK; k++) acc += state.items[(i + k) & 255] ^ state.tick + const next = acc & 0xffff + const changed = next !== last + last = next + return changed + } + }) +} + +// 1. original — frozen clock: budget is computed from a timestamp captured once, +// so `frozen < finishTime` is always true and the loop never yields. +async function runOriginal(listeners) { + const frozen = now() // sampled ONCE, like `const date = new Date()` + const wall = now() + let i = 0 + let longest = 0 + let chunks = 0 + while (i < listeners.length) { + const start = now() + const finishTime = frozen + BUDGET_MS + while (i < listeners.length && frozen < finishTime) { + listeners[i]() + i++ + } + longest = Math.max(longest, now() - start) + chunks++ + if (i < listeners.length) await yieldToHost() + } + return { longest, total: now() - wall, chunks } +} + +// 2. fixed — live budget sampled per iteration (current branch). +async function runFixed(listeners) { + const wall = now() + let i = 0 + let longest = 0 + let chunks = 0 + while (i < listeners.length) { + const start = now() + const finishTime = Date.now() + BUDGET_MS + while (i < listeners.length && Date.now() < finishTime) { + listeners[i]() + i++ + } + longest = Math.max(longest, now() - start) + chunks++ + if (i < listeners.length) await yieldToHost() + } + return { longest, total: now() - wall, chunks } +} + +// 3. react — shouldYieldToHost: startTime reset per task, elapsed vs frameInterval. +async function runReact(listeners) { + const wall = now() + let i = 0 + let longest = 0 + let chunks = 0 + while (i < listeners.length) { + const startTime = now() // Scheduler.js:507 + let chunkLongest = 0 + // work loop with shouldYieldToHost() checked after each unit + while (i < listeners.length) { + listeners[i]() + i++ + if (now() - startTime >= BUDGET_MS) break // Scheduler.js:464-471 + } + chunkLongest = now() - startTime + longest = Math.max(longest, chunkLongest) + chunks++ + if (i < listeners.length) await yieldToHost() + } + return { longest, total: now() - wall, chunks } +} + +async function bench(n, repeats = 7) { + const listeners = makeListeners(n) + await runFixed(listeners) // warm up + + const pick = async (fn) => { + const longest = [] + const total = [] + let chunks = 0 + for (let r = 0; r < repeats; r++) { + state.tick++ + const res = await fn(listeners) + longest.push(res.longest) + total.push(res.total) + chunks = res.chunks + } + return { longest: median(longest), total: median(total), chunks } + } + + return { + n, + original: await pick(runOriginal), + fixed: await pick(runFixed), + react: await pick(runReact), + } +} + +const f = (x) => x.toFixed(2).padStart(8) +const rows = [] +for (const n of [1_000, 10_000, 50_000, 100_000]) rows.push(await bench(n)) + +console.log('\n LONGEST TASK (ms) — lower is better | chunks (fixed/react)') +console.log('subscribers | original | fixed | react |') +console.log('-----------:|-----------:|-----------:|-----------:|') +for (const r of rows) { + console.log( + `${String(r.n).padStart(11)} | ${f(r.original.longest)} | ${f(r.fixed.longest)} | ${f(r.react.longest)} | ${r.fixed.chunks} / ${r.react.chunks}`, + ) +} +console.log('\nJSON:' + JSON.stringify(rows)) +process.exit(0) diff --git a/docs/api/Provider.md b/docs/api/Provider.md index 28ae2cc39..8370d9fbc 100644 --- a/docs/api/Provider.md +++ b/docs/api/Provider.md @@ -50,6 +50,9 @@ interface ProviderProps = UnknownAction, S = unknown> { /** Global configuration for the `useSelector` identity function check */ identityFunctionCheck?: DevModeCheckFrequency + /** Called on the first read of each top-level state key by a `useSelector` selector */ + onStateAccess?: (key: string, selector: Function) => void + /** The top-level React elements in your component tree, such as `` **/ children: ReactNode } @@ -63,6 +66,42 @@ You may provide a context instance. If you do so, you will need to provide the s > > Could not find "store" in the context of "Connect(MyComponent)". Either wrap the root component in a ``, or pass a custom React context provider to `` and the corresponding React context consumer to Connect(Todo) in connect options. +## Observing State Reads + +`useSelector` tracks which top-level keys of the store state each selector reads. Pass `onStateAccess` to be told about them: it is called the first time a selector reads a given key, once per key for each component using `useSelector`. This is useful for dev tooling, for example to check that every slice a component reads has been loaded. + +```tsx + { + console.log(`${selector.name} reads state.${key}`) + }} +> + + +``` + +The listener may be called during render, so it must not trigger React state updates. Only string keys are reported, and only reads made through `useSelector`: `connect` does not report them. + +The listener can be replaced for part of the tree by rendering a nested `ReactReduxContext.Provider` whose value spreads the parent context value and sets its own `onStateAccess`. Keep the parent's `store` and `subscription` so that subscription ordering is preserved. The context value also carries `stateAccessTracking: true`, which libraries can use to detect support for `onStateAccess`. + +```tsx +function Scope({ children }: { children: ReactNode }) { + const parent = useContext(ReactReduxContext)! + const value = useMemo( + () => ({ ...parent, onStateAccess: scopedListener }), + [parent], + ) + return ( + + {children} + + ) +} +``` + +A new `onStateAccess` function on each render does not re-run selectors or re-render consumers. + ## React 18 SSR Usage As of React-Redux v8, `` now accepts a `serverState` prop for use in SSR hydration scenarios. This is necessary if you are calling `hydrateRoot` in order to avoid hydration mismatches. diff --git a/package.json b/package.json index d38cb75ff..d772e0563 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "react-redux", - "version": "9.3.0", + "name": "@veksa/react-redux", + "version": "9.3.3", "description": "Official React bindings for Redux", "keywords": [ "react", @@ -24,17 +24,11 @@ "react-native": "./dist/react-redux.legacy-esm.js", "import": "./dist/react-redux.mjs", "default": "./dist/cjs/index.js" - }, - "./alternate-renderers": { - "types": "./dist/react-redux.d.ts", - "import": "./dist/react-redux.mjs", - "default": "./dist/cjs/index.js" } }, "sideEffects": false, "files": [ - "dist", - "src" + "dist" ], "scripts": { "build": "pnpm clean && tsdown && unrun scripts/stripSourcemapFileField.mts", @@ -61,10 +55,6 @@ "optional": true } }, - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@reduxjs/toolkit": "^2.12.0", @@ -86,7 +76,7 @@ "redux": "^5.0.1", "rimraf": "^6.1.3", "tsdown": "^0.22.14", - "typescript": "^6.0.3", + "typescript": "^7.0.2", "unrun": "^0.3.1", "vite": "^8.2.2", "vitest": "^4.1.11" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9115c196..a482b9e58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,13 +7,6 @@ settings: importers: .: - dependencies: - '@types/use-sync-external-store': - specifier: ^0.0.6 - version: 0.0.6 - use-sync-external-store: - specifier: ^1.4.0 - version: 1.6.0(react@19.2.8) devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.5 @@ -74,10 +67,10 @@ importers: version: 6.1.3 tsdown: specifier: ^0.22.14 - version: 0.22.14(@arethetypeswrong/core@0.18.5)(typescript@6.0.3)(unrun@0.3.1) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(typescript@7.0.2)(unrun@0.3.1) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: ^7.0.2 + version: 7.0.2 unrun: specifier: ^0.3.1 version: 0.3.1 @@ -583,8 +576,125 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} - '@types/use-sync-external-store@0.0.6': - resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -619,133 +729,157 @@ packages: resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} cpu: [arm64] os: [android] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-darwin-arm64@0.8.7': resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} cpu: [arm64] os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-darwin-x64@0.8.7': resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} cpu: [x64] os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-freebsd-x64@0.8.7': resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} cpu: [x64] os: [freebsd] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm-gnu@0.8.7': resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm-musl@0.8.7': resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-arm64-musl@0.8.7': resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-x64-gnu@0.8.7': resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-linux-x64-musl@0.8.7': resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-win32-arm64@0.8.7': resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} cpu: [arm64] os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-codegen/binding-win32-x64@0.8.7': resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} cpu: [x64] os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 '@yuku-parser/binding-android-arm64@0.8.7': resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} cpu: [arm64] os: [android] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-darwin-arm64@0.8.7': resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} cpu: [arm64] os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-darwin-x64@0.8.7': resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} cpu: [x64] os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-freebsd-x64@0.8.7': resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} cpu: [x64] os: [freebsd] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm-gnu@0.8.7': resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm-musl@0.8.7': resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm64-gnu@0.8.7': resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-arm64-musl@0.8.7': resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-x64-gnu@0.8.7': resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-linux-x64-musl@0.8.7': resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-win32-arm64@0.8.7': resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} cpu: [arm64] os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-parser/binding-win32-x64@0.8.7': resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} cpu: [x64] os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 '@yuku-toolchain/types@0.8.7': resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} @@ -1542,9 +1676,9 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} hasBin: true unconfig-core@7.5.0: @@ -1567,11 +1701,6 @@ packages: synckit: 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 - validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -2055,7 +2184,65 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/use-sync-external-store@0.0.6': {} + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true '@vitest/expect@4.1.11': dependencies: @@ -2716,7 +2903,7 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown-plugin-dts@0.27.14(rolldown@1.2.7)(typescript@6.0.3): + rolldown-plugin-dts@0.27.14(rolldown@1.2.7)(typescript@7.0.2): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.5 @@ -2726,7 +2913,7 @@ snapshots: yuku-codegen: 0.8.7 yuku-parser: 0.8.7 optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 transitivePeerDependencies: - oxc-resolver @@ -2845,7 +3032,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(typescript@6.0.3)(unrun@0.3.1): + tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(typescript@7.0.2)(unrun@0.3.1): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -2856,7 +3043,7 @@ snapshots: obug: 2.1.4 picomatch: 4.0.7 rolldown: 1.2.7 - rolldown-plugin-dts: 0.27.14(rolldown@1.2.7)(typescript@6.0.3) + rolldown-plugin-dts: 0.27.14(rolldown@1.2.7)(typescript@7.0.2) tinyexec: 1.3.1 tinyglobby: 0.2.17 tree-kill: 1.2.2 @@ -2864,7 +3051,7 @@ snapshots: verkit: 0.3.2 optionalDependencies: '@arethetypeswrong/core': 0.18.5 - typescript: 6.0.3 + typescript: 7.0.2 unrun: 0.3.1 transitivePeerDependencies: - '@typescript/native-preview' @@ -2874,7 +3061,28 @@ snapshots: typescript@5.6.1-rc: {} - typescript@6.0.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 unconfig-core@7.5.0: dependencies: @@ -2889,10 +3097,6 @@ snapshots: dependencies: rolldown: 1.2.7 - use-sync-external-store@1.6.0(react@19.2.8): - dependencies: - react: 19.2.8 - validate-npm-package-name@5.0.1: {} verkit@0.3.2: {} diff --git a/src/components/Context.ts b/src/components/Context.ts index 32454d198..e0bd5b60b 100644 --- a/src/components/Context.ts +++ b/src/components/Context.ts @@ -4,6 +4,16 @@ import type { Action, Store, UnknownAction } from 'redux' import type { Subscription } from '../utils/Subscription' import type { ProviderProps } from './Provider' +/** + * Receives the first read of each top-level key of the store state made by a + * `useSelector` selector. Reported once per key for each subscribing selector, + * possibly during render, so it must not trigger React state updates. + * + * @param key - The top-level state key that was read. + * @param selector - The selector passed to `useSelector` that read it. + */ +export type StateAccessListener = (key: string, selector: Function) => void + export interface ReactReduxContextValue< SS = any, A extends Action = UnknownAction, @@ -11,6 +21,19 @@ export interface ReactReduxContextValue< store: Store subscription: Subscription getServerState?: () => SS + + /** + * See {@linkcode StateAccessListener}. A nested + * `ReactReduxContext.Provider` may override it for a subtree by spreading the + * parent context value, keeping its `store` and `subscription`. + */ + onStateAccess?: StateAccessListener + + /** + * `true` when `useSelector` supports {@linkcode onStateAccess}, so consumers + * can feature-detect it instead of tracking state reads themselves. + */ + stateAccessTracking?: boolean } const ContextKey = /* @__PURE__ */ Symbol.for(`react-redux-context`) @@ -45,6 +68,9 @@ function getContext(): Context { return realContext } -export const ReactReduxContext = /*#__PURE__*/ getContext() +export const ReactReduxContext: Context | null> = /*#__PURE__*/ getContext() export type ReactReduxContextInstance = typeof ReactReduxContext diff --git a/src/components/Provider.tsx b/src/components/Provider.tsx index 2efe82b88..0ba1f34d4 100644 --- a/src/components/Provider.tsx +++ b/src/components/Provider.tsx @@ -4,7 +4,7 @@ import type { Action, Store, UnknownAction } from 'redux' import type { DevModeCheckFrequency } from '../hooks/useSelector' import { createSubscription } from '../utils/Subscription' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' -import type { ReactReduxContextValue } from './Context' +import type { ReactReduxContextValue, StateAccessListener } from './Context' import { ReactReduxContext } from './Context' export interface ProviderProps< @@ -51,12 +51,19 @@ export interface ProviderProps< */ identityFunctionCheck?: DevModeCheckFrequency + /** + * Called the first time a `useSelector` selector reads a top-level key of the + * store state, once per key for each subscribing selector. It may be called + * during render, so it must not trigger React state updates. + */ + onStateAccess?: StateAccessListener + children: ReactNode } -function Provider = UnknownAction, S = unknown>( +export function Provider = UnknownAction, S = unknown>( providerProps: ProviderProps, -) { +): React.JSX.Element { if (process.env.NODE_ENV !== 'production') { // The React Server Components build of React has no effect hooks if (typeof useIsomorphicLayoutEffect !== 'function') { @@ -72,15 +79,16 @@ function Provider = UnknownAction, S = unknown>( } } - const { children, context, serverState, store } = providerProps + const { children, context, serverState, store, onStateAccess } = providerProps const contextValue = React.useMemo(() => { const subscription = createSubscription(store) - const baseContextValue = { + const baseContextValue: ReactReduxContextValue = { store, subscription, getServerState: serverState != null ? () => serverState : undefined, + stateAccessTracking: true, } if (process.env.NODE_ENV === 'production') { @@ -96,6 +104,27 @@ function Provider = UnknownAction, S = unknown>( } }, [store, serverState]) + // A stable wrapper around the latest `onStateAccess`, added in a separate + // memo so that neither a new listener identity nor gaining or losing one + // recreates the subscription. + const onStateAccessRef = React.useRef(onStateAccess) + onStateAccessRef.current = onStateAccess + + const hasStateAccessListener = onStateAccess !== undefined + + const providedValue = React.useMemo( + () => + hasStateAccessListener + ? { + ...contextValue, + onStateAccess: (key: string, selector: Function) => { + onStateAccessRef.current?.(key, selector) + }, + } + : contextValue, + [contextValue, hasStateAccessListener], + ) + const previousState = React.useMemo(() => store.getState(), [store]) useIsomorphicLayoutEffect(() => { @@ -114,7 +143,5 @@ function Provider = UnknownAction, S = unknown>( const Context = context || ReactReduxContext - return {children} + return {children} } - -export default Provider diff --git a/src/components/connect.tsx b/src/components/connect.tsx index f37f0ae32..4ba971548 100644 --- a/src/components/connect.tsx +++ b/src/components/connect.tsx @@ -25,7 +25,7 @@ import type { MergeProps, SelectorFactoryOptions, } from '../connect/selectorFactory' -import defaultSelectorFactory from '../connect/selectorFactory' +import { finalPropsSelectorFactory as defaultSelectorFactory } from '../connect/selectorFactory' import { mapDispatchToPropsFactory } from '../connect/mapDispatchToProps' import { mapStateToPropsFactory } from '../connect/mapStateToProps' import { mergePropsFactory } from '../connect/mergeProps' @@ -33,9 +33,9 @@ import { mergePropsFactory } from '../connect/mergeProps' import type { Subscription } from '../utils/Subscription' import { createSubscription } from '../utils/Subscription' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' -import shallowEqual from '../utils/shallowEqual' -import hoistStatics from '../utils/hoistStatics' -import warning from '../utils/warning' +import { shallowEqual } from '../utils/shallowEqual' +import { hoistNonReactStatics as hoistStatics } from '../utils/hoistStatics' +import { warning } from '../utils/warning' import type { ReactReduxContextValue, diff --git a/src/connect/invalidArgFactory.ts b/src/connect/invalidArgFactory.ts index 15b1ec5b0..da0c7f8d2 100644 --- a/src/connect/invalidArgFactory.ts +++ b/src/connect/invalidArgFactory.ts @@ -1,6 +1,12 @@ import type { Action, Dispatch } from 'redux' -export function createInvalidArgFactory(arg: unknown, name: string) { +export function createInvalidArgFactory( + arg: unknown, + name: string, +): ( + dispatch: Dispatch>, + options: { readonly wrappedComponentName: string }, +) => never { return ( dispatch: Dispatch>, options: { readonly wrappedComponentName: string }, diff --git a/src/connect/mapDispatchToProps.ts b/src/connect/mapDispatchToProps.ts index 7113b3ca0..978cf4eda 100644 --- a/src/connect/mapDispatchToProps.ts +++ b/src/connect/mapDispatchToProps.ts @@ -1,14 +1,18 @@ import type { Action, Dispatch } from 'redux' -import bindActionCreators from '../utils/bindActionCreators' -import { wrapMapToPropsConstant, wrapMapToPropsFunc } from './wrapMapToProps' +import { bindActionCreators } from '../utils/bindActionCreators' import { createInvalidArgFactory } from './invalidArgFactory' import type { MapDispatchToPropsParam } from './selectorFactory' +import type { InitProxyOptions, WrappedMapToProps } from './wrapMapToProps' +import { wrapMapToPropsConstant, wrapMapToPropsFunc } from './wrapMapToProps' export function mapDispatchToPropsFactory( mapDispatchToProps: | MapDispatchToPropsParam | undefined, -) { +): ( + dispatch: Dispatch>, + options: InitProxyOptions, +) => WrappedMapToProps { return mapDispatchToProps && typeof mapDispatchToProps === 'object' ? wrapMapToPropsConstant((dispatch: Dispatch>) => // @ts-ignore diff --git a/src/connect/mapStateToProps.ts b/src/connect/mapStateToProps.ts index 0c537c0a9..bfb129418 100644 --- a/src/connect/mapStateToProps.ts +++ b/src/connect/mapStateToProps.ts @@ -1,10 +1,15 @@ -import { wrapMapToPropsConstant, wrapMapToPropsFunc } from './wrapMapToProps' +import type { Action, Dispatch } from 'redux' import { createInvalidArgFactory } from './invalidArgFactory' import type { MapStateToPropsParam } from './selectorFactory' +import type { InitProxyOptions, WrappedMapToProps } from './wrapMapToProps' +import { wrapMapToPropsConstant, wrapMapToPropsFunc } from './wrapMapToProps' export function mapStateToPropsFactory( mapStateToProps: MapStateToPropsParam, -) { +): ( + dispatch: Dispatch>, + options: InitProxyOptions, +) => WrappedMapToProps { return !mapStateToProps ? wrapMapToPropsConstant(() => ({})) : typeof mapStateToProps === 'function' diff --git a/src/connect/mergeProps.ts b/src/connect/mergeProps.ts index 9de4a44c9..18d9a4283 100644 --- a/src/connect/mergeProps.ts +++ b/src/connect/mergeProps.ts @@ -1,5 +1,5 @@ import type { Action, Dispatch } from 'redux' -import verifyPlainObject from '../utils/verifyPlainObject' +import { verifyPlainObject } from '../utils/verifyPlainObject' import { createInvalidArgFactory } from './invalidArgFactory' import type { MergeProps } from './selectorFactory' import type { EqualityFn } from '../types' @@ -69,7 +69,14 @@ export function mergePropsFactory< TMergedProps, >( mergeProps?: MergeProps, -) { +): ( + dispatch: Dispatch>, + options: { + readonly displayName: string + readonly wrappedComponentName: string + readonly areMergedPropsEqual: EqualityFn + }, +) => MergeProps { return !mergeProps ? () => defaultMergeProps : typeof mergeProps === 'function' diff --git a/src/connect/selectorFactory.ts b/src/connect/selectorFactory.ts index 5d5dca500..0470ef62e 100644 --- a/src/connect/selectorFactory.ts +++ b/src/connect/selectorFactory.ts @@ -1,6 +1,6 @@ import type { Dispatch, Action } from 'redux' import type { ComponentType } from 'react' -import verifySubselectors from './verifySubselectors' +import { verifySubselectors } from './verifySubselectors' import type { EqualityFn, ExtendedEqualityFn } from '../types' export type SelectorFactory = ( @@ -207,7 +207,7 @@ export interface SelectorFactoryOptions< // allowing connect's shouldComponentUpdate to return false if final // props have not changed. -export default function finalPropsSelectorFactory< +export function finalPropsSelectorFactory< TStateProps, TOwnProps, TDispatchProps, @@ -227,7 +227,7 @@ export default function finalPropsSelectorFactory< TMergedProps, State >, -) { +): (nextState: State, nextOwnProps: TOwnProps) => TMergedProps { const mapStateToProps = initMapStateToProps(dispatch, options) const mapDispatchToProps = initMapDispatchToProps(dispatch, options) const mergeProps = initMergeProps(dispatch, options) diff --git a/src/connect/verifySubselectors.ts b/src/connect/verifySubselectors.ts index 1f59f9623..180aa72ee 100644 --- a/src/connect/verifySubselectors.ts +++ b/src/connect/verifySubselectors.ts @@ -1,4 +1,4 @@ -import warning from '../utils/warning' +import { warning } from '../utils/warning' function verify(selector: unknown, methodName: string): void { if (!selector) { @@ -15,7 +15,7 @@ function verify(selector: unknown, methodName: string): void { } } -export default function verifySubselectors( +export function verifySubselectors( mapStateToProps: unknown, mapDispatchToProps: unknown, mergeProps: unknown, diff --git a/src/connect/wrapMapToProps.ts b/src/connect/wrapMapToProps.ts index 0a95939be..36c4476b2 100644 --- a/src/connect/wrapMapToProps.ts +++ b/src/connect/wrapMapToProps.ts @@ -1,7 +1,7 @@ import type { ActionCreatorsMapObject, Dispatch, ActionCreator } from 'redux' import type { FixTypeLater } from '../types' -import verifyPlainObject from '../utils/verifyPlainObject' +import { verifyPlainObject } from '../utils/verifyPlainObject' type AnyState = { [key: string]: any } type StateOrDispatch = S | Dispatch @@ -14,6 +14,26 @@ export type MapToProps

= { dependsOnOwnProps?: boolean } +/** + * What the `init*` factories below hand back to `selectorFactory`: the same + * call signature as {@linkcode MapToProps}, but with `dependsOnOwnProps` + * always present — `selectorFactory` reads it unconditionally. + */ +export type WrappedMapToProps

= { + (stateOrDispatch: StateOrDispatch, ownProps?: P): FixTypeLater + dependsOnOwnProps: boolean +} + +/** + * The fields of `selectorFactory`'s `InitOptions` that the `init*` factories + * actually read. Kept structural (rather than importing `InitOptions`) so the + * two modules don't have to agree on its type parameters. + */ +export type InitProxyOptions = { + readonly displayName: string + readonly wrappedComponentName: string +} + export function wrapMapToPropsConstant( // * Note: // It seems that the dispatch argument @@ -27,7 +47,7 @@ export function wrapMapToPropsConstant( } | ActionCreatorsMapObject | ActionCreator, -) { +): (dispatch: Dispatch) => WrappedMapToProps { return function initConstantSelector(dispatch: Dispatch) { const constant = getConstant(dispatch) @@ -68,7 +88,10 @@ function getDependsOnOwnProps(mapToProps: MapToProps) { export function wrapMapToPropsFunc

( mapToProps: MapToProps, methodName: string, -) { +): ( + dispatch: Dispatch, + factoryOptions: { displayName: string }, +) => WrappedMapToProps

{ return function initProxySelector( dispatch: Dispatch, { displayName }: { displayName: string }, diff --git a/src/exports.ts b/src/exports.ts index e3a027b08..21aa0ebc0 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -5,13 +5,16 @@ export type { ConnectedProps, } from './components/connect' -import shallowEqual from './utils/shallowEqual' +import { shallowEqual } from './utils/shallowEqual' -import Provider from './components/Provider' -import { defaultNoopBatch } from './utils/batch' +import { Provider } from './components/Provider' +import { batch } from './utils/batch' export { ReactReduxContext } from './components/Context' -export type { ReactReduxContextValue } from './components/Context' +export type { + ReactReduxContextValue, + StateAccessListener, +} from './components/Context' export type { ProviderProps } from './components/Provider' @@ -42,10 +45,4 @@ export type { Subscription } from './utils/Subscription' export * from './types' -/** - * @deprecated As of React 18, batching is enabled by default for ReactDOM and React Native. - * This is now a no-op that immediately runs the callback. - */ -const batch = defaultNoopBatch - export { Provider, batch, connect, legacy_connect, shallowEqual } diff --git a/src/hooks/useDispatch.ts b/src/hooks/useDispatch.ts index 32c88f0ba..31dcc6163 100644 --- a/src/hooks/useDispatch.ts +++ b/src/hooks/useDispatch.ts @@ -100,4 +100,5 @@ export function createDispatchHook< * ) * } */ -export const useDispatch = /*#__PURE__*/ createDispatchHook() +export const useDispatch: UseDispatch> = + /*#__PURE__*/ createDispatchHook() diff --git a/src/hooks/useReduxContext.ts b/src/hooks/useReduxContext.ts index fe103515e..8d2690ece 100644 --- a/src/hooks/useReduxContext.ts +++ b/src/hooks/useReduxContext.ts @@ -1,6 +1,7 @@ -import { React } from '../utils/react' -import { ReactReduxContext } from '../components/Context' +import type { Context } from 'react' import type { ReactReduxContextValue } from '../components/Context' +import { ReactReduxContext } from '../components/Context' +import { React } from '../utils/react' /** * Hook factory, which creates a `useReduxContext` hook bound to a given context. This is a low-level @@ -9,7 +10,9 @@ import type { ReactReduxContextValue } from '../components/Context' * @param {React.Context} [context=ReactReduxContext] Context passed to your ``. * @returns {Function} A `useReduxContext` hook bound to the specified context. */ -export function createReduxContextHook(context = ReactReduxContext) { +export function createReduxContextHook( + context: Context | null> = ReactReduxContext, +): () => ReactReduxContextValue { return function useReduxContext(): ReactReduxContextValue { const contextValue = React.useContext(context) @@ -39,4 +42,5 @@ export function createReduxContextHook(context = ReactReduxContext) { * return

{store.getState()}
* } */ -export const useReduxContext = /*#__PURE__*/ createReduxContextHook() +export const useReduxContext: () => ReactReduxContextValue = + /*#__PURE__*/ createReduxContextHook() diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index 07ebb5f7e..ca4fc3058 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -1,9 +1,8 @@ -//import * as React from 'react' -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector.js' import type { ReactReduxContextValue } from '../components/Context' import { ReactReduxContext } from '../components/Context' import type { EqualityFn } from '../types' import { React } from '../utils/react' +import { useSyncExternalStoreWithSelector } from '../utils/useSyncExternalStoreWithSelector' import { createReduxContextHook, useReduxContext as useDefaultReduxContext, @@ -90,7 +89,9 @@ export interface UseSelector { */ ( selector: (state: TState) => Selected, - equalityFnOrOptions?: EqualityFn | UseSelectorOptions, + equalityFnOrOptions?: + | EqualityFn> + | UseSelectorOptions>, ): Selected /** @@ -161,10 +162,24 @@ export function createSelectorHook( const reduxContext = useReduxContext() - const { store, subscription, getServerState } = reduxContext + const { store, subscription, getServerState, onStateAccess } = reduxContext const firstRun = React.useRef(true) + // The listener and selector are read through a ref when a key is first + // accessed, so a new listener identity (e.g. from a nested context + // override) does not invalidate the memoized selection. + const accessRef = React.useRef({ onStateAccess, selector }) + accessRef.current.onStateAccess = onStateAccess + accessRef.current.selector = selector + + const onAccess = React.useCallback((key: string) => { + const { onStateAccess, selector } = accessRef.current + if (onStateAccess) { + onStateAccess(key, selector) + } + }, []) + const wrappedSelector = React.useCallback( { [selector.name](state: TState) { @@ -246,6 +261,9 @@ export function createSelectorHook( getServerState || store.getState, wrappedSelector, equalityFn, + // Only passed while a listener is present: gaining or losing one starts a + // fresh memoized instance, so the new listener sees every key read. + onStateAccess ? onAccess : undefined, ) React.useDebugValue(selectedState) @@ -283,4 +301,5 @@ export function createSelectorHook( * return
{counter}
* } */ -export const useSelector = /*#__PURE__*/ createSelectorHook() +export const useSelector: UseSelector = + /*#__PURE__*/ createSelectorHook() diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index 59c5522a5..2c14863df 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -120,4 +120,5 @@ export function createStoreHook< * return
{store.getState()}
* } */ -export const useStore = /*#__PURE__*/ createStoreHook() +export const useStore: UseStore> = + /*#__PURE__*/ createStoreHook() diff --git a/src/index-rsc.ts b/src/index-rsc.ts index 47722081e..5adea97fd 100644 --- a/src/index-rsc.ts +++ b/src/index-rsc.ts @@ -32,4 +32,4 @@ export { throwNotSupportedError as useStore, } export const ReactReduxContext = {} as any -export { default as shallowEqual } from './utils/shallowEqual' +export { shallowEqual } from './utils/shallowEqual' diff --git a/src/utils/Subscription.ts b/src/utils/Subscription.ts index c9f8412a4..3ad6ee578 100644 --- a/src/utils/Subscription.ts +++ b/src/utils/Subscription.ts @@ -1,80 +1,5 @@ -import { defaultNoopBatch as batch } from './batch' - -// encapsulates the subscription logic for connecting a component to the redux store, as -// well as nesting subscriptions of descendant components, so that we can ensure the -// ancestor components re-render before descendants - -type VoidFunc = () => void - -type Listener = { - callback: VoidFunc - next: Listener | null - prev: Listener | null -} - -function createListenerCollection() { - let first: Listener | null = null - let last: Listener | null = null - - return { - clear() { - first = null - last = null - }, - - notify() { - batch(() => { - let listener = first - while (listener) { - listener.callback() - listener = listener.next - } - }) - }, - - get() { - const listeners: Listener[] = [] - let listener = first - while (listener) { - listeners.push(listener) - listener = listener.next - } - return listeners - }, - - subscribe(callback: () => void) { - let isSubscribed = true - - const listener: Listener = (last = { - callback, - next: null, - prev: last, - }) - - if (listener.prev) { - listener.prev.next = listener - } else { - first = listener - } - - return function unsubscribe() { - if (!isSubscribed || first === null) return - isSubscribed = false - - if (listener.next) { - listener.next.prev = listener.prev - } else { - last = listener.prev - } - if (listener.prev) { - listener.prev.next = listener.next - } else { - first = listener.next - } - } - }, - } -} +import type { VoidFunc } from './listeners' +import { createListenerCollection } from './listeners' type ListenerCollection = ReturnType @@ -94,7 +19,10 @@ const nullListeners = { get: () => [], } as unknown as ListenerCollection -export function createSubscription(store: any, parentSub?: Subscription) { +export function createSubscription( + store: any, + parentSub?: Subscription, +): Subscription { let unsubscribe: VoidFunc | undefined let listeners: ListenerCollection = nullListeners diff --git a/src/utils/batch.ts b/src/utils/batch.ts index b3bcb588e..ac6651017 100644 --- a/src/utils/batch.ts +++ b/src/utils/batch.ts @@ -1,4 +1,14 @@ -// Default to a dummy "batch" implementation that just runs the callback -export function defaultNoopBatch(callback: () => void) { - callback() +import { scheduleCallback } from './scheduler' + +// Public `batch` (re-exported from ../exports) now defers to the shared host +// scheduler, so it uses the same single MessageChannel transport as the +// time-sliced notification pass. +export function defaultNoopBatch(callback: () => void): void { + scheduleCallback(callback) } + +/** + * @deprecated As of React 18, batching is enabled by default for ReactDOM and React Native. + * This is now a no-op that immediately runs the callback. + */ +export const batch: typeof defaultNoopBatch = defaultNoopBatch diff --git a/src/utils/bindActionCreators.ts b/src/utils/bindActionCreators.ts index 79957430e..83672c6d4 100644 --- a/src/utils/bindActionCreators.ts +++ b/src/utils/bindActionCreators.ts @@ -1,6 +1,6 @@ import type { ActionCreatorsMapObject, Dispatch } from 'redux' -export default function bindActionCreators( +export function bindActionCreators( actionCreators: ActionCreatorsMapObject, dispatch: Dispatch, ): ActionCreatorsMapObject { diff --git a/src/utils/hoistStatics.ts b/src/utils/hoistStatics.ts index a017ad0a8..6fdd5e581 100644 --- a/src/utils/hoistStatics.ts +++ b/src/utils/hoistStatics.ts @@ -90,7 +90,7 @@ const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor const getPrototypeOf = Object.getPrototypeOf const objectPrototype = Object.prototype -export default function hoistNonReactStatics< +export function hoistNonReactStatics< Target, Source, CustomStatic extends { diff --git a/src/utils/isPlainObject.ts b/src/utils/isPlainObject.ts index f069adc56..df3094e62 100644 --- a/src/utils/isPlainObject.ts +++ b/src/utils/isPlainObject.ts @@ -2,7 +2,7 @@ * @param {any} obj The object to inspect. * @returns {boolean} True if the argument appears to be a plain object. */ -export default function isPlainObject(obj: unknown) { +export function isPlainObject(obj: unknown): boolean { if (typeof obj !== 'object' || obj === null) return false const proto = Object.getPrototypeOf(obj) diff --git a/src/utils/listeners.ts b/src/utils/listeners.ts new file mode 100644 index 000000000..59b4c8454 --- /dev/null +++ b/src/utils/listeners.ts @@ -0,0 +1,132 @@ +import { scheduleCallback, shouldYield } from './scheduler' + +export type VoidFunc = () => void + +export type Listener = { + callback: VoidFunc + next: Listener | null + prev: Listener | null +} + +export type ListenerCollection = { + clear(): void + notify(): void + get(): Listener[] + subscribe(callback: () => void): VoidFunc +} + +// encapsulates the subscription logic for connecting a component to the redux store, as +// well as nesting subscriptions of descendant components, so that we can ensure the +// ancestor components re-render before descendants + +export function createListenerCollection(): ListenerCollection { + let first: Listener | null = null + let last: Listener | null = null + + // Notification is time-sliced across async chunks, so a `notify()` can arrive + // while a previous pass is still draining. These live on the collection (not + // per `notify()` call) so we keep a single in-flight drain instead of + // spawning a second, parallel traversal — which would notify listeners out of + // order and more than once, breaking the ancestor-before-descendant ordering. + let current: Listener | null = null + let isDraining = false + let restartQueued = false + + const drainChunk = () => { + // Notify at least one listener before consulting shouldYield(), so the pass + // always makes progress — otherwise pending input (which yields early) could + // reschedule forever without notifying anyone. + if (current) { + do { + current.callback() + current = current.next + } while (current && !shouldYield()) + } + + if (current) { + // Deadline reached mid-pass: yield to the browser, then resume from here. + scheduleCallback(drainChunk) + return + } + + if (restartQueued) { + // A notification arrived while we were draining. Re-run from the head so + // every listener observes the latest state exactly once per pass. + restartQueued = false + current = first + scheduleCallback(drainChunk) + return + } + + isDraining = false + } + + return { + clear() { + first = null + last = null + // Abort any in-flight drain: the listeners it was walking are gone. + current = null + restartQueued = false + }, + + notify() { + if (isDraining) { + // Fold this notification into the running drain instead of starting a + // competing one; it will re-run from the head once the pass completes. + restartQueued = true + return + } + + isDraining = true + current = first + // Scheduled, not run inline: keeping the whole pass off the dispatch task + // is what stops a large subscriber set from turning one write into a long + // task. A caller that must observe the result synchronously has to flush + // — see the note on `notify` in the type above. + scheduleCallback(drainChunk) + }, + + get() { + const listeners: Listener[] = [] + let listener = first + while (listener) { + listeners.push(listener) + listener = listener.next + } + return listeners + }, + + subscribe(callback: () => void) { + let isSubscribed = true + + const listener: Listener = (last = { + callback, + next: null, + prev: last, + }) + + if (listener.prev) { + listener.prev.next = listener + } else { + first = listener + } + + return function unsubscribe() { + if (!isSubscribed || first === null) return + isSubscribed = false + + if (listener.next) { + listener.next.prev = listener.prev + } else { + last = listener.prev + } + if (listener.prev) { + listener.prev.next = listener.next + } else { + first = listener.next + } + } + }, + } +} diff --git a/src/utils/react-is.ts b/src/utils/react-is.ts index cab872925..78e437aca 100644 --- a/src/utils/react-is.ts +++ b/src/utils/react-is.ts @@ -6,7 +6,8 @@ import { React } from './react' // It's very possible this could change in the future, but given that // we only use these in `connect`, this is a low priority. -export const IS_REACT_19 = /* @__PURE__ */ React.version.startsWith('19') +export const IS_REACT_19: boolean = + /* @__PURE__ */ React.version.startsWith('19') const REACT_ELEMENT_TYPE = /* @__PURE__ */ Symbol.for( IS_REACT_19 ? 'react.transitional.element' : 'react.element', @@ -29,8 +30,8 @@ const REACT_CLIENT_REFERENCE = /* @__PURE__ */ Symbol.for( 'react.client.reference', ) -export const ForwardRef = REACT_FORWARD_REF_TYPE -export const Memo = REACT_MEMO_TYPE +export const ForwardRef: symbol = REACT_FORWARD_REF_TYPE +export const Memo: symbol = REACT_MEMO_TYPE export function isValidElementType(type: any): type is ElementType { return typeof type === 'string' || diff --git a/src/utils/scheduler.ts b/src/utils/scheduler.ts new file mode 100644 index 000000000..f5c17f099 --- /dev/null +++ b/src/utils/scheduler.ts @@ -0,0 +1,122 @@ +// Host scheduling primitive, vendored from React's Scheduler so react-redux +// time-slices store notifications with the exact same, proven semantics. +// Source: react/packages/scheduler/src/forks/Scheduler.js (line refs below). +// +// We copy only what a notification pass needs — a macrotask scheduler plus a +// `shouldYield()` deadline — not the priority min-heap. +// +// DEVIATION FROM REACT (our improvement): React yields *early*, before the 5ms +// frame budget, when it knows a paint is pending (`needsPaint`, set by its own +// commit phase — a signal a library doesn't have). We reproduce that early +// yield for the one signal a library *does* have: `navigator.scheduling +// .isInputPending()`. An in-progress notification then releases the main thread +// the instant the user interacts, instead of holding it for the full frame. + +// Scheduler.js:11 — frameYieldMs +const frameInterval = 5 + +// After blocking this long we yield even while input keeps arriving, so a burst +// of input can't starve the notification indefinitely. +const maxYieldInterval = 300 + +// Scheduler.js:59-71 — getCurrentTime +let getCurrentTime: () => number +if (typeof performance === 'object' && typeof performance.now === 'function') { + const localPerformance = performance + getCurrentTime = () => localPerformance.now() +} else { + const localDate = Date + const initialTime = localDate.now() + getCurrentTime = () => localDate.now() - initialTime +} + +export { getCurrentTime } + +// navigator.scheduling.isInputPending — not in the DOM lib typings yet. +type Scheduling = { + isInputPending?: (options?: { includeContinuous?: boolean }) => boolean +} +const scheduling: Scheduling | undefined = + typeof navigator !== 'undefined' + ? (navigator as unknown as { scheduling?: Scheduling }).scheduling + : undefined +const isInputPending = + scheduling && typeof scheduling.isInputPending === 'function' + ? scheduling.isInputPending.bind(scheduling) + : null +// Include continuous input (pointermove, scroll, …) so we also stay responsive +// mid-gesture, not just on discrete clicks/keypresses. +const continuousOptions = { includeContinuous: true } + +let startTime = -1 +let needsPaint = false + +// Scheduler.js:474-479 — requestPaint. Exported for parity / host integration; +// a consumer that knows a paint is imminent can call it to force an early yield. +export function requestPaint(): void { + needsPaint = true +} + +// Scheduler.js:459-472 — shouldYieldToHost, extended with the isInputPending +// early-yield described above. +export function shouldYield(): boolean { + if (needsPaint) { + // A paint is pending. Yield now. (Scheduler.js:460-462) + return true + } + const timeElapsed = getCurrentTime() - startTime + if (timeElapsed < frameInterval) { + // Under one frame of work. React would keep going; we additionally yield + // early if the user is trying to interact, so we don't add input latency in + // the middle of a notification pass — while still honoring the max interval + // as a hard cap against input starving the pass. + if ( + isInputPending !== null && + timeElapsed < maxYieldInterval && + isInputPending(continuousOptions) + ) { + return true + } + // The main thread has only been blocked for a really short amount of time; + // smaller than a single frame. Don't yield yet. (Scheduler.js:465-468) + return false + } + // Yield now. (Scheduler.js:470-471) + return true +} + +// Macrotask transport. Scheduler.js:530-561 prefers setImmediate (Node), then a +// single reused MessageChannel (avoids setTimeout's 4ms clamp), then setTimeout. +// `startTime` is reset before each callback so `shouldYield()` measures the +// block length of *this* chunk (Scheduler.js:504-507). +let queue: Array<() => void> = [] +let channel: MessageChannel | undefined + +function flush() { + // Swap in a fresh queue so callbacks scheduled during this flush (the next + // chunk) run in the next task, not this one. + const pending = queue + queue = [] + for (let i = 0; i < pending.length; i++) { + // Non-null: `pending` is a dense array we own, only ever grown by `push`. + const callback = pending[i]! + needsPaint = false + startTime = getCurrentTime() + callback() + } +} + +export function scheduleCallback(callback: () => void): void { + queue.push(callback) + if (queue.length > 1) return + + if (typeof MessageChannel !== 'undefined') { + if (!channel) { + channel = new MessageChannel() + channel.port1.onmessage = flush + } + channel.port2.postMessage(null) + } else { + setTimeout(flush, 0) + } +} diff --git a/src/utils/shallowEqual.ts b/src/utils/shallowEqual.ts index e50c6be52..5a6aafed5 100644 --- a/src/utils/shallowEqual.ts +++ b/src/utils/shallowEqual.ts @@ -6,7 +6,7 @@ function is(x: unknown, y: unknown) { } } -export default function shallowEqual(objA: any, objB: any) { +export function shallowEqual(objA: any, objB: any): boolean { if (is(objA, objB)) return true if ( diff --git a/src/utils/useIsomorphicLayoutEffect.ts b/src/utils/useIsomorphicLayoutEffect.ts index 13fd332ec..101dd6dc5 100644 --- a/src/utils/useIsomorphicLayoutEffect.ts +++ b/src/utils/useIsomorphicLayoutEffect.ts @@ -36,5 +36,5 @@ const isReactNative = /* @__PURE__ */ isRunningInReactNative() const getUseIsomorphicLayoutEffect = () => isDOM || isReactNative ? React.useLayoutEffect : React.useEffect -export const useIsomorphicLayoutEffect = +export const useIsomorphicLayoutEffect: typeof React.useEffect = /* @__PURE__ */ getUseIsomorphicLayoutEffect() diff --git a/src/utils/useSyncExternalStore.ts b/src/utils/useSyncExternalStore.ts deleted file mode 100644 index b7f777391..000000000 --- a/src/utils/useSyncExternalStore.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { useSyncExternalStore } from 'use-sync-external-store' -import type { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector' - -export const notInitialized = () => { - throw new Error('uSES not initialized!') -} - -export type uSES = typeof useSyncExternalStore -export type uSESWS = typeof useSyncExternalStoreWithSelector diff --git a/src/utils/useSyncExternalStoreWithSelector.ts b/src/utils/useSyncExternalStoreWithSelector.ts new file mode 100644 index 000000000..4cd84a287 --- /dev/null +++ b/src/utils/useSyncExternalStoreWithSelector.ts @@ -0,0 +1,185 @@ +import { + useDebugValue, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from 'react' + +export function useSyncExternalStoreWithSelector( + subscribe: (onStoreChange: () => void) => () => void, + // subscribe: VoidFunction, + getSnapshot: () => Snapshot, + getServerSnapshot: void | null | (() => Snapshot), + selector: (snapshot: Snapshot) => Selection, + isEqual?: (a: Selection, b: Selection) => boolean, + // Called the first time `selector` reads a given top-level string key of the + // snapshot, i.e. once per key for this memoized selector instance. + onAccess?: (key: string) => void, +): Selection { + // Use this to track the rendered snapshot. + const instRef = useRef< + | { + hasValue: true + value: Selection + } + | { + hasValue: false + value: null + } + | null + >(null) + + let inst: any + + if (instRef.current === null) { + inst = { + hasValue: false, + value: null, + } + instRef.current = inst + } else { + inst = instRef.current + } + + const [getSelection, getServerSelection] = useMemo(() => { + // Track the memoized state using closure variables that are local to this + // memoized instance of a getSnapshot function. Intentionally not using a + // useRef hook, because that state would be shared across all concurrent + // copies of the hook/component. + let hasMemo = false + let memoizedSnapshot: Snapshot + let memoizedSelection: Selection + + let lastUsedProps: string[] | undefined = undefined + const accessedProps: string[] = [] + + const memoizedSelector = (nextSnapshot: Snapshot) => { + const getProxy = () => { + if (!(typeof nextSnapshot === 'object')) { + return nextSnapshot + } + + const handler = { + get: (target: object, prop: PropertyKey, receiver: any) => { + const propertyName = prop.toString() + + if (accessedProps.indexOf(propertyName) === -1) { + accessedProps.push(propertyName) + + if (onAccess !== undefined && typeof prop === 'string') { + onAccess(prop) + } + } + + const value = Reflect.get(target, prop, receiver) + + return value + }, + } + + return new Proxy(nextSnapshot as object, handler) as Snapshot + } + + if (!hasMemo) { + // The first time the hook is called, there is no memoized result. + hasMemo = true + memoizedSnapshot = nextSnapshot + const nextSelection = selector(getProxy()) + lastUsedProps = accessedProps + if (isEqual !== undefined) { + // Even if the selector has changed, the currently rendered selection + // may be equal to the new selection. We should attempt to reuse the + // current value if possible, to preserve downstream memoizations. + if (inst.hasValue) { + const currentSelection = inst.value + if (isEqual(currentSelection, nextSelection)) { + memoizedSelection = currentSelection + return currentSelection + } + } + } + memoizedSelection = nextSelection + return nextSelection + } + + // We may be able to reuse the previous invocation's result. + const prevSnapshot = memoizedSnapshot as Snapshot + const prevSelection = memoizedSelection as Selection + + const getChangedSegments = (): string[] | undefined => { + if ( + prevSnapshot === undefined || + lastUsedProps === undefined || + lastUsedProps.length === 0 + ) { + return undefined + } + + const result: string[] = [] + + for (const segmentName of lastUsedProps) { + if ( + (nextSnapshot as Record)[segmentName] !== + (prevSnapshot as Record)[segmentName] + ) { + result.push(segmentName) + } + } + + return result + } + + if (Object.is(prevSnapshot, nextSnapshot)) { + // The snapshot is the same as last time. Reuse the previous selection. + return prevSelection + } + + const changedSegments = getChangedSegments() + if (changedSegments === undefined || changedSegments.length > 0) { + const nextSelection = selector(getProxy()) + lastUsedProps = accessedProps + + if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) { + // The snapshot still has changed, so make sure to update to not keep + // old references alive + memoizedSnapshot = nextSnapshot + return prevSelection + } + + memoizedSnapshot = nextSnapshot + memoizedSelection = nextSelection + return nextSelection + } else { + return prevSelection + } + } + + // Assigning this to a constant so that Flow knows it can't change. + const maybeGetServerSnapshot = + getServerSnapshot === undefined ? null : getServerSnapshot + const getSnapshotWithSelector = () => memoizedSelector(getSnapshot()) + const getServerSnapshotWithSelector = + maybeGetServerSnapshot === null + ? undefined + : () => memoizedSelector(maybeGetServerSnapshot()) + return [getSnapshotWithSelector, getServerSnapshotWithSelector] + }, [getSnapshot, getServerSnapshot, selector, isEqual, onAccess]) + + const value = useSyncExternalStore( + subscribe, + getSelection, + getServerSelection, + ) + + useEffect(() => { + // $FlowFixMe[incompatible-type] changing the variant using mutation isn't supported + inst.hasValue = true + // $FlowFixMe[incompatible-type] + inst.value = value + }, [value]) + + useDebugValue(value) + + return value +} diff --git a/src/utils/verifyPlainObject.ts b/src/utils/verifyPlainObject.ts index e04a527e9..9a399a201 100644 --- a/src/utils/verifyPlainObject.ts +++ b/src/utils/verifyPlainObject.ts @@ -1,11 +1,11 @@ -import isPlainObject from './isPlainObject' -import warning from './warning' +import { isPlainObject } from './isPlainObject' +import { warning } from './warning' -export default function verifyPlainObject( +export function verifyPlainObject( value: unknown, displayName: string, methodName: string, -) { +): void { if (!isPlainObject(value)) { warning( `${methodName}() in ${displayName} must return a plain object. Instead received ${value}.`, diff --git a/src/utils/warning.ts b/src/utils/warning.ts index 92d3d638b..3a8f4bbb7 100644 --- a/src/utils/warning.ts +++ b/src/utils/warning.ts @@ -4,7 +4,7 @@ * @param {String} message The warning message. * @returns {void} */ -export default function warning(message: string) { +export function warning(message: string): void { /* eslint-disable no-console */ if (typeof console !== 'undefined' && typeof console.error === 'function') { console.error(message) diff --git a/test/hooks/useSelectorStateAccess.spec.tsx b/test/hooks/useSelectorStateAccess.spec.tsx new file mode 100644 index 000000000..e67866ad1 --- /dev/null +++ b/test/hooks/useSelectorStateAccess.spec.tsx @@ -0,0 +1,226 @@ +import * as rtl from '@testing-library/react' +import type { ReactNode } from 'react' +import React, { useContext } from 'react' +import type { + ProviderProps, + ReactReduxContextValue, + StateAccessListener, +} from 'react-redux' +import { Provider, ReactReduxContext, useSelector } from 'react-redux' +import type { Action, AnyAction, Store } from 'redux' +import { createStore } from 'redux' + +function ProviderMock
= AnyAction, S = unknown>( + props: ProviderProps, +) { + return ( + + ) +} + +type State = { a: number; b: number; c: number } + +const increment = () => ({ type: 'increment' }) + +describe('React', () => { + describe('hooks', () => { + describe('useSelector onStateAccess', () => { + let store: Store + + beforeEach(() => { + store = createStore( + (state: State = { a: 1, b: 1, c: 1 }, action: AnyAction): State => + action.type === 'increment' ? { ...state, a: state.a + 1 } : state, + ) + }) + + it('reports each top-level key once per subscriber', () => { + const onStateAccess = vi.fn() + const selectSum = (state: State) => state.a + state.b + + const Comp = () =>
{useSelector(selectSum)}
+ + rtl.render( + + + , + ) + + expect(onStateAccess.mock.calls).toEqual([ + ['a', selectSum], + ['b', selectSum], + ]) + + rtl.act(() => { + store.dispatch(increment()) + store.dispatch(increment()) + }) + + expect(onStateAccess).toHaveBeenCalledTimes(2) + }) + + it('reports a key the first time a later evaluation reads it', () => { + const onStateAccess = vi.fn() + const selectConditional = (state: State) => + state.a > 1 ? state.c : state.a + + const Comp = () =>
{useSelector(selectConditional)}
+ + rtl.render( + + + , + ) + + expect(onStateAccess.mock.calls.map(([key]) => key)).toEqual(['a']) + + rtl.act(() => { + store.dispatch(increment()) + }) + + expect(onStateAccess.mock.calls.map(([key]) => key)).toEqual(['a', 'c']) + }) + + it('reports separately for each subscribing component', () => { + const onStateAccess = vi.fn() + const selectA = (state: State) => state.a + + const Comp = () =>
{useSelector(selectA)}
+ + rtl.render( + + + + , + ) + + expect(onStateAccess.mock.calls).toEqual([ + ['a', selectA], + ['a', selectA], + ]) + }) + + it('does not report symbol keys', () => { + const onStateAccess = vi.fn() + const selectTag = (state: State) => + String((state as any)[Symbol.toStringTag]) + state.a + + const Comp = () =>
{useSelector(selectTag)}
+ + rtl.render( + + + , + ) + + expect(onStateAccess.mock.calls.map(([key]) => key)).toEqual(['a']) + }) + + it('lets a nested context override the listener for a subtree', () => { + const outer = vi.fn() + const inner = vi.fn() + const selectA = (state: State) => state.a + const selectB = (state: State) => state.b + + const Scope = ({ children }: { children: ReactNode }) => { + const parent = useContext(ReactReduxContext)! + const value = React.useMemo( + () => ({ ...parent, onStateAccess: inner }), + [parent], + ) + return ( + + {children} + + ) + } + + const Outside = () =>
{useSelector(selectA)}
+ const Inside = () =>
{useSelector(selectB)}
+ + rtl.render( + + + + + + , + ) + + expect(outer.mock.calls).toEqual([['a', selectA]]) + expect(inner.mock.calls).toEqual([['b', selectB]]) + }) + + it('does not re-run selectors when the listener identity changes', () => { + const selectA = vi.fn((state: State) => state.a) + let renders = 0 + + const Comp = React.memo(() => { + renders++ + return
{useSelector(selectA)}
+ }) + + const first = vi.fn() + const second = vi.fn() + + const { rerender } = rtl.render( + + + , + ) + + const selectorCalls = selectA.mock.calls.length + + rerender( + + + , + ) + + expect(renders).toBe(1) + expect(selectA).toHaveBeenCalledTimes(selectorCalls) + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + }) + + it('reports every key to a listener added after mount', () => { + const onStateAccess = vi.fn() + const selectSum = (state: State) => state.a + state.b + + const Comp = () =>
{useSelector(selectSum)}
+ + const { rerender } = rtl.render( + + + , + ) + + rerender( + + + , + ) + + expect(onStateAccess.mock.calls.map(([key]) => key)).toEqual(['a', 'b']) + }) + + it('marks the context value as supporting state access tracking', () => { + let contextValue: ReactReduxContextValue | null = null + + const Comp = () => { + contextValue = useContext(ReactReduxContext) + return null + } + + rtl.render( + + + , + ) + + expect(contextValue!.stateAccessTracking).toBe(true) + expect(contextValue!.onStateAccess).toBeUndefined() + }) + }) + }) +}) diff --git a/test/setup.ts b/test/setup.ts index a9d0dd31a..989983f42 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1 +1,26 @@ import '@testing-library/jest-dom/vitest' + +;(function mockMessageChannel() { + const MessageChannel = function () { + let onmessage: Function + return { + port1: { + set onmessage(cb: Function) { + onmessage = cb + }, + close: () => ({}), + }, + port2: { + postMessage: (data: unknown) => { + const stopPropagation = () => ({}) + + onmessage?.({ data, stopPropagation }) + }, + close: () => ({}), + }, + } + } + + global.MessageChannel = + MessageChannel as unknown as typeof global.MessageChannel +})() diff --git a/test/typetests/hooks.test-d.tsx b/test/typetests/hooks.test-d.tsx index 1b85895a2..bec8776f0 100644 --- a/test/typetests/hooks.test-d.tsx +++ b/test/typetests/hooks.test-d.tsx @@ -102,6 +102,29 @@ describe('type tests', () => { const selected2 = useAppSelector((state) => state.stateProp, shallowEqual) expectTypeOf(selected2).toBeString() + + // https://github.com/reduxjs/react-redux/issues/2186 + // The equality function must not be an inference site for `Selected`, + // otherwise `shallowEqual`'s `any` parameters widen the result to `any`. + + // Selector declared separately (rather than inline) with `withTypes`. + const selectStateProp = (state: TestState) => state.stateProp + + const selected3 = useAppSelector(selectStateProp, shallowEqual) + + expectTypeOf(selected3).toBeString() + + // Selector declared separately with plain `useSelector`. + const selected4 = useSelector(selectStateProp, shallowEqual) + + expectTypeOf(selected4).toBeString() + + // `shallowEqual` passed via the options object. + const selected5 = useAppSelector((state) => state.stateProp, { + equalityFn: shallowEqual, + }) + + expectTypeOf(selected5).toBeString() }) test('useDispatch', () => { diff --git a/test/utils/isPlainObject.spec.ts b/test/utils/isPlainObject.spec.ts index 11ed937e4..087bb5a15 100644 --- a/test/utils/isPlainObject.spec.ts +++ b/test/utils/isPlainObject.spec.ts @@ -1,4 +1,4 @@ -import isPlainObject from '@internal/utils/isPlainObject' +import { isPlainObject } from '@internal/utils/isPlainObject' import vm from 'node:vm' class Test {} describe('isPlainObject', () => { diff --git a/test/utils/listeners.spec.ts b/test/utils/listeners.spec.ts new file mode 100644 index 000000000..5e35a067f --- /dev/null +++ b/test/utils/listeners.spec.ts @@ -0,0 +1,150 @@ +import { createListenerCollection } from '../../src/utils/listeners' +import * as scheduler from '../../src/utils/scheduler' + +describe('listeners', () => { + test('should notify listeners in one cycle with the same state', () => { + const state = { a: 0 } + + // Run each scheduled chunk synchronously, bumping the "version" of the + // state per chunk so we can prove listeners in different chunks observe the + // state as it was when their chunk ran. + vi.spyOn(scheduler, 'scheduleCallback').mockImplementation((callback) => { + state.a = state.a + 1 + callback() + }) + // Yield after every listener → exactly one listener per chunk. + vi.spyOn(scheduler, 'shouldYield').mockReturnValue(true) + + const listeners = createListenerCollection() + + const fn1Result = vi.fn() + const fn2Result = vi.fn() + + const fn1 = vi.fn(() => fn1Result({ ...state })) + const fn2 = vi.fn(() => fn2Result({ ...state })) + + listeners.subscribe(fn1) + listeners.subscribe(fn2) + + listeners.notify() + + expect(fn1).toBeCalledTimes(1) + expect(fn1Result).toBeCalledWith({ a: 1 }) + expect(fn2).toBeCalledTimes(1) + expect(fn2Result).toBeCalledWith({ a: 2 }) + }) +}) + +// Notification is time-sliced, so a `notify()` can land while a previous pass +// is still draining. These tests pin down what must happen in that window; they +// fail against a per-`notify()` cursor, which spawns a second parallel drain. +describe('listeners re-entrancy', () => { + let scheduled: Array<() => void> + + const flushOne = () => { + scheduled.shift()?.() + } + + const flushAll = () => { + let guard = 0 + while (scheduled.length) { + if (++guard > 10_000) throw new Error('drain did not terminate') + flushOne() + } + } + + beforeEach(() => { + scheduled = [] + + // Fake scheduler: capture each chunk instead of running it, so the test can + // deterministically interleave a re-entrant notify() between chunks. + vi.spyOn(scheduler, 'scheduleCallback').mockImplementation((callback) => { + scheduled.push(callback) + }) + // Yield after every listener → exactly one listener per chunk. + vi.spyOn(scheduler, 'shouldYield').mockReturnValue(true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('does not spawn a second, parallel drain when notify() arrives mid-notification', () => { + const collection = createListenerCollection() + collection.subscribe(vi.fn()) + collection.subscribe(vi.fn()) + collection.subscribe(vi.fn()) + + collection.notify() + expect(scheduled).toHaveLength(1) // first chunk queued + + flushOne() // first listener notified; the drain is still in flight + expect(scheduled).toHaveLength(1) + + // A new notification arrives while the previous pass is still draining. + collection.notify() + + // The in-flight drain is reused — NO extra chunk is stacked on top of it. + // A per-notify() cursor queues a second, independent traversal here, making + // this length 2. + expect(scheduled).toHaveLength(1) + + flushAll() + expect(scheduled).toHaveLength(0) // terminates cleanly, no leaked chunk + }) + + test('finishes the current pass in order before re-notifying, without interleaving passes', () => { + const collection = createListenerCollection() + const order: string[] = [] + collection.subscribe(() => order.push('a')) + collection.subscribe(() => order.push('b')) + collection.subscribe(() => order.push('c')) + + collection.notify() + flushOne() // 'a' notified in pass 1 + + collection.notify() // state changed again mid-drain + flushAll() + + // One full pass completes in subscription order, then a single restart + // re-notifies everyone in order. A parallel drain interleaves the two passes + // (e.g. a,b,a,c,b,c), re-notifying a first-subscribed listener for the new + // state before the earlier pass has even reached the last one. + expect(order).toEqual(['a', 'b', 'c', 'a', 'b', 'c']) + }) + + test('coalesces multiple mid-drain notifications into a single restart', () => { + const collection = createListenerCollection() + const order: string[] = [] + collection.subscribe(() => order.push('a')) + collection.subscribe(() => order.push('b')) + + collection.notify() + flushOne() // 'a' notified + + // A burst of notifications arrives during the same drain. + collection.notify() + collection.notify() + collection.notify() + + flushAll() + + // Exactly one extra pass, not one per notification. + expect(order).toEqual(['a', 'b', 'a', 'b']) + }) + + test('always notifies at least one listener per chunk, even when shouldYield() is immediately true', () => { + // Pending input makes shouldYield() true from the very first check. The pass + // must still make progress (one listener per chunk) rather than reschedule + // forever without notifying anyone. + const collection = createListenerCollection() + const order: string[] = [] + collection.subscribe(() => order.push('a')) + collection.subscribe(() => order.push('b')) + + collection.notify() + flushAll() + + expect(order).toEqual(['a', 'b']) + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json index a2f8438b7..0173e694c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,6 +2,10 @@ // For building the library. "extends": "./tsconfig.base.json", "compilerOptions": { + // Lets the declarations be emitted with Oxc instead of tsc, which the + // TypeScript 7 toolchain requires. Scoped to the build config so the + // annotations are only required of `src`, not of tests. + "isolatedDeclarations": true, "emitDeclarationOnly": true, "outDir": "dist", "rootDir": "./src" diff --git a/tsconfig.json b/tsconfig.json index 10777726b..6b4f7d634 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,7 @@ "rootDir": "." }, "include": ["."], - "exclude": ["dist*", "examples", "docs"] + // The benchmark scripts are plain `.mjs`; with `allowJs`/`checkJs` they would + // otherwise be pulled into type-checking. + "exclude": ["dist*", "examples", "docs", "benchmark"] }