Skip to content

localOnly fails to persist manual mutation that localStorage handles ok #955

@Cluster444

Description

@Cluster444

NOTE - The below was generated by Opus 4.5. I was having an issue switching a couple of collections from localStorage to localOnly while using a manual transaction. They initially persist but after commit they get removed from the collection.

I asked Opus 4.5 to diagnose what was going on, and it dug into tanstack db, created a patch, and the patch actually worked. I asked it to describe the problem, reproduction and the patch below.

I hope this is helpful, really enjoying tanstack, but I don't know enough to turn this into a proper PR, just a lowly lowly Rails dev ;)

I hope this is helpful!

Side Note: There's actually more than one collection in the transaction, but it fails regardless of the number of collections.


Bug: localOnlyCollectionOptions acceptMutations fails to match mutations by collection ID

Summary

When using localOnlyCollectionOptions with manual transactions that call utils.acceptMutations(), mutations are not persisted and disappear when the transaction completes. The same code works correctly with localStorageCollectionOptions.

Environment

  • @tanstack/db: 0.5.10
  • @tanstack/react-db: 0.1.54

Reproduction

// Collection definition
export const workoutsCollection = createCollection(
  localOnlyCollectionOptions({
    id: "training.workouts",
    getKey: (item: Workout) => item.id,
    schema: WorkoutSchema,
    onInsert: performSync,
    onUpdate: performSync,
    onDelete: performSync,
  })
)

// Transaction usage
const tx = createTransaction({
  mutationFn: async ({ transaction }) => {
    workoutsCollection.utils.acceptMutations(transaction)
    await performSync({ transaction })
  },
})

tx.mutate(() => {
  workoutsCollection.insert(workout)
})

Expected Behavior

After the transaction completes, the workout should exist in the collection.

Actual Behavior

The workout exists during optimistic state but disappears when the transaction completes:

[createWorkout] acceptMutations done:     workoutExists: true  ✓
[createWorkout] tx.isPersisted resolved:  workoutExists: false ✗

Switching to localStorageCollectionOptions (with the same code pattern) works correctly.

Root Cause

The issue is in acceptMutations within localOnlyCollectionOptions. It filters mutations to find those belonging to the collection:

Current code (local-only.ts line ~249):

const acceptMutations = (transaction) => {
  const collectionMutations = transaction.mutations.filter(
    (m) => m.collection === syncResult.collection
  );
  // ...
};

The problem is that syncResult.collection is always null when acceptMutations is called. Here's why:

function createLocalOnlySync(initialData) {
  let collection = null;  // Starts as null
  
  const sync = {
    sync: (params) => {
      collection = params.collection;  // Set later when sync initializes
    },
  };
  
  return {
    sync,
    confirmOperationsSync,
    collection,  // ← Captured as null at object creation time!
  };
}

When createLocalOnlySync() returns, collection in the returned object is captured as null. Even though the sync() function later updates the local variable, the returned object's property remains null.

As a result, m.collection === syncResult.collection always compares against null, never matches, and confirmOperationsSync is never called. The mutations stay in optimistic state only, and when the transaction completes, they're cleared.

Why localStorageCollectionOptions Works

The localStorageCollectionOptions implementation has a fallback:

// local-storage.ts
const collectionMutations = transaction.mutations.filter((m) => {
  // Try to match by collection reference first
  if (sync.collection && m.collection === sync.collection) {
    return true;
  }
  // Fall back to matching by collection ID
  return m.collection.id === collectionId;  // ← This fallback is missing in localOnly!
});

This ID-based fallback ensures mutations are matched even when the collection reference isn't available.

Suggested Fix

Add the same fallback to localOnlyCollectionOptions:

// local-only.ts
const acceptMutations = (transaction) => {
  const collectionMutations = transaction.mutations.filter((m) => {
    // Try to match by collection reference first
    if (syncResult.collection && m.collection === syncResult.collection) {
      return true;
    }
    // Fall back to matching by collection ID
    return m.collection.id === config.id;
  });
  
  if (collectionMutations.length === 0) {
    return;
  }
  
  syncResult.confirmOperationsSync(collectionMutations);
};

This fix needs to be applied to both:

  • src/local-only.ts (TypeScript source)
  • dist/esm/local-only.js (compiled output)

Workaround

Until this is fixed, users can apply the patch using patch-package or bun patch.

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