Skip to content

feat(offline-transactions): confirm writes off the serial drain path (confirmWrite hook)#1603

Open
TomasGonzalez wants to merge 3 commits into
TanStack:mainfrom
TomasGonzalez:feat/offline-transactions-confirm-write
Open

feat(offline-transactions): confirm writes off the serial drain path (confirmWrite hook)#1603
TomasGonzalez wants to merge 3 commits into
TanStack:mainfrom
TomasGonzalez:feat/offline-transactions-confirm-write

Conversation

@TomasGonzalez

@TomasGonzalez TomasGonzalez commented Jun 19, 2026

Copy link
Copy Markdown

Closes #1602

🎯 Changes

Adds an opt-in OfflineConfig.confirmWrite hook to @tanstack/offline-transactions that keeps a just-committed write's optimistic state painted across the post-commit confirmation window — running off the serial drain path.

Motivation

The executor drains the outbox serially (one mutationFn at a time), which is what preserves create-then-update / FK ordering. But there's no built-in way to keep a row painted between "the server committed the write" and "the sync stream echoed it back". The only option today is to await that confirmation inside the mutationFn — e.g.:

mutationFn: async ({ transaction }) => {
  const { txid } = await postToServer(transaction)
  await collection.utils.awaitTxId(txid) // ⛔ runs ON the serial path
}

Because the drain is serial, that await blocks the next write. With an ElectricSQL shape stream whose awaitTxId budget is ~10s, throughput collapses to ~1 write / 10s and each settled write tends to spawn a duplicate "already-applied" resend. The alternative — dropping the await — makes rows flicker (disappear then reappear) in the commit→sync gap, because the executor drops the transaction's optimistic overlay the instant mutationFn resolves.

What this adds

confirmWrite runs after the write commits and its outbox entry is removed, but off the serial path — it never blocks the next mutationFn:

startOfflineExecutor({
  // ...
  confirmWrite: async ({ mutations, result }) => {
    // result is whatever your mutationFn returned (e.g. a server txid)
    await Promise.all(
      collectionsOf(mutations).map((c) => c.utils.awaitTxId(result.txid)),
    )
  },
})

While the returned promise is pending, the library keeps the committed mutations' optimistic overlay painted via an internal hold transaction — the same primitive restoreOptimisticState already uses to re-show pending writes after a reload — then releases it when the hook settles. The serial chain still serializes the POSTs (ordering preserved); only the confirmation moved off it.

