Skip to content

Calling writeInsert inside a createOptimisticAction throws error #1783

Description

@samvantoever
  • I've validated the bug against the latest version of DB packages

Describe the bug

Calling collection.utils.writeInsert() inside a createOptimisticAction mutationFn, when that action's onMutate optimistically inserted the same key, corrupts the live-query pipeline. The create itself succeeds and reads back correctly; the next mutation of that row throws Query contributors with the same row key are not congruent, synchronously out of onMutate.

The create — reconciles the POST response with writeInsert before returning, as the createOptimisticAction docs instruct:

const addTodo = createOptimisticAction<Todo>({
  onMutate: (todo) => {
    todoCollection.insert(todo);
  },
  mutationFn: async (todo) => {
    const saved = await api.post(todo);

    todoCollection.utils.writeInsert(saved); // <-- corrupts the pipeline
  },
});

The update — same shape, writeUpdate instead:

const renameTodo = createOptimisticAction<{ id: string; text: string }>({
  onMutate: ({ id, text }) => {
    todoCollection.update(id, (draft) => {
      draft.text = text;
    });
  },
  mutationFn: async ({ id, text }) => {
    const saved = await api.patch({ id, text });

    todoCollection.utils.writeUpdate(saved);
  },
});
await addTodo({ id: 'todo-1', text: 'walk the dog' }).isPersisted.promise;
// fine: tx.state === 'completed', tx.error undefined, collection and live query both correct

renameTodo({ id: 'todo-1', text: 'walk the cat' });
// Uncaught Error: Query contributors with the same row key are not congruent

Full stack:

Uncaught Error: Query contributors with the same row key are not congruent
    at ReduceOperator (@tanstack/db/src/query/compiler/index.ts:1270)
    at ReduceOperator.run (@tanstack/db-ivm/src/operators/reduce.ts:42)
    at D2.step (@tanstack/db-ivm/src/d2.ts:51)
    at D2.run (@tanstack/db-ivm/src/d2.ts:61)
    at CollectionConfigBuilder.maybeRunGraph (@tanstack/db/src/query/live/collection-config-builder.ts:452)
    at CollectionConfigBuilder.executeGraphRun (@tanstack/db/src/query/live/collection-config-builder.ts:657)
    at Scheduler.flush (@tanstack/db/src/scheduler.ts:158)
    at CollectionChangesManager.publishEvents (@tanstack/db/src/collection/changes.ts:196)

Because it throws out of onMutate it is not a rejected transaction that rollback can absorb — it is an uncaught error inside the caller's event handler, and every live query over the collection stays broken from that point on.

Reconciling through a direct write API is what the createOptimisticAction docblock tells you to do:

Important: Inside your mutationFn, you must ensure that your server writes have synced back before you return, as the optimistic state is dropped when you return from the mutation function. You generally use collection-specific helpers to do this, such as Query's utils.refetch(), direct write APIs, or Electric's utils.awaitTxId().

That contract holds for writeUpdate over an optimistic update — the second action above can run repeatedly against a row that arrived from sync. It breaks for writeInsert over an optimistic insert.

To Reproduce

Self-contained script (no DOM, no test runner, no network) attached below. npx tsx tanstack-db-writeinsert-repro.ts on Node 20+.

Steps:

  1. Create a queryCollectionOptions collection whose queryFn returns [].
  2. Create a live query over it (q.from({ todo }).select(...)) and attach a subscriber — the D2 graph has to actually be running.
  3. Create an optimistic action whose onMutate calls collection.insert(row) and whose mutationFn awaits a POST and then calls collection.utils.writeInsert(saved).
  4. Run it and await tx.isPersisted.promise. It resolves; everything reads correctly.
  5. Run a second optimistic action that calls collection.update(key, ...) in onMutate.
  6. See the error thrown synchronously out of step 5.

Output of the attached script:

[broken] create isPersisted: resolved
[broken] create state: completed
[broken] create error: none
[broken] collection row: walk the dog
[broken] live query row: walk the dog
[broken] THREW: Query contributors with the same row key are not congruent
[control] rename 1 isPersisted: resolved
[control] rename 2 isPersisted: resolved
[control] final text: water plants thrice
[workaround] rename isPersisted: resolved
[workaround] final text: buy oat milk

Expected behavior

Reconciling an optimistically inserted row with the server's response via utils.writeInsert() before returning from mutationFn should leave the collection and its live queries in a consistent state, exactly as utils.writeUpdate() does for an optimistically updated row. Subsequent mutations of that key should behave normally.

If mixing a direct write with an in-flight optimistic insert is not supported, the direct write API should reject the call at the point it is made rather than silently corrupting the pipeline and surfacing an unrelated-looking error on a later mutation — and the createOptimisticAction docs should say so.

Screenshots

n/a — console output above.

Desktop (please complete the following information):

  • OS: macOS 26.5.1 (Darwin 25.5.0)
  • Browser: originally hit in a Chromium browser in a Vite + React app; the attached script reproduces it headless, so it is not browser specific
  • Version: Node 22.12.0 via tsx; package versions below

Smartphone (please complete the following information):

  • n/a — not device specific.

Additional context

Package versions:

