Skip to content

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

Open
TomasGonzalez wants to merge 1 commit 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 1 commit 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

    • Optional confirmWrite hook for post-commit confirmation workflows
    • New maxConfirmationHolds configuration to limit concurrent confirmation overlays
    • Diagnostic method getActiveConfirmationHoldCount() for monitoring active holds
    • Optimistic overlays now persist during confirmation window
    • Confirmation processing moved off serial drain path for improved throughput
  • Tests

    • Added comprehensive test suite for confirmation write functionality

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 to @tanstack/offline-transactions that keeps optimistic UI overlays alive off the serial drain path during the post-commit sync window. A new OptimisticHold primitive in OptimisticHold.ts backs both this hook and a refactored restoreOptimisticState. A maxConfirmationHolds cap and getActiveConfirmationHoldCount() diagnostic are introduced. The mutationFn return value is now propagated to confirmWrite.

Changes

confirmWrite hook: contracts, primitives, executor wiring, and tests

Layer / File(s) Summary
Public API contracts: ConfirmWriteContext, OfflineConfig, OptimisticHold interface
packages/offline-transactions/src/types.ts, packages/offline-transactions/src/executor/OptimisticHold.ts
Adds ConfirmWriteContext interface; extends OfflineConfig with confirmWrite and maxConfirmationHolds; declares the OptimisticHold interface with an idempotent release API.
OptimisticHold primitive: createOptimisticHold
packages/offline-transactions/src/executor/OptimisticHold.ts
Implements createOptimisticHold: creates a non-auto-committing transaction, applies mutations, registers it on each touched collection's _state, forces optimistic recomputation, and returns an idempotent release that rolls back or marks the hold completed and recomputes.
TransactionExecutor: confirmation-hold lifecycle and restoreOptimisticState refactor
packages/offline-transactions/src/executor/TransactionExecutor.ts
Adds confirmationHolds set and DEFAULT_MAX_CONFIRMATION_HOLDS cap; updates runMutationFn to return the mutation result; routes post-success path through resolveWithOptionalConfirmation; implements createConfirmationHold, runConfirmation, and public releaseConfirmationHolds/getActiveConfirmationHoldCount; refactors restoreOptimisticState to use createOptimisticHold; extends clear() to release all active holds.
OfflineExecutor: public diagnostics and dispose cleanup
packages/offline-transactions/src/OfflineExecutor.ts
Adds getActiveConfirmationHoldCount() delegating to TransactionExecutor, and updates dispose() to call releaseConfirmationHolds() before teardown.
Tests for confirmWrite and changeset
packages/offline-transactions/tests/confirm-write.test.ts, packages/offline-transactions/tests/offline-e2e.test.ts, .changeset/confirm-write-hook.md
Adds a full Vitest suite for confirmWrite (hold-while-pending, non-blocking drain, rejection releases hold, absent hook drops immediately, maxConfirmationHolds: 0 cap, dispose cleanup); updates e2e test to assert mutationFn return value propagates through waitForTransactionCompletion; adds the minor changeset entry.

Sequence Diagram

sequenceDiagram
  participant App
  participant OfflineExecutor
  participant TransactionExecutor
  participant OptimisticHold
  participant confirmWrite hook

  rect rgba(100, 149, 237, 0.5)
    Note over OfflineExecutor, TransactionExecutor: Serial drain path
    App->>OfflineExecutor: mutate(data)
    OfflineExecutor->>TransactionExecutor: runMutationFn(tx)
    TransactionExecutor-->>OfflineExecutor: result (mutationFn return value)
    TransactionExecutor->>TransactionExecutor: resolveWithOptionalConfirmation(tx, result)
    TransactionExecutor->>OptimisticHold: createOptimisticHold(mutations)
    OptimisticHold-->>TransactionExecutor: hold (overlay painted)
    TransactionExecutor->>OfflineExecutor: resolveTransaction (outbox entry removed)
  end

  rect rgba(144, 238, 144, 0.5)
    Note over TransactionExecutor, confirmWrite hook: Off-drain confirmation path
    TransactionExecutor->>confirmWrite hook: confirmWrite({ id, mutations, result })
    confirmWrite hook-->>TransactionExecutor: settles (resolve or reject)
    TransactionExecutor->>OptimisticHold: release() / release({ rollback: true })
    OptimisticHold-->>App: overlay dropped, collection updates
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • samwillis

Poem

🐰 A rabbit once waited for sync to come through,
While optimistic rows flickered — a glitchy tableau.
Now confirmWrite holds the overlay tight,
Off the serial path, the drain runs with delight!
No flicker, no block — just a smooth overlay flight. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% 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
Title check ✅ Passed The PR title accurately describes the main change: adding a confirmWrite hook feature to offline-transactions that runs confirmation off the serial drain path.
Description check ✅ Passed The PR description comprehensively covers changes, motivation, implementation details, and follows the repository template with completed checklist and release impact sections.
Linked Issues check ✅ Passed The PR fully implements the objectives from #1602: adds confirmWrite hook, maintains optimistic state off the serial path, includes maxConfirmationHolds cap with getActiveConfirmationHoldCount(), and threads mutation results to completion promise.
Out of Scope Changes check ✅ Passed All changes are within scope: new OptimisticHold primitive, confirmWrite/maxConfirmationHolds configuration, TransactionExecutor enhancements for holds, diagnostic methods, tests, and a targeted drive-by fix threading mutation results through completion.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@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 on lines +200 to +219
const maxHolds =
this.config.maxConfirmationHolds ?? DEFAULT_MAX_CONFIRMATION_HOLDS
if (this.confirmationHolds.size >= maxHolds) {
// Safety valve: too many concurrent holds. Skip the hold — the optimistic
// overlay drops at resolve as it would without the hook. The write is
// already durably committed, so correctness is unaffected.
this.offlineExecutor.resolveTransaction(transaction.id, result)
return
}

const hold = this.createConfirmationHold(transaction.mutations)
this.offlineExecutor.resolveTransaction(transaction.id, result)

if (!hold) {
// Hold creation failed (already logged). The write is committed; just let
// the optimistic state drop as it did before the hook existed.
return
}

this.runConfirmation(confirmWrite, transaction, result, hold)

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

confirmWrite is silently skipped when holds are capped or hold creation fails.

At Line 202 and Line 213, the function returns before invoking confirmWrite. That breaks the post-commit hook contract and makes behavior depend on hold availability instead of hook configuration. Also, Line 201 uses maxConfirmationHolds without normalizing NaN/negative values, which can bypass or disable the cap unexpectedly.

Suggested patch
-    const maxHolds =
-      this.config.maxConfirmationHolds ?? DEFAULT_MAX_CONFIRMATION_HOLDS
-    if (this.confirmationHolds.size >= maxHolds) {
-      // Safety valve: too many concurrent holds. Skip the hold — the optimistic
-      // overlay drops at resolve as it would without the hook. The write is
-      // already durably committed, so correctness is unaffected.
-      this.offlineExecutor.resolveTransaction(transaction.id, result)
-      return
-    }
-
-    const hold = this.createConfirmationHold(transaction.mutations)
+    const configuredMax = this.config.maxConfirmationHolds
+    const maxHolds =
+      Number.isFinite(configuredMax) && (configuredMax as number) >= 0
+        ? Math.floor(configuredMax as number)
+        : DEFAULT_MAX_CONFIRMATION_HOLDS
+    const hold =
+      this.confirmationHolds.size < maxHolds
+        ? this.createConfirmationHold(transaction.mutations)
+        : null
     this.offlineExecutor.resolveTransaction(transaction.id, result)
-
-    if (!hold) {
-      // Hold creation failed (already logged). The write is committed; just let
-      // the optimistic state drop as it did before the hook existed.
-      return
-    }
-
     this.runConfirmation(confirmWrite, transaction, result, hold)
   }
@@
-    hold: OptimisticHold,
+    hold: OptimisticHold | null,
   ): void {
     const release = (): void => {
-      this.confirmationHolds.delete(hold)
-      try {
-        hold.release()
-      } catch (error) {
-        console.warn(`Failed to release confirmation hold:`, error)
+      if (hold) {
+        this.confirmationHolds.delete(hold)
+        try {
+          hold.release()
+        } catch (error) {
+          console.warn(`Failed to release confirmation hold:`, error)
+        }
       }
     }

Also applies to: 237-270

🤖 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/TransactionExecutor.ts` around
lines 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.

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