Design points:

  • Never rolls back. The write is already durably committed, so a rejected/timed-out confirmWrite just releases the overlay early (a possible brief flicker), never data loss. Any timeout / verify-by-state logic lives inside the hook.
  • Never throws into the drain. Hold creation and the hook are fully guarded; a throw can't make the executor retry an already-committed write.
  • No flicker. The hold is registered synchronously, before resolveTransaction drops the original overlay, so the overlay is owned continuously.
  • Bounded. maxConfirmationHolds (default 1000) caps concurrent holds to avoid O(n²) optimistic-recompute churn on a large, fast drain; beyond the cap the hold is skipped (degrades to today's behavior). getActiveConfirmationHoldCount() exposes the live count.
  • Released on teardown. Holds are dropped on clear() and dispose().

Internals: the hold create/release pattern is factored into a new executor/OptimisticHold.ts and reused by restoreOptimisticState, so there's a single primitive. The hook is fully opt-in — with no confirmWrite configured, behavior is byte-for-byte unchanged.

Drive-by fix

runMutationFn previously awaited the mutationFn and discarded its return value, so waitForTransactionCompletion always resolved undefined. The result is now threaded through to the completion promise and to confirmWrite (so the hook can read e.g. a server txid). One existing e2e assertion was updated to reflect this.

Context

This generalizes a pattern we shipped app-side at Autarc (an external offlineConfirmationHolds.ts that reaches into collection._state and re-implements the library's private hold/teardown). With this hook, that ~360-line module collapses to the small confirmWrite callback above. Happy to bikeshed the name (confirmWrite / awaitSync / confirmTransaction) and the default cap.

✅ Checklist

  • I have tested this code locally with pnpm test. (@tanstack/offline-transactions: 71 passing + 6 new in tests/confirm-write.test.ts, 1 pre-existing skip; full suite green. eslint src and prettier --check clean. tsc introduces zero new errors vs main.)

🚀 Release Impact

  • This change affects published code, and I have generated a changeset (.changeset/confirm-write-hook.md, minor).
  • This change is docs/CI/dev-only (no release).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added optional confirmWrite post-commit hook to control when optimistic overlays are released.
    • Introduced maxConfirmationHolds to cap concurrent confirmation overlays.
    • Added getActiveConfirmationHoldCount() for visibility into active confirmation holds.
    • Confirmation handling was decoupled from the serial outbox drain to improve write throughput; optimistic overlays persist through the confirmation window.
  • Tests

    • Added/expanded Vitest coverage for confirmWrite, hold limits, clear/dispose behavior, and non-blocking confirmations.
  • Documentation

    • Updated README and skill docs to describe the new post-commit confirmation flow and configuration options.

Add an opt-in `OfflineConfig.confirmWrite` hook that holds a just-committed
write's optimistic state through the post-commit confirmation window, off the
serial drain path, so waiting for an async sync stream (e.g. Electric's
awaitTxId) no longer throttles drain throughput and no longer flickers rows.

- Factor the hold create/release primitive into executor/OptimisticHold.ts and
  reuse it in restoreOptimisticState.
- Add maxConfirmationHolds cap + getActiveConfirmationHoldCount() diagnostic;
  release holds on clear()/dispose().
- Thread the mutationFn result through to the completion promise and the hook
  (previously awaited and discarded).
- Fully opt-in: with no confirmWrite, behavior is unchanged.

Closes TanStack#1602

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an opt-in OfflineConfig.confirmWrite hook that preserves optimistic overlays after commit without blocking serial outbox draining. Confirmation holds, lifecycle cleanup, hold caps, diagnostics, mutation-result propagation, tests, and documentation are included.

Changes

confirmWrite hook and optimistic confirmation lifecycle

Layer / File(s) Summary
Confirmation contracts and optimistic hold primitive
packages/offline-transactions/src/types.ts, packages/offline-transactions/src/executor/OptimisticHold.ts, packages/offline-transactions/src/api/OfflineTransaction.ts
Adds confirmation configuration/context types and implements failure-atomic optimistic hold creation and release.
ConfirmationManager scheduling and hold limits
packages/offline-transactions/src/executor/ConfirmationManager.ts
Runs confirmWrite asynchronously, caps active holds, releases holds after settlement, and handles disposal.
Executor confirmation and restoration lifecycle
packages/offline-transactions/src/OfflineExecutor.ts, packages/offline-transactions/src/executor/TransactionExecutor.ts, packages/offline-transactions/src/index.ts, packages/offline-transactions/src/react-native/index.ts
Threads mutation results into confirmation, wires leader and non-leader flows, reuses holds for restoration, exposes hold diagnostics, and cleans up holds during clear/dispose.
Confirmation behavior validation and documentation
packages/offline-transactions/tests/*, packages/offline-transactions/README.md, packages/offline-transactions/skills/offline/SKILL.md, .changeset/confirm-write-hook.md
Tests pending, rejected, capped, non-leader, clearing, and disposal behavior; documents the API and records the minor changeset.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: samwillis, kevin-dp

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly names the main offline-transactions change: adding a confirmWrite hook to move confirmation off the serial drain path.
Linked Issues check ✅ Passed The changes implement the opt-in confirmWrite hook, optimistic holds, max hold cap, cleanup, and active-hold diagnostics required by #1602.
Out of Scope Changes check ✅ Passed The added docs, tests, re-exports, and helper refactors all support the confirmWrite feature and do not appear unrelated to the issue.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Tools execution failed with the following error:

Failed to run tools: 13 INTERNAL: Received RST_STREAM with code 2 (Internal server error)


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/offline-transactions/src/types.ts (1)

103-103: ⚡ Quick win

Use unknown for ConfirmWriteContext.metadata instead of any.

Line 103 introduces a new public API field typed as Record<string, any>, which drops type safety at the boundary and propagates unchecked values downstream.

Suggested patch
-  metadata?: Record<string, any>
+  metadata?: Record<string, unknown>

As per coding guidelines, "Avoid using any types; use unknown instead when the type is truly unknown."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/offline-transactions/src/types.ts` at line 103, Replace the `any`
type with `unknown` in the ConfirmWriteContext.metadata field definition. Change
the metadata field from Record<string, any> to Record<string, unknown> to
maintain type safety at the public API boundary and align with coding guidelines
that avoid using any types.

Source: Coding guidelines

packages/offline-transactions/tests/confirm-write.test.ts (1)

149-171: ⚡ Quick win

Add a regression assertion that confirmWrite still executes when hold creation is skipped.

This test currently validates only the hold count/overlay behavior. It should also assert that the hook is invoked under maxConfirmationHolds: 0, so a contract regression (hook suppressed) is caught.

Suggested patch
   it(`skips the hold past maxConfirmationHolds (O(n^2) safety valve)`, async () => {
     const gate = deferred()
+    let confirmCalls = 0
     const config: Partial<OfflineConfig> = {
-      confirmWrite: () => gate.promise,
+      confirmWrite: () => {
+        confirmCalls += 1
+        return gate.promise
+      },
       maxConfirmationHolds: 0,
     }
@@
     expect(env.executor.getActiveConfirmationHoldCount()).toBe(0)
     expect(env.collection.get(`item-1`)).toBeUndefined()
+    expect(confirmCalls).toBe(1)

As per coding guidelines, "**/*.test.{ts,tsx,js}: Always add unit tests that reproduce a bug before fixing it to ensure the bug is fixed and prevent regression."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/offline-transactions/tests/confirm-write.test.ts` around lines 149 -
171, The test currently only validates that no hold is created when
maxConfirmationHolds is 0, but does not verify that the confirmWrite hook is
still invoked. Add a spy or flag to track whether the confirmWrite callback
executes, then add an assertion before gate.resolve() to verify that
confirmWrite was indeed called despite the hold being skipped. This ensures the
hook contract is maintained even when hold creation is bypassed due to the
maxConfirmationHolds cap.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/offline-transactions/src/executor/TransactionExecutor.ts`:
- Around line 200-219: The confirmWrite callback is being silently skipped due
to early returns at lines 202 and 213 when holds are capped or hold creation
fails, which violates the post-commit hook contract. Restructure the logic in
the method body to ensure confirmWrite is always invoked, regardless of hold
availability. Additionally, the maxConfirmationHolds assignment does not
normalize NaN or negative config values, which can unexpectedly bypass or
disable the cap. Add validation to ensure maxConfirmationHolds is a positive
integer, using something like Math.max to ensure proper normalization before the
size check is performed.

---

Nitpick comments:
In `@packages/offline-transactions/src/types.ts`:
- Line 103: Replace the `any` type with `unknown` in the
ConfirmWriteContext.metadata field definition. Change the metadata field from
Record<string, any> to Record<string, unknown> to maintain type safety at the
public API boundary and align with coding guidelines that avoid using any types.

In `@packages/offline-transactions/tests/confirm-write.test.ts`:
- Around line 149-171: The test currently only validates that no hold is created
when maxConfirmationHolds is 0, but does not verify that the confirmWrite hook
is still invoked. Add a spy or flag to track whether the confirmWrite callback
executes, then add an assertion before gate.resolve() to verify that
confirmWrite was indeed called despite the hold being skipped. This ensures the
hook contract is maintained even when hold creation is bypassed due to the
maxConfirmationHolds cap.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a618729c-330c-4d17-ba1b-7fa34d594a50

📥 Commits

Reviewing files that changed from the base of the PR and between 9a5e8e5 and 696ba15.

📒 Files selected for processing (7)
  • .changeset/confirm-write-hook.md
  • packages/offline-transactions/src/OfflineExecutor.ts
  • packages/offline-transactions/src/executor/OptimisticHold.ts
  • packages/offline-transactions/src/executor/TransactionExecutor.ts
  • packages/offline-transactions/src/types.ts
  • packages/offline-transactions/tests/confirm-write.test.ts
  • packages/offline-transactions/tests/offline-e2e.test.ts

Comment thread packages/offline-transactions/src/executor/TransactionExecutor.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/offline-transactions/src/executor/OptimisticHold.ts`:
- Around line 90-119: Replace the direct use of private collection internals in
the optimistic transaction setup with a supported public API or hook exposed by
`@tanstack/db`. Update the logic around mutation collections and the cleanup path
in OptimisticHold to call that registration/recompute hook, preserving
deduplication and failure-atomic rollback without accessing _state.transactions
or recomputeOptimisticState().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cd1d025e-8254-4ce3-b1c4-236c66954fec

📥 Commits

Reviewing files that changed from the base of the PR and between 696ba15 and 3f1da12.

📒 Files selected for processing (13)
  • .changeset/confirm-write-hook.md
  • packages/offline-transactions/README.md
  • packages/offline-transactions/skills/offline/SKILL.md
  • packages/offline-transactions/src/OfflineExecutor.ts
  • packages/offline-transactions/src/api/OfflineTransaction.ts
  • packages/offline-transactions/src/executor/ConfirmationManager.ts
  • packages/offline-transactions/src/executor/OptimisticHold.ts
  • packages/offline-transactions/src/executor/TransactionExecutor.ts
  • packages/offline-transactions/src/index.ts
  • packages/offline-transactions/src/react-native/index.ts
  • packages/offline-transactions/src/types.ts
  • packages/offline-transactions/tests/confirm-write.test.ts
  • packages/offline-transactions/tests/optimistic-hold.test.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/offline-transactions/src/react-native/index.ts
  • packages/offline-transactions/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • .changeset/confirm-write-hook.md
  • packages/offline-transactions/src/types.ts

Comment on lines +90 to +119
try {
transaction.applyMutations(mutations)

for (const mutation of mutations) {
// Defensive check for corrupted deserialized data
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!mutation.collection) {
continue
}
if (touchedCollections.has(mutation.collection)) {
continue
}
// Track before registration so a throwing set/recompute is unwound too.
touchedCollections.add(mutation.collection)
mutation.collection._state.transactions.set(transaction.id, transaction)
// `recomputeOptimisticState(true)` forces the recompute through even when
// a sync commit is in flight (the "triggered by user action" path), so the
// overlay always applies.
mutation.collection._state.recomputeOptimisticState(true)
}
} catch (error) {
// Failure-atomic creation: remove any registrations installed before the
// throw and complete the synthetic transaction so no global state leaks.
try {
release()
} catch {
// Preserve the original registration error after best-effort cleanup.
}
throw error
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm _state.transactions / recomputeOptimisticState are exposed & used elsewhere in-repo
rg -nP '_state\.(transactions|recomputeOptimisticState)' --type=ts
# Inspect the Collection state definition to confirm these members exist
fd -e ts . packages/db/src --exec rg -nl 'recomputeOptimisticState' {}

Repository: TanStack/db

Length of output: 1974


🌐 Web query:

"@tanstack/db" recomputeOptimisticState _state.transactions

💡 Result:

In @tanstack/db, the recomputeOptimisticState method is a core private function within the CollectionStateManager (or CollectionImpl) responsible for recalculating the application's optimistic state based on active transactions, pending sync operations, and local mutations [1][2]. It manages the reconciliation between synced data, optimistic upserts, and optimistic deletes to ensure the UI remains consistent while background syncs are in progress [3][4][5]. Key responsibilities and behaviors of recomputeOptimisticState include: 1. Optimistic State Reconciliation: It clears existing optimistic upserts, deletes, and local changes, then reseeds them based on active/pending transactions [5]. It uses sets like pendingOptimisticDirectUpserts and pendingOptimisticDirectDeletes to maintain the visibility of optimistic changes during the gap between transaction completion and server confirmation [3][4]. 2. User Action Optimization: The method accepts a triggeredByUserAction boolean parameter. When true, it bypasses certain complex event filtering and batching logic to prevent UI blocking, ensuring that actions initiated directly by the user are reflected immediately [1]. 3. Acknowledgment Tracking: It manages the $acknowledged state of rows by tying it to the covering (last-applied) active optimistic mutation [6]. Recent changes ensure that recomputeOptimisticState snapshots previous acknowledgment states before rebuilding, which keeps the $acknowledged status accurate during rollbacks or overwrites [7]. 4. Addressing State Leaks: The logic has undergone various refinements to prevent state "leaks" (where keys persist indefinitely in optimistic sets), particularly when a server confirms a mutation under a different key than the client's optimistic key [3][4]. These fixes often involve using guards (like add-once mechanisms) and tracking transactions that performed immediate sync writes to ensure optimistic state is correctly cleaned up [4]. If you are encountering errors related to this method, ensure your version of @tanstack/db is up to date, as many issues involving state consistency, cleanup, and transaction handling have been addressed in recent releases [4][8].

Citations:


🌐 Web query:

site:github.com/tanstack/db recomputeOptimisticState _state.transactions

💡 Result:

The function recomputeOptimisticState is a method found within TanStack DB, specifically in the context of handling offline transactions [1]. It is used to recalculate the application state following optimistic updates [1][2]. When _state.transactions is modified—for example, when a transaction is deleted or completed—the system calls recomputeOptimisticState to ensure the local state accurately reflects the pending changes and any subsequent operations [1]. This mechanism allows TanStack DB to provide instant user interface feedback by processing writes locally before they are confirmed by the server [2]. By managing the transaction queue and recomputing the resulting state, the library maintains consistency between the server-side source of truth and the client-side optimistic UI [1][2].

Citations:


Avoid depending on @tanstack/db internals here. collection._state.transactions and recomputeOptimisticState() are private implementation details, so an @tanstack/db upgrade can break optimistic overlays without a public API change. Expose a supported hook for transaction registration/recompute instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/offline-transactions/src/executor/OptimisticHold.ts` around lines 90
- 119, Replace the direct use of private collection internals in the optimistic
transaction setup with a supported public API or hook exposed by `@tanstack/db`.
Update the logic around mutation collections and the cleanup path in
OptimisticHold to call that registration/recompute hook, preserving
deduplication and failure-atomic rollback without accessing _state.transactions
or recomputeOptimisticState().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

offline-transactions: confirm writes off the serial drain (hold optimistic state through the post-commit sync window)

1 participant