@tanstack/db                   0.8.5   (latest published)
@tanstack/db-ivm               0.1.19
@tanstack/query-db-collection  1.2.10
@tanstack/query-core           5.90.20
@tanstack/react-db             0.3.5
tanstack-db-writeinsert-repro.ts
/**
 * Repro: `utils.writeInsert()` inside a `createOptimisticAction` `mutationFn`
 * corrupts the live-query pipeline when that action's `onMutate` optimistically
 * inserted the same key.
 *
 *   @tanstack/db                 0.8.5
 *   @tanstack/query-db-collection 1.2.10
 *   @tanstack/query-core          5.x
 *
 * Run it (Node 20+ — `@tanstack/db-ivm` hashing needs global `File`, and
 * transaction IDs need global `crypto`):
 *
 *   npx tsx tanstack-db-writeinsert-repro.ts
 *
 * or import it from an app entry point and call `runRepro()`. No DOM, no test
 * runner, no network.
 *
 * Expected output:
 *
 *   [broken] create isPersisted: resolved
 *   [broken] create state: completed
 *   [broken] create error: none
 *   [broken] collection row: walk the dog
 *   [broken] live query row: walk the dog
 *   [broken] THREW: Query contributors with the same row key are not congruent
 *   [control] rename 1 isPersisted: resolved
 *   [control] rename 2 isPersisted: resolved
 *   [control] final text: water plants thrice
 *   [workaround] rename isPersisted: resolved
 *   [workaround] final text: buy oat milk
 */

import {
  createCollection,
  createLiveQueryCollection,
  createOptimisticAction,
} from '@tanstack/db';
import { QueryClient } from '@tanstack/query-core';
import { queryCollectionOptions } from '@tanstack/query-db-collection';

type Todo = {
  id: string;
  text: string;
};

const flush = () => new Promise((resolve) => setTimeout(resolve, 25));

/**
 * Reports how `tx.isPersisted.promise` settles without hanging on it, so the
 * script still prints its findings if a transaction never resolves.
 */
async function settle(tx: { isPersisted: { promise: Promise<unknown> } }) {
  return Promise.race([
    tx.isPersisted.promise.then(
      () => 'resolved',
      () => 'rejected'
    ),
    flush().then(() => 'still pending'),
  ]);
}

/** Stands in for the backend. IDs are client-generated, so POST echoes the row. */
const server = {
  post: async (todo: Todo): Promise<Todo> => ({ ...todo }),
  patch: async (todo: Todo): Promise<Todo> => ({ ...todo }),
};

async function setup() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false, staleTime: Infinity } },
  });

  // Starts empty. Every row arrives through the direct write API below.
  const todos = createCollection(
    queryCollectionOptions<Todo>({
      id: 'todos',
      queryClient,
      queryKey: ['todos'],
      getKey: (todo) => todo.id,
      queryFn: async () => [],
    })
  );

  const visibleTodos = createLiveQueryCollection({
    id: 'visible-todos',
    query: (q) => q.from({ todo: todos }).select(({ todo }) => ({ ...todo })),
    getKey: (row) => row.id,
  });

  await todos.preload();

  // An active subscriber is required — the D2 graph has to actually be running.
  const subscription = visibleTodos.subscribeChanges(() => {}, {
    includeInitialState: true,
  });

  await visibleTodos.preload();

  const addTodo = createOptimisticAction<Todo>({
    onMutate: (todo) => {
      todos.insert(todo);
    },
    mutationFn: async (todo) => {
      const saved = await server.post(todo);

      // Reconcile the server response before returning, as the
      // `createOptimisticAction` docblock instructs. This is the call that
      // corrupts the pipeline.
      todos.utils.writeInsert(saved);
    },
  });

  const renameTodo = createOptimisticAction<{ id: string; text: string }>({
    onMutate: ({ id, text }) => {
      todos.update(id, (draft) => {
        draft.text = text;
      });
    },
    mutationFn: async ({ id, text }) => {
      const saved = await server.patch({ id, text });

      todos.utils.writeUpdate(saved);
    },
  });

  return {
    todos,
    visibleTodos,
    addTodo,
    renameTodo,
    dispose: () => subscription.unsubscribe(),
  };
}

/** The bug: optimistic insert reconciled with `writeInsert`. */
async function brokenCase() {
  const { todos, visibleTodos, addTodo, renameTodo, dispose } = await setup();

  const tx = addTodo({ id: 'todo-1', text: 'walk the dog' });

  // The create itself looks completely healthy.
  console.log('[broken] create isPersisted:', await settle(tx));
  console.log('[broken] create state:', tx.state);
  console.log('[broken] create error:', tx.error ?? 'none');
  console.log('[broken] collection row:', todos.get('todo-1')?.text);
  console.log('[broken] live query row:', visibleTodos.get('todo-1')?.text);

  // The next mutation of that row throws synchronously, out of `onMutate`.
  try {
    renameTodo({ id: 'todo-1', text: 'walk the cat' });
    console.log('[broken] NO ERROR — bug did not reproduce');
  } catch (error) {
    console.log('[broken] THREW:', (error as Error).message);
  }

  dispose();
}

export async function runRepro() {
  await brokenCase();
}

runRepro()
  .catch((error) => {
    console.error('unexpected failure', error);
  })
  .finally(() => {
    // The QueryClient keeps timers alive, so a plain script would hang here.
    if (typeof process !== 'undefined') process.exit(0);
  });

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions