Skip to content

Commit cb25623

Browse files
KyleAMathewsclaude
andauthored
feat: Add paced mutations with timing strategies (#704)
* feat: add useSerializedMutations hook with timing strategies Implements a new hook for managing optimistic mutations with pluggable timing strategies (debounce, queue, throttle) using TanStack Pacer. Key features: - Auto-merge mutations and serialize persistence according to strategy - Track and rollback superseded pending transactions to prevent memory leaks - Proper cleanup of pending/executing transactions on unmount - Queue strategy uses AsyncQueuer for true sequential processing Breaking changes from initial design: - Renamed from useSerializedTransaction to useSerializedMutations (more accurate name) - Each mutate() call creates mutations that are auto-merged, not separate transactions Addresses feedback: - HIGH: Rollback superseded transactions to prevent orphaned isPersisted promises - HIGH: cleanup() now properly rolls back all pending/executing transactions - HIGH: Queue strategy properly serializes commits using AsyncQueuer with concurrency: 1 Example usage: ```tsx const mutate = useSerializedMutations({ mutationFn: async ({ transaction }) => { await api.save(transaction.mutations) }, strategy: debounceStrategy({ wait: 500 }) }) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Fix feedback-4 issues and add interactive demo Fixes for feedback-4 issues: - Queue strategy: await isPersisted.promise instead of calling commit() again to fix double-commit error - cleanup(): check transaction state before rollback to prevent errors on completed transactions - Pending transactions: rollback all pending transactions on each new mutate() call to handle dropped callbacks Added interactive serialized mutations demo: - Visual tracking of transaction states (pending/executing/completed/failed) - Live configuration of debounce/queue/throttle strategies - Real-time stats dashboard showing transaction counts - Transaction timeline with mutation details and durations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: serialized mutations strategy execution and transaction handling Core fixes: - Save transaction reference before calling strategy.execute() to prevent null returns when strategies (like queue) execute callbacks synchronously - Call strategy.execute() on every mutate() call to properly reset debounce timers - Simplified transaction lifecycle - single active transaction that gets reused for batching Demo improvements: - Memoized strategy and mutationFn to prevent unnecessary recreations - Added fake server sync to demonstrate optimistic updates - Enhanced UI to show optimistic vs synced state and detailed timing - Added mitt for event-based server communication Tests: - Replaced comprehensive test suite with focused debounce strategy tests - Two tests demonstrating batching and timer reset behavior - Tests pass with real timers and validate mutation auto-merging 🤖 Generated with [Claude Code](https://claude.com/claude-code) * prettier * test: add comprehensive tests for queue and throttle strategies Added test coverage for all three mutation strategies: - Debounce: batching and timer reset (already passing) - Queue: accumulation and sequential processing - Throttle: leading/trailing edge execution All 5 tests passing with 100% coverage on useSerializedMutations hook. Also added changeset documenting the new serialized mutations feature. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: resolve TypeScript strict mode errors in useSerializedMutations tests Added non-null assertions and proper type casting for test variables to satisfy TypeScript's strict null checking. All 62 tests still passing with 100% coverage on useSerializedMutations hook. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * refactor: convert demo to slider-based interface with 300ms default Changed from button-based mutations to a slider interface that better demonstrates the different strategies in action: - Changed Item.value from string to number (was already being used as number) - Reduced default wait time from 1000ms to 300ms for more responsive demo - Replaced "Trigger Mutation" and "Trigger 5 Rapid Mutations" buttons with a slider (0-100 range) that triggers mutations on every change - Updated UI text to reference slider instead of buttons - Changed mutation display from "value X-1 → X" to "value = X" since slider sets absolute values rather than incrementing The slider provides a more natural and vivid demonstration of how strategies handle rapid mutations - users can drag it and see debounce wait for stops, throttle sample during drags, and queue process all changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix(demo): improve UI and fix slider reset issue - Use mutation.modified instead of mutation.changes for updates to preserve full state - Remove Delta stat card as it wasn't providing value - Show newest transactions first in timeline for better UX 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix(queue): capture transaction before clearing activeTransaction Queue strategy now receives a closure that commits the captured transaction instead of calling commitCallback which expects activeTransaction to be set. This prevents "no active transaction exists" errors. - Capture transaction before clearing activeTransaction for queue strategy - Pass commit closure to queue that operates on captured transaction - Remove "Reset to 0" button from demo - All tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix(queue): explicitly default to FIFO processing order Set explicit defaults for addItemsTo='back' and getItemsFrom='front' to ensure queue strategy processes transactions in FIFO order (oldest first). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * docs: clarify queue strategy creates separate transactions with configurable order Update changeset to reflect that queue strategy creates separate transactions per mutation and defaults to FIFO (but is configurable). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * refactor: rename "Serialized Mutations" to "Paced Mutations" Rename the feature from "Serialized Mutations" to "Paced Mutations" to better reflect its purpose of controlling mutation timing rather than serialization. This includes: - Renamed core functions: createSerializedMutations → createPacedMutations - Renamed React hook: useSerializedMutations → usePacedMutations - Renamed types: SerializedMutationsConfig → PacedMutationsConfig - Updated all file names, imports, exports, and documentation - Updated demo app title and examples - Updated changeset All tests pass and the demo app builds successfully. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * update lock * chore: change paced mutations changeset from minor to patch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: update remaining references to useSerializedMutations Update todo example and queueStrategy JSDoc to use usePacedMutations instead of useSerializedMutations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * docs: mention TanStack Pacer in changeset Add reference to TanStack Pacer which powers the paced mutations strategies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * docs: clarify key design difference between strategies Make it crystal clear that debounce/throttle only allow one pending tx (collecting mutations) and one persisting tx at a time, while queue guarantees each mutation becomes a separate tx processed in order. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * docs: add comprehensive Paced Mutations guide Add new "Paced Mutations" section to mutations.md covering: - Introduction to paced mutations and TanStack Pacer - Key design differences (debounce/throttle vs queue) - Detailed examples for each strategy (debounce, throttle, queue) - Guidance on choosing the right strategy - React hook usage with usePacedMutations - Non-React usage with createPacedMutations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: remove id property from PacedMutationsConfig The id property doesn't make sense for paced mutations because: - Queue strategy creates separate transactions per mutate() call - Debounce/throttle create multiple transactions over time - Users shouldn't control internal transaction IDs Changed PacedMutationsConfig to explicitly define only the properties that make sense (mutationFn, strategy, metadata) instead of extending TransactionConfig. This prevents TypeScript from accepting invalid configuration like: usePacedMutations({ id: 'foo', ... }) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: prevent unnecessary recreation of paced mutations instance Fixed issue where wrapping usePacedMutations in another hook would recreate the instance on every render when passing strategy inline: Before (broken): usePacedMutations({ strategy: debounceStrategy({ wait: 3000 }) }) // Recreates instance every render because strategy object changes After (fixed): // Serializes strategy type + options for stable comparison // Only recreates when actual values change Now uses JSON.stringify to create a stable dependency from the strategy's type and options, so the instance is only recreated when the strategy configuration actually changes, not when the object reference changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * test: add memoization tests for usePacedMutations Add comprehensive tests to verify that usePacedMutations doesn't recreate the instance unnecessarily when wrapped in custom hooks. Tests cover: 1. Basic memoization - instance stays same when strategy values are same 2. User's exact scenario - custom hook with inline strategy creation 3. Proper recreation - instance changes when strategy options change These tests verify the fix for the bug where wrapping usePacedMutations in a custom hook with inline strategy would recreate the instance on every render. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: stabilize mutationFn to prevent recreating paced mutations instance Wrap the user-provided mutationFn in a stable callback using useRef, so that even if the mutationFn reference changes on each render, the paced mutations instance is not recreated. This fixes the bug where: 1. User types "123" in a textarea 2. Each keystroke recreates the instance (new mutationFn on each render) 3. Each call to mutate() gets a different transaction ID 4. Old transactions with stale data (e.g. "12") are still pending 5. When they complete, they overwrite the correct "123" value Now the mutationFn identity is stable, so the same paced mutations instance is reused across renders, and all mutations during the debounce window batch into the same transaction. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Refactor paced mutations to work like createOptimisticAction Modified the paced mutations API to follow the same pattern as createOptimisticAction, where the hook takes an onMutate callback and you pass the actual update variables directly to the mutate function. Changes: - Updated PacedMutationsConfig to accept onMutate callback - Modified createPacedMutations to accept variables instead of callback - Updated usePacedMutations hook to handle the new API - Fixed all tests to use the new API with onMutate - Updated documentation and examples to reflect the new pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Update paced mutations demo to use new onMutate API Modified the example to use the new variables-based API where you pass the value directly to mutate() and provide an onMutate callback for optimistic updates. This aligns with the createOptimisticAction pattern. Changes: - Removed useCallback wrappers (hook handles stabilization internally) - Pass newValue directly to mutate() instead of a callback - Simplified code since hook manages ref stability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent d2b569c commit cb25623

30 files changed

+2954
-32
lines changed

.changeset/paced-mutations.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@tanstack/db": patch
3+
"@tanstack/react-db": patch
4+
---
5+
6+
Add paced mutations with pluggable timing strategies
7+
8+
Introduces a new paced mutations system that enables optimistic mutations with pluggable timing strategies. This provides fine-grained control over when and how mutations are persisted to the backend. Powered by [TanStack Pacer](https://github.com/TanStack/pacer).
9+
10+
**Key Design:**
11+
12+
- **Debounce/Throttle**: Only one pending transaction (collecting mutations) and one persisting transaction (writing to backend) at a time. Multiple rapid mutations automatically merge together.
13+
- **Queue**: Each mutation creates a separate transaction, guaranteed to run in the order they're made (FIFO by default, configurable to LIFO).
14+
15+
**Core Features:**
16+
17+
- **Pluggable Strategy System**: Choose from debounce, queue, or throttle strategies to control mutation timing
18+
- **Auto-merging Mutations**: Multiple rapid mutations on the same item automatically merge for efficiency (debounce/throttle only)
19+
- **Transaction Management**: Full transaction lifecycle tracking (pending → persisting → completed/failed)
20+
- **React Hook**: `usePacedMutations` for easy integration in React applications
21+
22+
**Available Strategies:**
23+
24+
- `debounceStrategy`: Wait for inactivity before persisting. Only final state is saved. (ideal for auto-save, search-as-you-type)
25+
- `queueStrategy`: Each mutation becomes a separate transaction, processed sequentially in order (defaults to FIFO, configurable to LIFO). All mutations are guaranteed to persist. (ideal for sequential workflows, rate-limited APIs)
26+
- `throttleStrategy`: Ensure minimum spacing between executions. Mutations between executions are merged. (ideal for analytics, progress updates)
27+
28+
**Example Usage:**
29+
30+
```ts
31+
import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"
32+
33+
const mutate = usePacedMutations({
34+
mutationFn: async ({ transaction }) => {
35+
await api.save(transaction.mutations)
36+
},
37+
strategy: debounceStrategy({ wait: 500 }),
38+
})
39+
40+
// Trigger a mutation
41+
const tx = mutate(() => {
42+
collection.update(id, (draft) => {
43+
draft.value = newValue
44+
})
45+
})
46+
47+
// Optionally await persistence
48+
await tx.isPersisted.promise
49+
```

0 commit comments

Comments
 (0)