From 6cd4951177f11237241a5e49886d6eae0e79ae65 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 15:25:54 +0000 Subject: [PATCH 01/11] Add comprehensive design and exploration docs for issue #19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created design proposal for stable viewKeys feature to prevent UI re-renders during temporary-to-real ID transitions. Includes: - DESIGN_ISSUE_19_STABLE_VIEWKEYS.md: Complete design proposal with API, implementation plan, usage examples, and alternatives considered - CODEBASE_EXPLORATION_ISSUE_19.md: Detailed analysis of current codebase architecture including storage, mutations, and transactions - KEY_FILES_REFERENCE.md: Quick reference for key files and structures - CODE_SNIPPETS_REFERENCE.md: Implementation examples from codebase Design proposes opt-in viewKey configuration with auto-generation and explicit linking API for mapping temporary IDs to real server IDs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CODEBASE_EXPLORATION_ISSUE_19.md | 509 ++++++++++++++++++++++++++++ CODE_SNIPPETS_REFERENCE.md | 450 +++++++++++++++++++++++++ DESIGN_ISSUE_19_STABLE_VIEWKEYS.md | 513 +++++++++++++++++++++++++++++ KEY_FILES_REFERENCE.md | 111 +++++++ 4 files changed, 1583 insertions(+) create mode 100644 CODEBASE_EXPLORATION_ISSUE_19.md create mode 100644 CODE_SNIPPETS_REFERENCE.md create mode 100644 DESIGN_ISSUE_19_STABLE_VIEWKEYS.md create mode 100644 KEY_FILES_REFERENCE.md diff --git a/CODEBASE_EXPLORATION_ISSUE_19.md b/CODEBASE_EXPLORATION_ISSUE_19.md new file mode 100644 index 000000000..983373e1b --- /dev/null +++ b/CODEBASE_EXPLORATION_ISSUE_19.md @@ -0,0 +1,509 @@ +# TanStack DB Codebase Structure - Issue #19 Exploration + +## Executive Summary + +Issue #19 focuses on implementing **stable view keys** for handling temporary-to-real ID transitions when inserting items where the server generates the final ID. Currently, this requires manual mapping outside the collection. + +## Directory Structure + +``` +/home/user/db/packages/db/src/ +├── collection/ +│ ├── index.ts # Main Collection implementation +│ ├── state.ts # CollectionStateManager - core item storage +│ ├── mutations.ts # CollectionMutationsManager - insert/update/delete +│ ├── transactions.ts # NOT HERE - see root +│ ├── changes.ts # CollectionChangesManager - event emission +│ ├── change-events.ts # Change event generation +│ ├── lifecycle.ts # Collection lifecycle management +│ ├── sync.ts # CollectionSyncManager - sync operations +│ ├── subscription.ts # Subscription management +│ ├── events.ts # Event emission +│ └── indexes.ts # IndexesManager - query optimization +├── transactions.ts # Transaction implementation (mutation grouping) +├── types.ts # Type definitions (PendingMutation, Transaction, etc.) +├── local-storage.ts # LocalStorage collection implementation +├── proxy.ts # Change tracking proxy system +├── SortedMap.ts # Ordered Map implementation +├── event-emitter.ts # Event system +├── scheduler.ts # Async scheduler +├── utils.ts # Utilities +└── indexes/ # Index implementations + ├── base-index.ts + ├── btree-index.ts + ├── lazy-index.ts + └── ... +``` + +--- + +## 1. Collection Item Storage + +### Primary Storage Structure + +**Location**: `/home/user/db/packages/db/src/collection/state.ts` (CollectionStateManager) + +```typescript +// Main stores +public syncedData: Map | SortedMap +public optimisticUpserts = new Map() +public optimisticDeletes = new Set() +public syncedMetadata = new Map() +``` + +**Storage Layers**: +1. **`syncedData`**: Source of truth from the server + - Regular `Map` if no comparator provided + - `SortedMap` if a `compare` function is provided + - Contains confirmed items from sync operations + +2. **`optimisticUpserts`**: Pending insert/update items + - Overlays syncedData + - Cleared when transactions complete/fail + - Re-added if still active + +3. **`optimisticDeletes`**: Items pending deletion + - Set of keys marked for deletion + - Checked in the virtual `get()` method + +### Virtual Derived State Access + +```typescript +// Constructor (lines 73-77) +if (config.compare) { + this.syncedData = new SortedMap(config.compare) +} else { + this.syncedData = new Map() +} + +// Combined view (lines 95-109) +public get(key: TKey): TOutput | undefined { + if (optimisticDeletes.has(key)) return undefined + if (optimisticUpserts.has(key)) return optimisticUpserts.get(key) + return syncedData.get(key) +} +``` + +**Ordering**: +1. Check optimistic deletes (returns undefined) +2. Check optimistic upserts (returns optimistic value) +3. Fall back to synced data + +--- + +## 2. Transaction and Mutation Implementation + +### Mutation Data Structure + +**Location**: `/home/user/db/packages/db/src/types.ts` (lines 57-86) + +```typescript +export interface PendingMutation { + mutationId: string // UUID for the specific mutation + original: T | {} // Pre-mutation state (empty for inserts) + modified: T // Post-mutation state + changes: ResolveTransactionChanges // Only actual changes (for partial updates) + globalKey: string // KEY::{collectionId}/{key} - for deduplication + + key: any // User's item key (from getKey()) + type: 'insert' | 'update' | 'delete' // Operation type + + metadata: unknown // User-provided metadata + syncMetadata: Record // Metadata from sync operations + + optimistic: boolean // Apply changes immediately? (default: true) + createdAt: Date // When mutation was created + updatedAt: Date // Last update time + + collection: Collection // Reference to collection +} +``` + +### Global Key Generation + +**Location**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 143-149) + +```typescript +public generateGlobalKey(key: any, item: any): string { + if (typeof key === `undefined`) { + throw new UndefinedKeyError(item) + } + // Format: KEY::{collectionId}/{key} + return `KEY::${this.id}/${key}` +} +``` + +**Purpose**: +- Uniquely identifies an item across transactions +- Used to merge mutations on the same item +- Supports deduplication and transaction merging logic + +### Transaction Structure + +**Location**: `/home/user/db/packages/db/src/transactions.ts` (lines 207-530) + +```typescript +class Transaction { + public id: string // UUID for transaction + public state: TransactionState // 'pending' | 'persisting' | 'completed' | 'failed' + public mutationFn: MutationFn // Persistence function + public mutations: Array> // Grouped mutations + public isPersisted: Deferred> // Promise for completion + public autoCommit: boolean // Auto-commit after mutate() + public createdAt: Date + public sequenceNumber: number // For ordering transactions + public metadata: Record + public error?: { message: string; error: Error } +} +``` + +### Mutation Merging Logic + +**Location**: `/home/user/db/packages/db/src/transactions.ts` (lines 41-101) + +**Truth Table**: +``` +Existing → New | Result | Behavior +insert → update | insert | Merge changes, keep empty original +insert → delete | removed | Cancel each other +update → delete | delete | Delete dominates +update → update | update | Union changes, keep first original +delete → delete | delete | Replace with latest +insert → insert | insert | Replace with latest +``` + +**Key Algorithm** (lines 323-345): +```typescript +applyMutations(mutations: Array>): void { + for (const newMutation of mutations) { + // Find existing mutation with same globalKey + const existingIndex = this.mutations.findIndex( + (m) => m.globalKey === newMutation.globalKey + ) + + if (existingIndex >= 0) { + // Merge or remove if cancel + const mergeResult = mergePendingMutations(existing, newMutation) + if (mergeResult === null) { + this.mutations.splice(existingIndex, 1) // Cancel + } else { + this.mutations[existingIndex] = mergeResult // Replace + } + } else { + this.mutations.push(newMutation) // New mutation + } + } +} +``` + +--- + +## 3. ID and Key Management + +### Current Key Handling + +**Location**: `/home/user/db/packages/db/src/types.ts` (lines 400-409) + +```typescript +interface BaseCollectionConfig { + getKey: (item: T) => TKey // REQUIRED: Extract ID from item + // ... other config +} +``` + +**Key Properties**: +- `TKey`: Type of the key (string | number) +- User provides `getKey()` function at collection creation +- Used to: + - Extract key from item for storage + - Generate globalKey + - Validate key changes are not allowed in updates + - Track mutations by item identity + +### Key Validation + +**Location**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 340-346) + +```typescript +// Check if ID is being changed (not allowed) +const originalItemId = this.config.getKey(originalItem) +const modifiedItemId = this.config.getKey(modifiedItem) + +if (originalItemId !== modifiedItemId) { + throw new KeyUpdateNotAllowedError(originalItemId, modifiedItemId) +} +``` + +### Sync Metadata + +**Location**: `/home/user/db/packages/db/src/collection/state.ts` (lines 47, 367) + +```typescript +public syncedMetadata = new Map() + +// Used in mutations (line 367-370) +syncMetadata: (state.syncedMetadata.get(key) || {}) as Record +``` + +**Purpose**: +- Stores metadata associated with synced items +- Separate from user-provided metadata +- Can be set via `sync.getSyncMetadata()` + +--- + +## 4. Type Definitions + +### Key Type Interfaces + +**Location**: `/home/user/db/packages/db/src/types.ts` + +#### OperationType (line 152) +```typescript +export type OperationType = `insert` | `update` | `delete` +``` + +#### Transaction Configuration (lines 115-123) +```typescript +export interface TransactionConfig { + id?: string + autoCommit?: boolean + mutationFn: MutationFn + metadata?: Record +} +``` + +#### Change Message (lines 261-270) +```typescript +export interface ChangeMessage { + key: TKey + value: T + previousValue?: T + type: OperationType + metadata?: Record +} +``` + +#### Operation Handlers (lines 496-583) +```typescript +// Insert handler +onInsert?: InsertMutationFn + +// Update handler +onUpdate?: UpdateMutationFn + +// Delete handler +onDelete?: DeleteMutationFn +``` + +--- + +## 5. Extended Properties and Metadata + +### Metadata Patterns + +**User Metadata** (provided at operation time): +```typescript +// In mutations +collection.update(id, + { metadata: { intent: 'complete' } }, // Custom metadata + (draft) => { draft.completed = true } +) + +// Accessible in handler +const mutation = transaction.mutations[0] +console.log(mutation.metadata?.intent) // 'complete' +``` + +**Sync Metadata** (from sync implementation): +```typescript +// Set by sync in getSyncMetadata() +sync: { + getSyncMetadata?: () => Record + // ... +} + +// Accessible in mutation +const syncMeta = mutation.syncMetadata +``` + +### Timestamps + +**Location**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 198-199, 373-374) + +```typescript +createdAt: new Date() // When mutation created +updatedAt: new Date() // Last update in transaction +``` + +**Note**: These track mutation lifecycle, not item timestamps. + +--- + +## 6. Issue #19: Stable View Keys + +### The Problem + +**Location**: `/home/user/db/docs/guides/mutations.md` (lines 1045-1070) + +When inserting items with temporary IDs (before server assigns real IDs): + +1. **UI Flicker**: Framework unmounts/remounts components when key changes from temporary to real ID +2. **Subsequent Operations Fail**: Delete/update before sync completes uses invalid temporary ID + +```typescript +// Current problematic pattern +const tempId = -(Math.floor(Math.random() * 1000000) + 1) +todoCollection.insert({ id: tempId, text: 'New todo' }) +// When sync completes, tempId becomes realId +todoCollection.delete(tempId) // May fail: tempId no longer exists +``` + +### Current Workaround (Manual) + +**Location**: `/home/user/db/docs/guides/mutations.md` (lines 1130-1201) + +```typescript +// User must maintain this mapping manually +const idToViewKey = new Map() + +function getViewKey(id: number | string): string { + if (!idToViewKey.has(id)) { + idToViewKey.set(id, crypto.randomUUID()) + } + return idToViewKey.get(id)! +} + +function linkIds(tempId: number, realId: number) { + const viewKey = getViewKey(tempId) + idToViewKey.set(realId, viewKey) // Link both IDs to same key +} + +// In handler +onInsert: async ({ transaction }) => { + const mutation = transaction.mutations[0] + const tempId = mutation.modified.id + const response = await api.todos.create(mutation.modified) + linkIds(tempId, response.id) + await todoCollection.utils.refetch() +} + +// In render +{todos.map((todo) => ( +
  • // Stable key! + {todo.text} +
  • +))} +``` + +### Issue Request + +**Location**: `/home/user/db/docs/guides/mutations.md` (line 1211) + +> There's an [open issue](https://github.com/TanStack/db/issues/19) to add better built-in support for temporary ID handling in TanStack DB. This would automate the view key pattern and make it easier to work with server-generated IDs. + +--- + +## 7. Key Files Summary + +| File | Purpose | Key Exports | +|------|---------|------------| +| `types.ts` | Type definitions | `PendingMutation`, `TransactionConfig`, `ChangeMessage`, etc. | +| `transactions.ts` | Transaction management | `Transaction`, `createTransaction()`, `mergePendingMutations()` | +| `collection/state.ts` | Item storage & state | `CollectionStateManager` with `syncedData`, `optimisticUpserts`, `optimisticDeletes` | +| `collection/mutations.ts` | Insert/update/delete logic | `CollectionMutationsManager` with `insert()`, `update()`, `delete()`, `generateGlobalKey()` | +| `collection/index.ts` | Main collection class | `Collection`, `CollectionImpl`, `createCollection()` | +| `collection/changes.ts` | Event emission | `CollectionChangesManager`, event batching & emission | +| `local-storage.ts` | localStorage implementation | `localStorageCollectionOptions()` with persistence | +| `proxy.ts` | Change tracking | `createChangeProxy()`, Immer-like change tracking | +| `SortedMap.ts` | Ordered storage | `SortedMap` with binary search insertion | + +--- + +## 8. Data Flow for Mutations + +``` +1. User calls collection.insert/update/delete() + ↓ +2. CollectionMutationsManager creates PendingMutation + - Generates globalKey: KEY::{collectionId}/{key} + - Sets metadata, timestamps + - Validates against schema + ↓ +3. Mutation added to active Transaction + - Can be ambient or explicit transaction + - If same globalKey exists: merge via mergePendingMutations() + ↓ +4. Transaction.mutate() completes or autoCommit triggers + ↓ +5. Transaction.commit() calls mutationFn() + - Sends mutations to backend + - User's onInsert/onUpdate/onDelete called + ↓ +6. On success: sync operations update syncedData + - Optimistic state cleared + - Server state becomes truth + - Change events emitted + ↓ +7. On failure: transaction.rollback() + - Optimistic state reverted + - Change events emitted +``` + +--- + +## 9. Implications for Issue #19 Fix + +### Where View Key Storage Would Go + +1. **Option A**: Add to `PendingMutation` + ```typescript + viewKey?: string // Stable key for rendering + ``` + +2. **Option B**: Add to CollectionStateManager + ```typescript + public viewKeyMap = new Map() // Maps both temp and real IDs + ``` + +3. **Option C**: Add to collection config + ```typescript + generateViewKey?: (item: T, mutation: PendingMutation) => string + ``` + +### Key Modification Points + +1. **In `mutations.ts`**: + - Generate/track viewKey during insert creation + - Link viewKey when key changes detected + +2. **In `state.ts`**: + - Maintain viewKey mapping through lifecycle + - Provide `getViewKey()` public method + +3. **In `types.ts`**: + - Add viewKey to `PendingMutation` + - Add viewKey to `ChangeMessage` + - Add config option to `BaseCollectionConfig` + +4. **In sync operations**: + - When synced item replaces optimistic item, link viewKeys + - Preserve viewKey through state transitions + +### Critical Behaviors to Preserve + +1. **Key immutability**: Still enforce in updates +2. **Mutation merging**: Use original key, not viewKey +3. **Backward compatibility**: viewKey optional, default to key +4. **Performance**: viewKey lookup O(1) with Map + +--- + +## References + +- **Main collection implementation**: `/home/user/db/packages/db/src/collection/index.ts` +- **State management**: `/home/user/db/packages/db/src/collection/state.ts` +- **Mutation handling**: `/home/user/db/packages/db/src/collection/mutations.ts` +- **Transaction logic**: `/home/user/db/packages/db/src/transactions.ts` +- **Type definitions**: `/home/user/db/packages/db/src/types.ts` +- **Documentation**: `/home/user/db/docs/guides/mutations.md` (lines 1045-1211) diff --git a/CODE_SNIPPETS_REFERENCE.md b/CODE_SNIPPETS_REFERENCE.md new file mode 100644 index 000000000..85d2d4c7e --- /dev/null +++ b/CODE_SNIPPETS_REFERENCE.md @@ -0,0 +1,450 @@ +# TanStack DB - Code Snippets Reference + +## 1. Collection Item Storage Pattern + +### CollectionStateManager Overview +**File**: `/home/user/db/packages/db/src/collection/state.ts` (lines 29-78) + +```typescript +export class CollectionStateManager< + TOutput extends object, + TKey extends string | number, + TSchema extends StandardSchemaV1, + TInput extends object, +> { + // Three layers of storage + public syncedData: Map | SortedMap + public optimisticUpserts = new Map() + public optimisticDeletes = new Set() + public syncedMetadata = new Map() + + constructor(config: CollectionConfig) { + // Set up data storage with optional comparison function + if (config.compare) { + this.syncedData = new SortedMap(config.compare) + } else { + this.syncedData = new Map() + } + } + + // Virtual derived state combining all layers + public get(key: TKey): TOutput | undefined { + const { optimisticDeletes, optimisticUpserts, syncedData } = this + + if (optimisticDeletes.has(key)) { + return undefined + } + if (optimisticUpserts.has(key)) { + return optimisticUpserts.get(key) + } + return syncedData.get(key) + } +} +``` + +## 2. Mutation Data Structure + +### PendingMutation Interface +**File**: `/home/user/db/packages/db/src/types.ts` (lines 57-86) + +```typescript +export interface PendingMutation< + T extends object = Record, + TOperation extends OperationType = OperationType, + TCollection extends Collection = Collection< + T, + any, + any, + any, + any + >, +> { + mutationId: string // Unique ID for this mutation + original: TOperation extends `insert` ? {} : T // State before mutation + modified: T // State after mutation + changes: ResolveTransactionChanges // Only changed fields + globalKey: string // "KEY::{collectionId}/{key}" + + key: any // Item's user-provided key + type: TOperation // 'insert', 'update', or 'delete' + metadata: unknown // Custom metadata + syncMetadata: Record // Sync-provided metadata + + optimistic: boolean // Apply immediately? + createdAt: Date // When created + updatedAt: Date // Last updated + collection: TCollection // Reference to collection +} +``` + +## 3. Global Key Generation + +### How Global Keys Are Created +**File**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 143-149) + +```typescript +public generateGlobalKey(key: any, item: any): string { + if (typeof key === `undefined`) { + throw new UndefinedKeyError(item) + } + // Format: KEY::{collectionId}/{key} + return `KEY::${this.id}/${key}` +} +``` + +### Usage in Insert +**File**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 173-177) + +```typescript +const key = this.config.getKey(validatedData) +if (this.state.has(key)) { + throw new DuplicateKeyError(key) +} +const globalKey = this.generateGlobalKey(key, item) + +const mutation: PendingMutation = { + mutationId: crypto.randomUUID(), + globalKey, + key, + // ... other fields +} +``` + +## 4. Transaction Mutation Merging + +### Merge Logic Truth Table +**File**: `/home/user/db/packages/db/src/transactions.ts` (lines 41-101) + +```typescript +function mergePendingMutations( + existing: PendingMutation, + incoming: PendingMutation +): PendingMutation | null { + switch (`${existing.type}-${incoming.type}` as const) { + case `insert-update`: + // Update after insert: keep as insert but merge changes + return { + ...existing, + type: `insert` as const, + original: {}, + modified: incoming.modified, + changes: { ...existing.changes, ...incoming.changes }, + key: existing.key, + globalKey: existing.globalKey, + mutationId: incoming.mutationId, + updatedAt: incoming.updatedAt, + } + + case `insert-delete`: + // Delete after insert: cancel both mutations + return null + + case `update-delete`: + // Delete after update: delete dominates + return incoming + + case `update-update`: + // Update after update: replace with latest, union changes + return { + ...incoming, + original: existing.original, + changes: { ...existing.changes, ...incoming.changes }, + } + + case `delete-delete`: + case `insert-insert`: + // Same type: replace with latest + return incoming + } +} +``` + +### How Mutations Get Merged +**File**: `/home/user/db/packages/db/src/transactions.ts` (lines 323-345) + +```typescript +applyMutations(mutations: Array>): void { + for (const newMutation of mutations) { + // Find existing mutation with same globalKey + const existingIndex = this.mutations.findIndex( + (m) => m.globalKey === newMutation.globalKey + ) + + if (existingIndex >= 0) { + // Merge or remove if cancel + const existingMutation = this.mutations[existingIndex]! + const mergeResult = mergePendingMutations(existingMutation, newMutation) + + if (mergeResult === null) { + // Remove the mutation (cancel) + this.mutations.splice(existingIndex, 1) + } else { + // Replace with merged mutation + this.mutations[existingIndex] = mergeResult + } + } else { + // Insert new mutation + this.mutations.push(newMutation) + } + } +} +``` + +## 5. Insert Operation + +### Complete Insert Flow +**File**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 154-243) + +```typescript +insert = (data: TInput | Array, config?: InsertConfig) => { + this.lifecycle.validateCollectionUsable(`insert`) + const state = this.state + const ambientTransaction = getActiveTransaction() + + // Check for handler + if (!ambientTransaction && !this.config.onInsert) { + throw new MissingInsertHandlerError() + } + + const items = Array.isArray(data) ? data : [data] + const mutations: Array> = [] + + // Create mutations for each item + items.forEach((item) => { + const validatedData = this.validateData(item, `insert`) + const key = this.config.getKey(validatedData) + + if (this.state.has(key)) { + throw new DuplicateKeyError(key) + } + + const globalKey = this.generateGlobalKey(key, item) + + const mutation: PendingMutation = { + mutationId: crypto.randomUUID(), + original: {}, + modified: validatedData, + changes: Object.fromEntries( + Object.keys(item).map((k) => [ + k, + validatedData[k as keyof typeof validatedData], + ]) + ) as TInput, + globalKey, + key, + metadata: config?.metadata as unknown, + syncMetadata: this.config.sync.getSyncMetadata?.() || {}, + optimistic: config?.optimistic ?? true, + type: `insert`, + createdAt: new Date(), + updatedAt: new Date(), + collection: this.collection, + } + + mutations.push(mutation) + }) + + // If ambient transaction, use it; otherwise create one + if (ambientTransaction) { + ambientTransaction.applyMutations(mutations) + state.transactions.set(ambientTransaction.id, ambientTransaction) + state.scheduleTransactionCleanup(ambientTransaction) + state.recomputeOptimisticState(true) + return ambientTransaction + } else { + // Create transaction with onInsert handler + const directOpTransaction = createTransaction({ + mutationFn: async (params) => { + return await this.config.onInsert!({ + transaction: params.transaction as unknown as TransactionWithMutations< + TOutput, + `insert` + >, + collection: this.collection as unknown as Collection, + }) + }, + }) + + directOpTransaction.applyMutations(mutations) + directOpTransaction.commit().catch(() => undefined) + state.transactions.set(directOpTransaction.id, directOpTransaction) + state.scheduleTransactionCleanup(directOpTransaction) + state.recomputeOptimisticState(true) + + return directOpTransaction + } +} +``` + +## 6. Transaction Lifecycle + +### Transaction States +**File**: `/home/user/db/packages/db/src/transactions.ts` (lines 207-235) + +```typescript +class Transaction> { + public id: string // UUID + public state: TransactionState // pending|persisting|completed|failed + public mutationFn: MutationFn + public mutations: Array> + public isPersisted: Deferred> + public autoCommit: boolean + public createdAt: Date + public sequenceNumber: number + public metadata: Record + public error?: { message: string; error: Error } + + constructor(config: TransactionConfig) { + this.id = config.id ?? crypto.randomUUID() + this.mutationFn = config.mutationFn + this.state = `pending` + this.mutations = [] + this.isPersisted = createDeferred>() + this.autoCommit = config.autoCommit ?? true + this.createdAt = new Date() + this.sequenceNumber = sequenceNumber++ + this.metadata = config.metadata ?? {} + } +} +``` + +### Commit Flow +**File**: `/home/user/db/packages/db/src/transactions.ts` (lines 468-514) + +```typescript +async commit(): Promise> { + if (this.state !== `pending`) { + throw new TransactionNotPendingCommitError() + } + + this.setState(`persisting`) + + if (this.mutations.length === 0) { + this.setState(`completed`) + this.isPersisted.resolve(this) + return this + } + + try { + // Call the mutation function + await this.mutationFn({ + transaction: this as unknown as TransactionWithMutations, + }) + + this.setState(`completed`) + this.touchCollection() + this.isPersisted.resolve(this) + } catch (error) { + const originalError = + error instanceof Error ? error : new Error(String(error)) + + this.error = { + message: originalError.message, + error: originalError, + } + + this.rollback() + throw originalError + } + + return this +} +``` + +## 7. Issue #19: Current Workaround + +### Manual View Key Pattern +**File**: `/home/user/db/docs/guides/mutations.md` (lines 1130-1201) + +```typescript +// User-maintained view key mapping +const idToViewKey = new Map() + +function getViewKey(id: number | string): string { + if (!idToViewKey.has(id)) { + idToViewKey.set(id, crypto.randomUUID()) + } + return idToViewKey.get(id)! +} + +function linkIds(tempId: number, realId: number) { + const viewKey = getViewKey(tempId) + idToViewKey.set(realId, viewKey) // Link both IDs +} + +const todoCollection = createCollection({ + id: "todos", + onInsert: async ({ transaction }) => { + const mutation = transaction.mutations[0] + const tempId = mutation.modified.id + + const response = await api.todos.create(mutation.modified) + const realId = response.id + + linkIds(tempId, realId) + await todoCollection.utils.refetch() + }, +}) + +// Usage in render +const TodoList = () => { + const { data: todos } = useLiveQuery((q) => + q.from({ todo: todoCollection }) + ) + + return ( +
      + {todos.map((todo) => ( +
    • // Stable key! + {todo.text} +
    • + ))} +
    + ) +} +``` + +## 8. Key Type and Configuration + +### Collection Configuration Type +**File**: `/home/user/db/packages/db/src/types.ts` (lines 385-595) + +```typescript +export interface BaseCollectionConfig< + T extends object = Record, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, + TUtils extends UtilsRecord = UtilsRecord, +> { + id?: string + schema?: TSchema + + // REQUIRED: Extract ID from item + getKey: (item: T) => TKey + + // Optional handlers + onInsert?: InsertMutationFn + onUpdate?: UpdateMutationFn + onDelete?: DeleteMutationFn + + // Comparison for ordering + compare?: (x: T, y: T) => number + + // Other options... + gcTime?: number + autoIndex?: `off` | `eager` + startSync?: boolean + syncMode?: SyncMode + utils?: TUtils +} + +export interface CollectionConfig< + T extends object = Record, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, + TUtils extends UtilsRecord = UtilsRecord, +> extends BaseCollectionConfig { + sync: SyncConfig // REQUIRED +} +``` + diff --git a/DESIGN_ISSUE_19_STABLE_VIEWKEYS.md b/DESIGN_ISSUE_19_STABLE_VIEWKEYS.md new file mode 100644 index 000000000..5faae974f --- /dev/null +++ b/DESIGN_ISSUE_19_STABLE_VIEWKEYS.md @@ -0,0 +1,513 @@ +# Design: Stable ViewKeys for Temporary ID Transitions (Issue #19) + +## Problem Statement + +When inserting items with temporary IDs (before server assigns real IDs), two critical UX issues occur: + +1. **UI Flicker**: React unmounts/remounts components when the key changes from temporary to real ID +2. **Operation Failures**: Subsequent operations (delete/update) fail if they use the temporary ID before sync completes + +Currently, developers must manually maintain a mapping from IDs to stable view keys, which is error-prone and boilerplate-heavy. + +## Goals + +1. **Automatic**: View keys should be automatically generated and tracked by collections +2. **Opt-in**: Backward compatible - only used when explicitly requested +3. **Simple API**: Easy to use with minimal boilerplate +4. **Type-safe**: Fully typed with good TypeScript support +5. **Performant**: O(1) lookups with no significant memory overhead + +## Design Overview + +### 1. Core Storage: ViewKey Mapping + +Add a `viewKeyMap` to `CollectionStateManager` that maintains stable view keys across ID transitions: + +```typescript +// In collection/state.ts +export class CollectionStateManager { + // Existing storage + public syncedData: Map | SortedMap + public optimisticUpserts = new Map() + public optimisticDeletes = new Set() + public syncedMetadata = new Map() + + // NEW: ViewKey mapping + public viewKeyMap = new Map() // Maps both temp and real IDs to stable viewKey +} +``` + +### 2. Collection Configuration (Opt-in) + +Add optional `viewKey` configuration to enable the feature: + +```typescript +// In types.ts +interface BaseCollectionConfig { + // Existing config... + getKey: (item: T) => TKey + + // NEW: ViewKey configuration (opt-in) + viewKey?: { + // Auto-generate view keys on insert + generate?: (item: T) => string + + // Or always use a specific field from the item as viewKey + field?: keyof T + } +} +``` + +**Usage patterns:** + +```typescript +// Pattern 1: Auto-generate UUIDs (most common) +const todoCollection = createCollection({ + id: "todos", + getKey: (item) => item.id, + viewKey: { + generate: () => crypto.randomUUID() // Auto-generate stable keys + }, +}) + +// Pattern 2: Use existing stable field (like a UUID field separate from ID) +const postCollection = createCollection({ + id: "posts", + getKey: (item) => item.id, + viewKey: { + field: 'uuid' // Use item.uuid as viewKey + }, +}) + +// Pattern 3: No viewKey (backward compatible - defaults to using key as viewKey) +const userCollection = createCollection({ + id: "users", + getKey: (item) => item.id, + // No viewKey config - uses key directly (current behavior) +}) +``` + +### 3. ViewKey Generation on Insert + +Automatically generate view keys when items are inserted: + +```typescript +// In collection/mutations.ts - CollectionMutationsManager.insert() +public insert( + item: T | T[], + options?: { metadata?: unknown; optimistic?: boolean } +): Transaction { + const items = Array.isArray(item) ? item : [item] + + return this.withTransaction((transaction) => { + const mutations = items.map((item) => { + const key = this.config.getKey(item) + + // NEW: Generate viewKey if configured + let viewKey: string | undefined + if (this.config.viewKey) { + if (this.config.viewKey.generate) { + viewKey = this.config.viewKey.generate(item) + } else if (this.config.viewKey.field) { + viewKey = String(item[this.config.viewKey.field]) + } + + // Store viewKey mapping + if (viewKey) { + this.state.viewKeyMap.set(key, viewKey) + } + } + + // Create mutation with viewKey + const mutation: PendingMutation = { + // ... existing fields + viewKey, // NEW field + // ... rest + } + + return mutation + }) + + // ... rest of insert logic + }) +} +``` + +### 4. ViewKey Linking API + +Provide a new method to link temporary IDs to real IDs during sync: + +```typescript +// In collection/mutations.ts - CollectionMutationsManager +public linkViewKeys(mapping: { tempKey: TKey; realKey: TKey }[]): void { + mapping.forEach(({ tempKey, realKey }) => { + const viewKey = this.state.viewKeyMap.get(tempKey) + if (viewKey) { + // Link real key to the same viewKey + this.state.viewKeyMap.set(realKey, viewKey) + // Keep temp key mapping for brief period (helps with race conditions) + // Could optionally delete tempKey after a delay + } + }) +} +``` + +**Alternative: Auto-detect ID transitions** (more magical but potentially fragile): + +```typescript +// In collection/state.ts - during sync +private detectIdTransitions(syncedItems: T[]): void { + // Detect when optimistic item with tempId is replaced by synced item with realId + // This would compare optimistic items to incoming synced items by content similarity + // More complex but requires no manual linking +} +``` + +### 5. Public API: getViewKey() + +Expose a method to retrieve view keys for rendering: + +```typescript +// In collection/index.ts - Collection interface +interface Collection { + // Existing methods... + insert(item: T | T[], options?: InsertOptions): Transaction + update(key: TKey | TKey[], ...): Transaction + delete(key: TKey | TKey[], ...): Transaction + + // NEW: Get stable viewKey for an item + getViewKey(key: TKey): string +} + +// Implementation in CollectionImpl +public getViewKey(key: TKey): string { + // Return mapped viewKey if exists, otherwise fall back to key + const viewKey = this.state.viewKeyMap.get(key) + return viewKey ?? String(key) +} +``` + +### 6. Include ViewKey in Change Events + +Add viewKey to change messages so subscribers can use stable keys: + +```typescript +// In types.ts +export interface ChangeMessage { + key: TKey + value: T + previousValue?: T + type: OperationType + metadata?: Record + viewKey?: string // NEW: Stable view key for rendering +} + +// In collection/change-events.ts - when emitting changes +const changeMessage: ChangeMessage = { + key, + value, + previousValue, + type: 'insert', + metadata: mutation.metadata, + viewKey: mutation.viewKey ?? this.getViewKey(key), // Include viewKey +} +``` + +### 7. PendingMutation Type Update + +Add viewKey to mutation type: + +```typescript +// In types.ts +export interface PendingMutation { + // Existing fields... + mutationId: string + original: T | {} + modified: T + changes: ResolveTransactionChanges + globalKey: string + key: any + type: 'insert' | 'update' | 'delete' + metadata: unknown + syncMetadata: Record + optimistic: boolean + createdAt: Date + updatedAt: Date + collection: Collection + + // NEW: Stable view key + viewKey?: string +} +``` + +## Usage Examples + +### Example 1: Basic Usage with Auto-Generated ViewKeys + +```typescript +import { createCollection, queryCollectionOptions } from '@tanstack/react-db' + +const todoCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => api.todos.getAll(), + getKey: (item) => item.id, + + // Enable auto-generated view keys + viewKey: { + generate: () => crypto.randomUUID() + }, + + onInsert: async ({ transaction }) => { + const mutation = transaction.mutations[0] + const tempId = mutation.modified.id + + // Create on server + const response = await api.todos.create(mutation.modified) + const realId = response.id + + // Link temporary ID to real ID + todoCollection.linkViewKeys([{ tempKey: tempId, realKey: realId }]) + + // Wait for sync + await todoCollection.utils.refetch() + }, + }) +) + +// In component +function TodoList() { + const { data: todos } = useLiveQuery((q) => + q.from({ todo: todoCollection }) + ) + + return ( +
      + {todos.map((todo) => ( + // Use stable viewKey instead of id +
    • + {todo.text} +
    • + ))} +
    + ) +} + +// Insert with temporary ID +const tempId = -Date.now() +todoCollection.insert({ + id: tempId, + text: 'New todo', + completed: false, +}) + +// Delete immediately (works even before sync completes) +todoCollection.delete(tempId) // Uses same temp key, no 404 +``` + +### Example 2: Using Existing UUID Field + +```typescript +interface Post { + id: number // Server-generated sequential ID + uuid: string // Client-generated UUID (stable) + title: string +} + +const postCollection = createCollection({ + id: "posts", + getKey: (item) => item.id, + + // Use existing uuid field as viewKey + viewKey: { + field: 'uuid' + }, + + onInsert: async ({ transaction }) => { + const mutation = transaction.mutations[0] + const tempId = mutation.modified.id + + const response = await api.posts.create(mutation.modified) + + // Link temp ID to real ID + postCollection.linkViewKeys([{ tempKey: tempId, realKey: response.id }]) + + await postCollection.utils.refetch() + }, +}) + +// Insert with both temp ID and stable UUID +postCollection.insert({ + id: -Date.now(), // Temporary ID + uuid: crypto.randomUUID(), // Stable UUID for viewKey + title: 'New Post', +}) +``` + +### Example 3: Batch Insert with Multiple ID Mappings + +```typescript +const batchInsertTodos = async (texts: string[]) => { + // Create temp items with viewKeys + const tempItems = texts.map(text => ({ + id: -Date.now() - Math.random(), + text, + completed: false, + })) + + // Insert optimistically + const tx = todoCollection.insert(tempItems) + + // Persist to server + const response = await api.todos.batchCreate(tempItems) + + // Link all temp IDs to real IDs + const mappings = tempItems.map((item, index) => ({ + tempKey: item.id, + realKey: response[index].id, + })) + + todoCollection.linkViewKeys(mappings) + + // Sync back + await todoCollection.utils.refetch() + + await tx.isPersisted.promise +} +``` + +## Implementation Plan + +### Phase 1: Core Infrastructure (Required for MVP) + +1. **Add viewKeyMap to CollectionStateManager** + - File: `packages/db/src/collection/state.ts` + - Add: `public viewKeyMap = new Map()` + +2. **Add viewKey to PendingMutation type** + - File: `packages/db/src/types.ts` + - Add: `viewKey?: string` to `PendingMutation` interface + +3. **Add viewKey config to BaseCollectionConfig** + - File: `packages/db/src/types.ts` + - Add: `viewKey?: { generate?: (item: T) => string; field?: keyof T }` to config + +4. **Implement viewKey generation in insert()** + - File: `packages/db/src/collection/mutations.ts` + - Update `insert()` method to generate and store viewKeys + +5. **Add getViewKey() public method** + - File: `packages/db/src/collection/index.ts` + - Expose `getViewKey(key: TKey): string` on Collection interface + +6. **Add linkViewKeys() method** + - File: `packages/db/src/collection/mutations.ts` + - Implement `linkViewKeys(mapping: Array<{ tempKey: TKey; realKey: TKey }>): void` + +### Phase 2: Change Events (Nice to have) + +7. **Include viewKey in ChangeMessage** + - File: `packages/db/src/types.ts` + - Add: `viewKey?: string` to `ChangeMessage` interface + +8. **Emit viewKey in change events** + - File: `packages/db/src/collection/change-events.ts` + - Include viewKey when creating change messages + +### Phase 3: Documentation & Testing + +9. **Update mutations.md documentation** + - Replace manual workaround with new built-in API + - Add examples and best practices + +10. **Add tests** + - Test viewKey generation + - Test linkViewKeys() with temp → real ID transitions + - Test getViewKey() fallback behavior + - Test backward compatibility (no viewKey config) + +## Backward Compatibility + +- **No breaking changes**: All new features are opt-in +- **Default behavior unchanged**: Collections without `viewKey` config work as before +- **Graceful fallback**: `getViewKey()` returns `String(key)` when no viewKey is configured + +## Alternative Approaches Considered + +### Alternative 1: Auto-detect ID transitions + +**Pros:** +- No manual linking required +- More "magical" DX + +**Cons:** +- Complex heuristics needed to match optimistic items to synced items +- Risk of false positives/negatives +- Hard to debug when detection fails +- Performance overhead + +**Decision:** Rejected in favor of explicit linking for reliability + +### Alternative 2: Add viewKey field to items themselves + +**Pros:** +- Simpler storage (no separate map) +- ViewKey persists with item data + +**Cons:** +- Pollutes user's data model +- Requires schema changes +- Not backward compatible +- ViewKey would sync to server unnecessarily + +**Decision:** Rejected - keep viewKey in collection metadata + +### Alternative 3: Transaction-level viewKey API + +```typescript +transaction.mapViewKey({ tempId, realId }) +``` + +**Pros:** +- Transaction-scoped (matches issue proposal) + +**Cons:** +- Less discoverable API +- Requires transaction reference +- Less flexible (what if user wants to link outside transaction?) + +**Decision:** Use collection-level API for better discoverability + +## Open Questions + +1. **ViewKey cleanup**: Should we automatically remove viewKey mappings for temp IDs after they're replaced? + - **Recommendation**: Keep temp mapping for ~1 second to handle race conditions, then clean up + +2. **ViewKey persistence**: Should viewKeys persist to localStorage for LocalStorageCollection? + - **Recommendation**: Yes, store viewKeyMap alongside data for consistency + +3. **ViewKey in queries**: Should query results include viewKey automatically? + - **Recommendation**: No, keep it opt-in via `getViewKey()`. Queries return data as-is. + +4. **Multiple temp → real transitions**: What if an item's ID changes multiple times? + - **Recommendation**: viewKey stays stable across all transitions (that's the point!) + +## Success Criteria + +1. Users can enable viewKey generation with single config option +2. Temp → real ID transitions don't cause UI flicker +3. `getViewKey()` provides stable keys for React rendering +4. Zero breaking changes to existing codebases +5. Documentation clearly explains usage and best practices + +## Timeline Estimate + +- **Phase 1 (Core)**: 2-3 days (6 changes) +- **Phase 2 (Events)**: 1 day (2 changes) +- **Phase 3 (Docs/Tests)**: 1-2 days +- **Total**: 4-6 days for full implementation + +## Related Issues + +- Issue #19: https://github.com/TanStack/db/issues/19 +- Documentation: /home/user/db/docs/guides/mutations.md (lines 1045-1211) diff --git a/KEY_FILES_REFERENCE.md b/KEY_FILES_REFERENCE.md new file mode 100644 index 000000000..6a74e89af --- /dev/null +++ b/KEY_FILES_REFERENCE.md @@ -0,0 +1,111 @@ +# TanStack DB - Key Files Quick Reference + +## File Location Map for Issue #19 + +### 1. Where Items Are Stored +- **File**: `/home/user/db/packages/db/src/collection/state.ts` +- **Key Classes**: `CollectionStateManager` +- **Storage Fields**: + - `syncedData: Map | SortedMap` - Server truth + - `optimisticUpserts: Map` - Pending changes + - `optimisticDeletes: Set` - Pending deletions + - `syncedMetadata: Map` - Metadata per item + +### 2. How Mutations Are Structured +- **File**: `/home/user/db/packages/db/src/types.ts` (lines 57-86) +- **Interface**: `PendingMutation` +- **Key Fields**: + ```typescript + mutationId: string // UUID + key: any // User's item ID + globalKey: string // "KEY::{collectionId}/{key}" + modified: T // Final state + changes: Partial // Only changed fields + original: T | {} // Pre-mutation state + metadata: unknown // User metadata + syncMetadata: Record // Sync metadata + optimistic: boolean // Apply immediately? + type: 'insert' | 'update' | 'delete' // Operation type + ``` + +### 3. How Transactions Work +- **File**: `/home/user/db/packages/db/src/transactions.ts` +- **Key Class**: `Transaction` +- **Merging Logic**: `mergePendingMutations()` (lines 41-101) +- **Key Methods**: + - `applyMutations()` - Add/merge mutations (lines 323-345) + - `commit()` - Persist to backend (lines 468-514) + - `rollback()` - Revert changes (lines 385-410) + +### 4. Insert/Update/Delete Operations +- **File**: `/home/user/db/packages/db/src/collection/mutations.ts` +- **Key Class**: `CollectionMutationsManager` +- **Key Methods**: + - `insert()` - Create new items (lines 154-243) + - `update()` - Modify items (lines 248-438) + - `delete()` - Remove items (lines 443-538) +- **Global Key Generation**: `generateGlobalKey()` (lines 143-149) + +### 5. Type Definitions +- **File**: `/home/user/db/packages/db/src/types.ts` +- **Key Exports**: + - `PendingMutation` - Mutation structure + - `TransactionConfig` - Transaction options + - `ChangeMessage` - Change event structure + - `BaseCollectionConfig` - Collection options + - `OperationType` - 'insert' | 'update' | 'delete' + +## Critical Concepts for Issue #19 + +### Current ID/Key Model +1. **User provides**: `getKey: (item: T) => TKey` +2. **TKey type**: string | number +3. **Key immutability**: Updates cannot change the key (throws error) +4. **Global key format**: `KEY::{collectionId}/{key}` used for deduplication + +### The View Key Problem +- **Issue**: Temporary IDs become real IDs during sync +- **Current workaround**: Manual mapping in user code +- **Goal**: Automate this with built-in view keys + +### Proposed Solution Sketch +1. Add optional `viewKey?: string` to `PendingMutation` +2. Generate viewKey on insert in `CollectionMutationsManager.insert()` +3. Maintain `viewKeyMap: Map` in `CollectionStateManager` +4. Link temp ID viewKey to real ID viewKey in sync operations +5. Expose `getViewKey(key: TKey): string` on collection + +## Key Data Structures to Understand + +### SortedMap +- **File**: `/home/user/db/packages/db/src/SortedMap.ts` +- **Use**: Optional ordered storage of items +- **Created when**: `config.compare` function provided +- **Time complexity**: O(log n) insertion via binary search + +### Change Proxy +- **File**: `/home/user/db/packages/db/src/proxy.ts` +- **Purpose**: Tracks changes to items using Immer-like pattern +- **Used in**: `update()` method to capture property changes + +### Event System +- **File**: `/home/user/db/packages/db/src/collection/changes.ts` +- **Class**: `CollectionChangesManager` +- **Emits**: `ChangeMessage` events for mutations + +## Testing Files (for reference) +Located in: `/home/user/db/packages/db/tests/` + +Useful test patterns: +- Collection creation and basic operations +- Mutation merging behavior +- Transaction lifecycle +- Sync operations +- State management + +## Related Files Not Yet Explored +- **Sync operations**: `/home/user/db/packages/db/src/collection/sync.ts` +- **Query system**: `/home/user/db/packages/db/src/query/` +- **Index management**: `/home/user/db/packages/db/src/indexes/` +- **LocalStorage collection**: `/home/user/db/packages/db/src/local-storage.ts` + From c029be7c2be38b407d92739c505198e891ef67eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 15:36:55 +0000 Subject: [PATCH 02/11] Implement stable viewKeys for temporary-to-real ID transitions (fixes #19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds built-in support for stable view keys that prevent UI re-renders when optimistic inserts transition from temporary IDs to real server-generated IDs. **Core Changes:** 1. **Storage**: Added `viewKeyMap: Map` to CollectionStateManager to track stable view keys for items 2. **Configuration**: Added optional `viewKey` config to collections with two modes: - `generate`: Auto-generate viewKeys (e.g., `() => crypto.randomUUID()`) - `field`: Use existing field from item (e.g., `field: 'uuid'`) 3. **Type Definitions**: - Added `viewKey?: string` to PendingMutation interface - Added `viewKey?: string` to ChangeMessage interface - Added `viewKey` configuration to BaseCollectionConfig 4. **Mutation Handling**: Modified insert() to generate and store viewKeys based on configuration 5. **Public API**: Added two new collection methods: - `getViewKey(key)`: Returns stable viewKey for any key (temp or real) - `mapViewKey(tempKey, realKey)`: Links temp and real IDs to same viewKey 6. **Change Events**: Updated all change event emission to include viewKey in both optimistic and synced state changes **Usage Example:** ```typescript const todoCollection = createCollection({ getKey: (item) => item.id, viewKey: { generate: () => crypto.randomUUID() }, onInsert: async ({ transaction }) => { const tempId = transaction.mutations[0].modified.id const response = await api.create(...) // Link temp to real ID todoCollection.mapViewKey(tempId, response.id) await todoCollection.utils.refetch() }, }) // Use stable keys in React
  • ``` **Documentation**: Updated mutations.md to replace manual workaround with new built-in API, including usage examples and best practices. **Design Decisions:** - Opt-in via configuration (backward compatible) - Explicit linking via mapViewKey() for reliability - ViewKeys stored in collection metadata (not in items) - Mappings kept indefinitely (tiny memory footprint) Fixes #19 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/guides/mutations.md | 88 +++++++++++++++---------- packages/db/src/collection/index.ts | 51 ++++++++++++++ packages/db/src/collection/mutations.ts | 16 +++++ packages/db/src/collection/state.ts | 24 +++++-- packages/db/src/types.ts | 27 ++++++++ 5 files changed, 168 insertions(+), 38 deletions(-) diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 24e8aba92..5ab086ef3 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -1127,30 +1127,19 @@ const TodoItem = ({ todo, isPersisted }: { todo: Todo, isPersisted: boolean }) = } ``` -### Solution 3: Maintain a View Key Mapping +### Solution 3: Use Built-in Stable View Keys -To avoid UI flicker while keeping optimistic updates, maintain a separate mapping from IDs (both temporary and real) to stable view keys: +TanStack DB provides built-in support for stable view keys that prevent UI flicker during ID transitions: ```tsx -// Create a mapping API -const idToViewKey = new Map() - -function getViewKey(id: number | string): string { - if (!idToViewKey.has(id)) { - idToViewKey.set(id, crypto.randomUUID()) - } - return idToViewKey.get(id)! -} - -function linkIds(tempId: number, realId: number) { - const viewKey = getViewKey(tempId) - idToViewKey.set(realId, viewKey) -} - -// Configure collection to link IDs when real ID comes back +// Configure collection with automatic view key generation const todoCollection = createCollection({ id: "todos", - // ... other options + getKey: (item) => item.id, + // Enable automatic view key generation + viewKey: { + generate: () => crypto.randomUUID() + }, onInsert: async ({ transaction }) => { const mutation = transaction.mutations[0] const tempId = mutation.modified.id @@ -1162,17 +1151,16 @@ const todoCollection = createCollection({ }) const realId = response.id - // Link temp ID to same view key as real ID - linkIds(tempId, realId) + // Link temp ID to real ID (they share the same viewKey) + todoCollection.mapViewKey(tempId, realId) // Wait for sync back await todoCollection.utils.refetch() }, }) -// When inserting with temp ID +// Insert with temp ID - viewKey is automatically generated const tempId = -Math.floor(Math.random() * 1000000) + 1 -const viewKey = getViewKey(tempId) // Creates and stores mapping todoCollection.insert({ id: tempId, @@ -1180,7 +1168,7 @@ todoCollection.insert({ completed: false }) -// Use view key for rendering +// Use getViewKey() for stable rendering keys const TodoList = () => { const { data: todos } = useLiveQuery((q) => q.from({ todo: todoCollection }) @@ -1189,7 +1177,7 @@ const TodoList = () => { return (
      {todos.map((todo) => ( -
    • {/* Stable key */} +
    • {/* Stable key! */} {todo.text}
    • ))} @@ -1198,14 +1186,48 @@ const TodoList = () => { } ``` -This pattern maintains a stable key throughout the temporary → real ID transition, preventing your UI framework from unmounting and remounting the component. The view key is stored outside the collection items, so you don't need to add extra fields to your data model. +**How it works:** -### Best Practices +1. The `viewKey.generate` function creates a stable UUID when items are inserted +2. `mapViewKey(tempId, realId)` links the temporary and real IDs to share the same viewKey +3. `getViewKey(id)` returns the stable viewKey for any ID (temp or real) +4. React uses the stable viewKey, preventing unmount/remount during ID transitions -1. **Use UUIDs when possible**: Client-generated UUIDs eliminate the temporary ID problem -2. **Generate temporary IDs deterministically**: Use negative numbers or a specific pattern to distinguish temporary IDs from real ones -3. **Disable operations on temporary items**: Disable delete/update buttons until persistence completes -4. **Maintain view key mappings**: Create a mapping between IDs and stable view keys for rendering +**Alternative: Use existing field as viewKey** -> [!NOTE] -> There's an [open issue](https://github.com/TanStack/db/issues/19) to add better built-in support for temporary ID handling in TanStack DB. This would automate the view key pattern and make it easier to work with server-generated IDs. +If your items already have a stable UUID field separate from the ID: + +```tsx +interface Todo { + id: number // Server-generated sequential ID + uuid: string // Client-generated UUID (stable) + text: string + completed: boolean +} + +const todoCollection = createCollection({ + id: "todos", + getKey: (item) => item.id, + // Use existing uuid field as the viewKey + viewKey: { + field: 'uuid' + }, + // ... +}) + +// Insert with both temp ID and stable UUID +todoCollection.insert({ + id: -Date.now(), + uuid: crypto.randomUUID(), + text: "New todo", + completed: false +}) +``` + +### Best Practices + +1. **Use UUIDs when possible**: Client-generated UUIDs as IDs eliminate the temporary ID problem entirely +2. **Enable viewKeys for server-generated IDs**: Use the `viewKey` config option to enable automatic stable keys +3. **Link IDs in insert handlers**: Always call `mapViewKey(tempId, realId)` after getting the real ID from the server +4. **Use getViewKey() in renders**: Call `collection.getViewKey(item.id)` for React keys instead of using the ID directly +5. **Generate temporary IDs deterministically**: Use negative numbers or a specific pattern to distinguish temporary IDs from real ones diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 5a4ab3c07..4625b6f76 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -450,6 +450,57 @@ export class CollectionImpl< return this.config.getKey(item) } + /** + * Get a stable view key for a given item key. + * If viewKey configuration is enabled, returns the stable viewKey. + * Otherwise, returns the key as a string (backward compatible behavior). + * + * @param key - The item key to get the view key for + * @returns The stable view key as a string + * + * @example + * // Use in React components for stable keys during ID transitions + * {todos.map((todo) => ( + *
    • + * {todo.text} + *
    • + * ))} + */ + public getViewKey(key: TKey): string { + const viewKey = this._state.viewKeyMap.get(key) + return viewKey ?? String(key) + } + + /** + * Link a temporary key to a real key, maintaining the same stable viewKey. + * This is used when server-generated IDs replace temporary client IDs. + * + * @param tempKey - The temporary key used during optimistic insert + * @param realKey - The real key assigned by the server + * + * @example + * // In your insert handler, link temp ID to real ID + * onInsert: async ({ transaction }) => { + * const mutation = transaction.mutations[0] + * const tempId = mutation.modified.id + * + * const response = await api.todos.create(mutation.modified) + * const realId = response.id + * + * // Link the IDs so they share the same viewKey + * collection.mapViewKey(tempId, realId) + * + * await collection.utils.refetch() + * } + */ + public mapViewKey(tempKey: TKey, realKey: TKey): void { + const viewKey = this._state.viewKeyMap.get(tempKey) + if (viewKey) { + // Link real key to the same viewKey + this._state.viewKeyMap.set(realKey, viewKey) + } + } + /** * Creates an index on a collection for faster queries. * Indexes significantly improve query performance by allowing constant time lookups diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 278234f47..ca0d0e5c2 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -176,6 +176,21 @@ export class CollectionMutationsManager< } const globalKey = this.generateGlobalKey(key, item) + // Generate viewKey if configured + let viewKey: string | undefined + if (this.config.viewKey) { + if (this.config.viewKey.generate) { + viewKey = this.config.viewKey.generate(validatedData) + } else if (this.config.viewKey.field) { + viewKey = String(validatedData[this.config.viewKey.field]) + } + + // Store viewKey mapping + if (viewKey) { + this.state.viewKeyMap.set(key, viewKey) + } + } + const mutation: PendingMutation = { mutationId: crypto.randomUUID(), original: {}, @@ -198,6 +213,7 @@ export class CollectionMutationsManager< createdAt: new Date(), updatedAt: new Date(), collection: this.collection, + viewKey, } mutations.push(mutation) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index f7b03da33..4c64b7513 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -50,6 +50,9 @@ export class CollectionStateManager< public optimisticUpserts = new Map() public optimisticDeletes = new Set() + // ViewKey mapping for stable rendering keys across ID transitions + public viewKeyMap = new Map() + // Cached size for performance public size = 0 @@ -380,10 +383,13 @@ export class CollectionStateManager< previousDeletes ) + // Get viewKey if available + const viewKey = this.viewKeyMap.get(key) + if (previousValue !== undefined && currentValue === undefined) { - events.push({ type: `delete`, key, value: previousValue }) + events.push({ type: `delete`, key, value: previousValue, viewKey }) } else if (previousValue === undefined && currentValue !== undefined) { - events.push({ type: `insert`, key, value: currentValue }) + events.push({ type: `insert`, key, value: currentValue, viewKey }) } else if ( previousValue !== undefined && currentValue !== undefined && @@ -394,6 +400,7 @@ export class CollectionStateManager< key, value: currentValue, previousValue, + viewKey, }) } } @@ -512,7 +519,8 @@ export class CollectionStateManager< truncateOptimisticSnapshot?.upserts.get(key) || this.syncedData.get(key) if (previousValue !== undefined) { - events.push({ type: `delete`, key, value: previousValue }) + const viewKey = this.viewKeyMap.get(key) + events.push({ type: `delete`, key, value: previousValue, viewKey }) } } @@ -619,10 +627,12 @@ export class CollectionStateManager< } } if (!foundInsert) { - events.push({ type: `insert`, key, value }) + const viewKey = this.viewKeyMap.get(key) + events.push({ type: `insert`, key, value, viewKey }) } } else { - events.push({ type: `insert`, key, value }) + const viewKey = this.viewKeyMap.get(key) + events.push({ type: `insert`, key, value, viewKey }) } } @@ -739,6 +749,7 @@ export class CollectionStateManager< } if (!isRedundantSync) { + const viewKey = this.viewKeyMap.get(key) if ( previousVisibleValue === undefined && newVisibleValue !== undefined @@ -747,6 +758,7 @@ export class CollectionStateManager< type: `insert`, key, value: newVisibleValue, + viewKey, }) } else if ( previousVisibleValue !== undefined && @@ -756,6 +768,7 @@ export class CollectionStateManager< type: `delete`, key, value: previousVisibleValue, + viewKey, }) } else if ( previousVisibleValue !== undefined && @@ -767,6 +780,7 @@ export class CollectionStateManager< key, value: newVisibleValue, previousValue: previousVisibleValue, + viewKey, }) } } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 73c1fc4fe..c8a5ecd65 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -83,6 +83,8 @@ export interface PendingMutation< createdAt: Date updatedAt: Date collection: TCollection + /** Stable view key for rendering (survives ID transitions) */ + viewKey?: string } /** @@ -267,6 +269,8 @@ export interface ChangeMessage< previousValue?: T type: OperationType metadata?: Record + /** Stable view key for rendering (survives ID transitions) */ + viewKey?: string } export interface OptimisticChangeMessage< @@ -582,6 +586,29 @@ export interface BaseCollectionConfig< */ onDelete?: DeleteMutationFn + /** + * Optional configuration for stable view keys to prevent UI re-renders during + * temporary-to-real ID transitions. + * + * @example + * // Auto-generate view keys with UUIDs + * viewKey: { + * generate: () => crypto.randomUUID() + * } + * + * @example + * // Use existing field from item as view key + * viewKey: { + * field: 'uuid' + * } + */ + viewKey?: { + /** Function to generate a stable view key for new items */ + generate?: (item: T) => string + /** Use an existing field from the item as the view key */ + field?: keyof T + } + utils?: TUtils } From e3214ec687e0d4ff2ba84bec844a64182d6508af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 15:46:39 +0000 Subject: [PATCH 03/11] Simplify viewKey API: remove field option, make it just a function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As suggested, removed the `field` option since users can already use existing stable fields directly without this feature: ```tsx // They can already do this:
    • ``` **Changes:** 1. **Simplified type**: `viewKey?: (item: T) => string` instead of object with generate/field options 2. **Cleaner config**: `viewKey: () => crypto.randomUUID()` instead of `viewKey: { generate: () => ... }` 3. **Removed field option**: No longer supports `field: 'uuid'` since it added no value 4. **Updated docs**: Removed alternative example using field option The feature now focuses on its core value: **generating** stable keys and **linking** temporary IDs to real IDs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/guides/mutations.md | 37 ++----------------------- packages/db/src/collection/mutations.ts | 11 ++------ packages/db/src/types.ts | 24 +++++----------- 3 files changed, 11 insertions(+), 61 deletions(-) diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 5ab086ef3..3414695bb 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -1137,9 +1137,7 @@ const todoCollection = createCollection({ id: "todos", getKey: (item) => item.id, // Enable automatic view key generation - viewKey: { - generate: () => crypto.randomUUID() - }, + viewKey: () => crypto.randomUUID(), onInsert: async ({ transaction }) => { const mutation = transaction.mutations[0] const tempId = mutation.modified.id @@ -1188,42 +1186,11 @@ const TodoList = () => { **How it works:** -1. The `viewKey.generate` function creates a stable UUID when items are inserted +1. The `viewKey` function creates a stable UUID when items are inserted 2. `mapViewKey(tempId, realId)` links the temporary and real IDs to share the same viewKey 3. `getViewKey(id)` returns the stable viewKey for any ID (temp or real) 4. React uses the stable viewKey, preventing unmount/remount during ID transitions -**Alternative: Use existing field as viewKey** - -If your items already have a stable UUID field separate from the ID: - -```tsx -interface Todo { - id: number // Server-generated sequential ID - uuid: string // Client-generated UUID (stable) - text: string - completed: boolean -} - -const todoCollection = createCollection({ - id: "todos", - getKey: (item) => item.id, - // Use existing uuid field as the viewKey - viewKey: { - field: 'uuid' - }, - // ... -}) - -// Insert with both temp ID and stable UUID -todoCollection.insert({ - id: -Date.now(), - uuid: crypto.randomUUID(), - text: "New todo", - completed: false -}) -``` - ### Best Practices 1. **Use UUIDs when possible**: Client-generated UUIDs as IDs eliminate the temporary ID problem entirely diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index ca0d0e5c2..188c82f4d 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -179,16 +179,9 @@ export class CollectionMutationsManager< // Generate viewKey if configured let viewKey: string | undefined if (this.config.viewKey) { - if (this.config.viewKey.generate) { - viewKey = this.config.viewKey.generate(validatedData) - } else if (this.config.viewKey.field) { - viewKey = String(validatedData[this.config.viewKey.field]) - } - + viewKey = this.config.viewKey(validatedData) // Store viewKey mapping - if (viewKey) { - this.state.viewKeyMap.set(key, viewKey) - } + this.state.viewKeyMap.set(key, viewKey) } const mutation: PendingMutation = { diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index c8a5ecd65..ff6f58677 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -587,27 +587,17 @@ export interface BaseCollectionConfig< onDelete?: DeleteMutationFn /** - * Optional configuration for stable view keys to prevent UI re-renders during - * temporary-to-real ID transitions. + * Optional function to generate stable view keys for items. + * This prevents UI re-renders during temporary-to-real ID transitions. * - * @example - * // Auto-generate view keys with UUIDs - * viewKey: { - * generate: () => crypto.randomUUID() - * } + * When enabled, call `collection.mapViewKey(tempId, realId)` in your + * insert handler to link the temporary and real IDs to the same viewKey. * * @example - * // Use existing field from item as view key - * viewKey: { - * field: 'uuid' - * } + * // Auto-generate view keys with UUIDs + * viewKey: () => crypto.randomUUID() */ - viewKey?: { - /** Function to generate a stable view key for new items */ - generate?: (item: T) => string - /** Use an existing field from the item as the view key */ - field?: keyof T - } + viewKey?: (item: T) => string utils?: TUtils } From 74c3c2f3276dbe6b69b9867644d990dd5a088614 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 15:53:29 +0000 Subject: [PATCH 04/11] Remove WIP docs and add changeset for stable viewKey feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed design exploration documents (CODEBASE_EXPLORATION, DESIGN, etc.) - Added changeset describing the new feature - Added PR_DESCRIPTION.md with title and detailed body for the PR 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/stable-viewkeys-for-temp-ids.md | 30 ++ CODEBASE_EXPLORATION_ISSUE_19.md | 509 -------------------- CODE_SNIPPETS_REFERENCE.md | 450 ------------------ DESIGN_ISSUE_19_STABLE_VIEWKEYS.md | 513 --------------------- KEY_FILES_REFERENCE.md | 111 ----- PR_DESCRIPTION.md | 107 +++++ 6 files changed, 137 insertions(+), 1583 deletions(-) create mode 100644 .changeset/stable-viewkeys-for-temp-ids.md delete mode 100644 CODEBASE_EXPLORATION_ISSUE_19.md delete mode 100644 CODE_SNIPPETS_REFERENCE.md delete mode 100644 DESIGN_ISSUE_19_STABLE_VIEWKEYS.md delete mode 100644 KEY_FILES_REFERENCE.md create mode 100644 PR_DESCRIPTION.md diff --git a/.changeset/stable-viewkeys-for-temp-ids.md b/.changeset/stable-viewkeys-for-temp-ids.md new file mode 100644 index 000000000..8ed40a8ce --- /dev/null +++ b/.changeset/stable-viewkeys-for-temp-ids.md @@ -0,0 +1,30 @@ +--- +"@tanstack/db": minor +--- + +Add stable `viewKey` support to prevent UI re-renders during temporary-to-real ID transitions. When inserting items with temporary IDs that are later replaced by server-generated IDs, React components would previously unmount and remount, causing loss of focus and visual flicker. + +Collections can now be configured with a `viewKey` function to generate stable keys: + +```typescript +const todoCollection = createCollection({ + getKey: (item) => item.id, + viewKey: () => crypto.randomUUID(), + onInsert: async ({ transaction }) => { + const tempId = transaction.mutations[0].modified.id + const response = await api.create(...) + + // Link temporary and real IDs to same viewKey + todoCollection.mapViewKey(tempId, response.id) + await todoCollection.utils.refetch() + }, +}) + +// Use stable keys in React +
    • +``` + +New APIs: +- `collection.getViewKey(key)` - Returns stable viewKey for any key (temporary or real) +- `collection.mapViewKey(tempKey, realKey)` - Links temporary and real IDs to share the same viewKey +- `viewKey` configuration option - Function to generate stable view keys for inserted items diff --git a/CODEBASE_EXPLORATION_ISSUE_19.md b/CODEBASE_EXPLORATION_ISSUE_19.md deleted file mode 100644 index 983373e1b..000000000 --- a/CODEBASE_EXPLORATION_ISSUE_19.md +++ /dev/null @@ -1,509 +0,0 @@ -# TanStack DB Codebase Structure - Issue #19 Exploration - -## Executive Summary - -Issue #19 focuses on implementing **stable view keys** for handling temporary-to-real ID transitions when inserting items where the server generates the final ID. Currently, this requires manual mapping outside the collection. - -## Directory Structure - -``` -/home/user/db/packages/db/src/ -├── collection/ -│ ├── index.ts # Main Collection implementation -│ ├── state.ts # CollectionStateManager - core item storage -│ ├── mutations.ts # CollectionMutationsManager - insert/update/delete -│ ├── transactions.ts # NOT HERE - see root -│ ├── changes.ts # CollectionChangesManager - event emission -│ ├── change-events.ts # Change event generation -│ ├── lifecycle.ts # Collection lifecycle management -│ ├── sync.ts # CollectionSyncManager - sync operations -│ ├── subscription.ts # Subscription management -│ ├── events.ts # Event emission -│ └── indexes.ts # IndexesManager - query optimization -├── transactions.ts # Transaction implementation (mutation grouping) -├── types.ts # Type definitions (PendingMutation, Transaction, etc.) -├── local-storage.ts # LocalStorage collection implementation -├── proxy.ts # Change tracking proxy system -├── SortedMap.ts # Ordered Map implementation -├── event-emitter.ts # Event system -├── scheduler.ts # Async scheduler -├── utils.ts # Utilities -└── indexes/ # Index implementations - ├── base-index.ts - ├── btree-index.ts - ├── lazy-index.ts - └── ... -``` - ---- - -## 1. Collection Item Storage - -### Primary Storage Structure - -**Location**: `/home/user/db/packages/db/src/collection/state.ts` (CollectionStateManager) - -```typescript -// Main stores -public syncedData: Map | SortedMap -public optimisticUpserts = new Map() -public optimisticDeletes = new Set() -public syncedMetadata = new Map() -``` - -**Storage Layers**: -1. **`syncedData`**: Source of truth from the server - - Regular `Map` if no comparator provided - - `SortedMap` if a `compare` function is provided - - Contains confirmed items from sync operations - -2. **`optimisticUpserts`**: Pending insert/update items - - Overlays syncedData - - Cleared when transactions complete/fail - - Re-added if still active - -3. **`optimisticDeletes`**: Items pending deletion - - Set of keys marked for deletion - - Checked in the virtual `get()` method - -### Virtual Derived State Access - -```typescript -// Constructor (lines 73-77) -if (config.compare) { - this.syncedData = new SortedMap(config.compare) -} else { - this.syncedData = new Map() -} - -// Combined view (lines 95-109) -public get(key: TKey): TOutput | undefined { - if (optimisticDeletes.has(key)) return undefined - if (optimisticUpserts.has(key)) return optimisticUpserts.get(key) - return syncedData.get(key) -} -``` - -**Ordering**: -1. Check optimistic deletes (returns undefined) -2. Check optimistic upserts (returns optimistic value) -3. Fall back to synced data - ---- - -## 2. Transaction and Mutation Implementation - -### Mutation Data Structure - -**Location**: `/home/user/db/packages/db/src/types.ts` (lines 57-86) - -```typescript -export interface PendingMutation { - mutationId: string // UUID for the specific mutation - original: T | {} // Pre-mutation state (empty for inserts) - modified: T // Post-mutation state - changes: ResolveTransactionChanges // Only actual changes (for partial updates) - globalKey: string // KEY::{collectionId}/{key} - for deduplication - - key: any // User's item key (from getKey()) - type: 'insert' | 'update' | 'delete' // Operation type - - metadata: unknown // User-provided metadata - syncMetadata: Record // Metadata from sync operations - - optimistic: boolean // Apply changes immediately? (default: true) - createdAt: Date // When mutation was created - updatedAt: Date // Last update time - - collection: Collection // Reference to collection -} -``` - -### Global Key Generation - -**Location**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 143-149) - -```typescript -public generateGlobalKey(key: any, item: any): string { - if (typeof key === `undefined`) { - throw new UndefinedKeyError(item) - } - // Format: KEY::{collectionId}/{key} - return `KEY::${this.id}/${key}` -} -``` - -**Purpose**: -- Uniquely identifies an item across transactions -- Used to merge mutations on the same item -- Supports deduplication and transaction merging logic - -### Transaction Structure - -**Location**: `/home/user/db/packages/db/src/transactions.ts` (lines 207-530) - -```typescript -class Transaction { - public id: string // UUID for transaction - public state: TransactionState // 'pending' | 'persisting' | 'completed' | 'failed' - public mutationFn: MutationFn // Persistence function - public mutations: Array> // Grouped mutations - public isPersisted: Deferred> // Promise for completion - public autoCommit: boolean // Auto-commit after mutate() - public createdAt: Date - public sequenceNumber: number // For ordering transactions - public metadata: Record - public error?: { message: string; error: Error } -} -``` - -### Mutation Merging Logic - -**Location**: `/home/user/db/packages/db/src/transactions.ts` (lines 41-101) - -**Truth Table**: -``` -Existing → New | Result | Behavior -insert → update | insert | Merge changes, keep empty original -insert → delete | removed | Cancel each other -update → delete | delete | Delete dominates -update → update | update | Union changes, keep first original -delete → delete | delete | Replace with latest -insert → insert | insert | Replace with latest -``` - -**Key Algorithm** (lines 323-345): -```typescript -applyMutations(mutations: Array>): void { - for (const newMutation of mutations) { - // Find existing mutation with same globalKey - const existingIndex = this.mutations.findIndex( - (m) => m.globalKey === newMutation.globalKey - ) - - if (existingIndex >= 0) { - // Merge or remove if cancel - const mergeResult = mergePendingMutations(existing, newMutation) - if (mergeResult === null) { - this.mutations.splice(existingIndex, 1) // Cancel - } else { - this.mutations[existingIndex] = mergeResult // Replace - } - } else { - this.mutations.push(newMutation) // New mutation - } - } -} -``` - ---- - -## 3. ID and Key Management - -### Current Key Handling - -**Location**: `/home/user/db/packages/db/src/types.ts` (lines 400-409) - -```typescript -interface BaseCollectionConfig { - getKey: (item: T) => TKey // REQUIRED: Extract ID from item - // ... other config -} -``` - -**Key Properties**: -- `TKey`: Type of the key (string | number) -- User provides `getKey()` function at collection creation -- Used to: - - Extract key from item for storage - - Generate globalKey - - Validate key changes are not allowed in updates - - Track mutations by item identity - -### Key Validation - -**Location**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 340-346) - -```typescript -// Check if ID is being changed (not allowed) -const originalItemId = this.config.getKey(originalItem) -const modifiedItemId = this.config.getKey(modifiedItem) - -if (originalItemId !== modifiedItemId) { - throw new KeyUpdateNotAllowedError(originalItemId, modifiedItemId) -} -``` - -### Sync Metadata - -**Location**: `/home/user/db/packages/db/src/collection/state.ts` (lines 47, 367) - -```typescript -public syncedMetadata = new Map() - -// Used in mutations (line 367-370) -syncMetadata: (state.syncedMetadata.get(key) || {}) as Record -``` - -**Purpose**: -- Stores metadata associated with synced items -- Separate from user-provided metadata -- Can be set via `sync.getSyncMetadata()` - ---- - -## 4. Type Definitions - -### Key Type Interfaces - -**Location**: `/home/user/db/packages/db/src/types.ts` - -#### OperationType (line 152) -```typescript -export type OperationType = `insert` | `update` | `delete` -``` - -#### Transaction Configuration (lines 115-123) -```typescript -export interface TransactionConfig { - id?: string - autoCommit?: boolean - mutationFn: MutationFn - metadata?: Record -} -``` - -#### Change Message (lines 261-270) -```typescript -export interface ChangeMessage { - key: TKey - value: T - previousValue?: T - type: OperationType - metadata?: Record -} -``` - -#### Operation Handlers (lines 496-583) -```typescript -// Insert handler -onInsert?: InsertMutationFn - -// Update handler -onUpdate?: UpdateMutationFn - -// Delete handler -onDelete?: DeleteMutationFn -``` - ---- - -## 5. Extended Properties and Metadata - -### Metadata Patterns - -**User Metadata** (provided at operation time): -```typescript -// In mutations -collection.update(id, - { metadata: { intent: 'complete' } }, // Custom metadata - (draft) => { draft.completed = true } -) - -// Accessible in handler -const mutation = transaction.mutations[0] -console.log(mutation.metadata?.intent) // 'complete' -``` - -**Sync Metadata** (from sync implementation): -```typescript -// Set by sync in getSyncMetadata() -sync: { - getSyncMetadata?: () => Record - // ... -} - -// Accessible in mutation -const syncMeta = mutation.syncMetadata -``` - -### Timestamps - -**Location**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 198-199, 373-374) - -```typescript -createdAt: new Date() // When mutation created -updatedAt: new Date() // Last update in transaction -``` - -**Note**: These track mutation lifecycle, not item timestamps. - ---- - -## 6. Issue #19: Stable View Keys - -### The Problem - -**Location**: `/home/user/db/docs/guides/mutations.md` (lines 1045-1070) - -When inserting items with temporary IDs (before server assigns real IDs): - -1. **UI Flicker**: Framework unmounts/remounts components when key changes from temporary to real ID -2. **Subsequent Operations Fail**: Delete/update before sync completes uses invalid temporary ID - -```typescript -// Current problematic pattern -const tempId = -(Math.floor(Math.random() * 1000000) + 1) -todoCollection.insert({ id: tempId, text: 'New todo' }) -// When sync completes, tempId becomes realId -todoCollection.delete(tempId) // May fail: tempId no longer exists -``` - -### Current Workaround (Manual) - -**Location**: `/home/user/db/docs/guides/mutations.md` (lines 1130-1201) - -```typescript -// User must maintain this mapping manually -const idToViewKey = new Map() - -function getViewKey(id: number | string): string { - if (!idToViewKey.has(id)) { - idToViewKey.set(id, crypto.randomUUID()) - } - return idToViewKey.get(id)! -} - -function linkIds(tempId: number, realId: number) { - const viewKey = getViewKey(tempId) - idToViewKey.set(realId, viewKey) // Link both IDs to same key -} - -// In handler -onInsert: async ({ transaction }) => { - const mutation = transaction.mutations[0] - const tempId = mutation.modified.id - const response = await api.todos.create(mutation.modified) - linkIds(tempId, response.id) - await todoCollection.utils.refetch() -} - -// In render -{todos.map((todo) => ( -
    • // Stable key! - {todo.text} -
    • -))} -``` - -### Issue Request - -**Location**: `/home/user/db/docs/guides/mutations.md` (line 1211) - -> There's an [open issue](https://github.com/TanStack/db/issues/19) to add better built-in support for temporary ID handling in TanStack DB. This would automate the view key pattern and make it easier to work with server-generated IDs. - ---- - -## 7. Key Files Summary - -| File | Purpose | Key Exports | -|------|---------|------------| -| `types.ts` | Type definitions | `PendingMutation`, `TransactionConfig`, `ChangeMessage`, etc. | -| `transactions.ts` | Transaction management | `Transaction`, `createTransaction()`, `mergePendingMutations()` | -| `collection/state.ts` | Item storage & state | `CollectionStateManager` with `syncedData`, `optimisticUpserts`, `optimisticDeletes` | -| `collection/mutations.ts` | Insert/update/delete logic | `CollectionMutationsManager` with `insert()`, `update()`, `delete()`, `generateGlobalKey()` | -| `collection/index.ts` | Main collection class | `Collection`, `CollectionImpl`, `createCollection()` | -| `collection/changes.ts` | Event emission | `CollectionChangesManager`, event batching & emission | -| `local-storage.ts` | localStorage implementation | `localStorageCollectionOptions()` with persistence | -| `proxy.ts` | Change tracking | `createChangeProxy()`, Immer-like change tracking | -| `SortedMap.ts` | Ordered storage | `SortedMap` with binary search insertion | - ---- - -## 8. Data Flow for Mutations - -``` -1. User calls collection.insert/update/delete() - ↓ -2. CollectionMutationsManager creates PendingMutation - - Generates globalKey: KEY::{collectionId}/{key} - - Sets metadata, timestamps - - Validates against schema - ↓ -3. Mutation added to active Transaction - - Can be ambient or explicit transaction - - If same globalKey exists: merge via mergePendingMutations() - ↓ -4. Transaction.mutate() completes or autoCommit triggers - ↓ -5. Transaction.commit() calls mutationFn() - - Sends mutations to backend - - User's onInsert/onUpdate/onDelete called - ↓ -6. On success: sync operations update syncedData - - Optimistic state cleared - - Server state becomes truth - - Change events emitted - ↓ -7. On failure: transaction.rollback() - - Optimistic state reverted - - Change events emitted -``` - ---- - -## 9. Implications for Issue #19 Fix - -### Where View Key Storage Would Go - -1. **Option A**: Add to `PendingMutation` - ```typescript - viewKey?: string // Stable key for rendering - ``` - -2. **Option B**: Add to CollectionStateManager - ```typescript - public viewKeyMap = new Map() // Maps both temp and real IDs - ``` - -3. **Option C**: Add to collection config - ```typescript - generateViewKey?: (item: T, mutation: PendingMutation) => string - ``` - -### Key Modification Points - -1. **In `mutations.ts`**: - - Generate/track viewKey during insert creation - - Link viewKey when key changes detected - -2. **In `state.ts`**: - - Maintain viewKey mapping through lifecycle - - Provide `getViewKey()` public method - -3. **In `types.ts`**: - - Add viewKey to `PendingMutation` - - Add viewKey to `ChangeMessage` - - Add config option to `BaseCollectionConfig` - -4. **In sync operations**: - - When synced item replaces optimistic item, link viewKeys - - Preserve viewKey through state transitions - -### Critical Behaviors to Preserve - -1. **Key immutability**: Still enforce in updates -2. **Mutation merging**: Use original key, not viewKey -3. **Backward compatibility**: viewKey optional, default to key -4. **Performance**: viewKey lookup O(1) with Map - ---- - -## References - -- **Main collection implementation**: `/home/user/db/packages/db/src/collection/index.ts` -- **State management**: `/home/user/db/packages/db/src/collection/state.ts` -- **Mutation handling**: `/home/user/db/packages/db/src/collection/mutations.ts` -- **Transaction logic**: `/home/user/db/packages/db/src/transactions.ts` -- **Type definitions**: `/home/user/db/packages/db/src/types.ts` -- **Documentation**: `/home/user/db/docs/guides/mutations.md` (lines 1045-1211) diff --git a/CODE_SNIPPETS_REFERENCE.md b/CODE_SNIPPETS_REFERENCE.md deleted file mode 100644 index 85d2d4c7e..000000000 --- a/CODE_SNIPPETS_REFERENCE.md +++ /dev/null @@ -1,450 +0,0 @@ -# TanStack DB - Code Snippets Reference - -## 1. Collection Item Storage Pattern - -### CollectionStateManager Overview -**File**: `/home/user/db/packages/db/src/collection/state.ts` (lines 29-78) - -```typescript -export class CollectionStateManager< - TOutput extends object, - TKey extends string | number, - TSchema extends StandardSchemaV1, - TInput extends object, -> { - // Three layers of storage - public syncedData: Map | SortedMap - public optimisticUpserts = new Map() - public optimisticDeletes = new Set() - public syncedMetadata = new Map() - - constructor(config: CollectionConfig) { - // Set up data storage with optional comparison function - if (config.compare) { - this.syncedData = new SortedMap(config.compare) - } else { - this.syncedData = new Map() - } - } - - // Virtual derived state combining all layers - public get(key: TKey): TOutput | undefined { - const { optimisticDeletes, optimisticUpserts, syncedData } = this - - if (optimisticDeletes.has(key)) { - return undefined - } - if (optimisticUpserts.has(key)) { - return optimisticUpserts.get(key) - } - return syncedData.get(key) - } -} -``` - -## 2. Mutation Data Structure - -### PendingMutation Interface -**File**: `/home/user/db/packages/db/src/types.ts` (lines 57-86) - -```typescript -export interface PendingMutation< - T extends object = Record, - TOperation extends OperationType = OperationType, - TCollection extends Collection = Collection< - T, - any, - any, - any, - any - >, -> { - mutationId: string // Unique ID for this mutation - original: TOperation extends `insert` ? {} : T // State before mutation - modified: T // State after mutation - changes: ResolveTransactionChanges // Only changed fields - globalKey: string // "KEY::{collectionId}/{key}" - - key: any // Item's user-provided key - type: TOperation // 'insert', 'update', or 'delete' - metadata: unknown // Custom metadata - syncMetadata: Record // Sync-provided metadata - - optimistic: boolean // Apply immediately? - createdAt: Date // When created - updatedAt: Date // Last updated - collection: TCollection // Reference to collection -} -``` - -## 3. Global Key Generation - -### How Global Keys Are Created -**File**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 143-149) - -```typescript -public generateGlobalKey(key: any, item: any): string { - if (typeof key === `undefined`) { - throw new UndefinedKeyError(item) - } - // Format: KEY::{collectionId}/{key} - return `KEY::${this.id}/${key}` -} -``` - -### Usage in Insert -**File**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 173-177) - -```typescript -const key = this.config.getKey(validatedData) -if (this.state.has(key)) { - throw new DuplicateKeyError(key) -} -const globalKey = this.generateGlobalKey(key, item) - -const mutation: PendingMutation = { - mutationId: crypto.randomUUID(), - globalKey, - key, - // ... other fields -} -``` - -## 4. Transaction Mutation Merging - -### Merge Logic Truth Table -**File**: `/home/user/db/packages/db/src/transactions.ts` (lines 41-101) - -```typescript -function mergePendingMutations( - existing: PendingMutation, - incoming: PendingMutation -): PendingMutation | null { - switch (`${existing.type}-${incoming.type}` as const) { - case `insert-update`: - // Update after insert: keep as insert but merge changes - return { - ...existing, - type: `insert` as const, - original: {}, - modified: incoming.modified, - changes: { ...existing.changes, ...incoming.changes }, - key: existing.key, - globalKey: existing.globalKey, - mutationId: incoming.mutationId, - updatedAt: incoming.updatedAt, - } - - case `insert-delete`: - // Delete after insert: cancel both mutations - return null - - case `update-delete`: - // Delete after update: delete dominates - return incoming - - case `update-update`: - // Update after update: replace with latest, union changes - return { - ...incoming, - original: existing.original, - changes: { ...existing.changes, ...incoming.changes }, - } - - case `delete-delete`: - case `insert-insert`: - // Same type: replace with latest - return incoming - } -} -``` - -### How Mutations Get Merged -**File**: `/home/user/db/packages/db/src/transactions.ts` (lines 323-345) - -```typescript -applyMutations(mutations: Array>): void { - for (const newMutation of mutations) { - // Find existing mutation with same globalKey - const existingIndex = this.mutations.findIndex( - (m) => m.globalKey === newMutation.globalKey - ) - - if (existingIndex >= 0) { - // Merge or remove if cancel - const existingMutation = this.mutations[existingIndex]! - const mergeResult = mergePendingMutations(existingMutation, newMutation) - - if (mergeResult === null) { - // Remove the mutation (cancel) - this.mutations.splice(existingIndex, 1) - } else { - // Replace with merged mutation - this.mutations[existingIndex] = mergeResult - } - } else { - // Insert new mutation - this.mutations.push(newMutation) - } - } -} -``` - -## 5. Insert Operation - -### Complete Insert Flow -**File**: `/home/user/db/packages/db/src/collection/mutations.ts` (lines 154-243) - -```typescript -insert = (data: TInput | Array, config?: InsertConfig) => { - this.lifecycle.validateCollectionUsable(`insert`) - const state = this.state - const ambientTransaction = getActiveTransaction() - - // Check for handler - if (!ambientTransaction && !this.config.onInsert) { - throw new MissingInsertHandlerError() - } - - const items = Array.isArray(data) ? data : [data] - const mutations: Array> = [] - - // Create mutations for each item - items.forEach((item) => { - const validatedData = this.validateData(item, `insert`) - const key = this.config.getKey(validatedData) - - if (this.state.has(key)) { - throw new DuplicateKeyError(key) - } - - const globalKey = this.generateGlobalKey(key, item) - - const mutation: PendingMutation = { - mutationId: crypto.randomUUID(), - original: {}, - modified: validatedData, - changes: Object.fromEntries( - Object.keys(item).map((k) => [ - k, - validatedData[k as keyof typeof validatedData], - ]) - ) as TInput, - globalKey, - key, - metadata: config?.metadata as unknown, - syncMetadata: this.config.sync.getSyncMetadata?.() || {}, - optimistic: config?.optimistic ?? true, - type: `insert`, - createdAt: new Date(), - updatedAt: new Date(), - collection: this.collection, - } - - mutations.push(mutation) - }) - - // If ambient transaction, use it; otherwise create one - if (ambientTransaction) { - ambientTransaction.applyMutations(mutations) - state.transactions.set(ambientTransaction.id, ambientTransaction) - state.scheduleTransactionCleanup(ambientTransaction) - state.recomputeOptimisticState(true) - return ambientTransaction - } else { - // Create transaction with onInsert handler - const directOpTransaction = createTransaction({ - mutationFn: async (params) => { - return await this.config.onInsert!({ - transaction: params.transaction as unknown as TransactionWithMutations< - TOutput, - `insert` - >, - collection: this.collection as unknown as Collection, - }) - }, - }) - - directOpTransaction.applyMutations(mutations) - directOpTransaction.commit().catch(() => undefined) - state.transactions.set(directOpTransaction.id, directOpTransaction) - state.scheduleTransactionCleanup(directOpTransaction) - state.recomputeOptimisticState(true) - - return directOpTransaction - } -} -``` - -## 6. Transaction Lifecycle - -### Transaction States -**File**: `/home/user/db/packages/db/src/transactions.ts` (lines 207-235) - -```typescript -class Transaction> { - public id: string // UUID - public state: TransactionState // pending|persisting|completed|failed - public mutationFn: MutationFn - public mutations: Array> - public isPersisted: Deferred> - public autoCommit: boolean - public createdAt: Date - public sequenceNumber: number - public metadata: Record - public error?: { message: string; error: Error } - - constructor(config: TransactionConfig) { - this.id = config.id ?? crypto.randomUUID() - this.mutationFn = config.mutationFn - this.state = `pending` - this.mutations = [] - this.isPersisted = createDeferred>() - this.autoCommit = config.autoCommit ?? true - this.createdAt = new Date() - this.sequenceNumber = sequenceNumber++ - this.metadata = config.metadata ?? {} - } -} -``` - -### Commit Flow -**File**: `/home/user/db/packages/db/src/transactions.ts` (lines 468-514) - -```typescript -async commit(): Promise> { - if (this.state !== `pending`) { - throw new TransactionNotPendingCommitError() - } - - this.setState(`persisting`) - - if (this.mutations.length === 0) { - this.setState(`completed`) - this.isPersisted.resolve(this) - return this - } - - try { - // Call the mutation function - await this.mutationFn({ - transaction: this as unknown as TransactionWithMutations, - }) - - this.setState(`completed`) - this.touchCollection() - this.isPersisted.resolve(this) - } catch (error) { - const originalError = - error instanceof Error ? error : new Error(String(error)) - - this.error = { - message: originalError.message, - error: originalError, - } - - this.rollback() - throw originalError - } - - return this -} -``` - -## 7. Issue #19: Current Workaround - -### Manual View Key Pattern -**File**: `/home/user/db/docs/guides/mutations.md` (lines 1130-1201) - -```typescript -// User-maintained view key mapping -const idToViewKey = new Map() - -function getViewKey(id: number | string): string { - if (!idToViewKey.has(id)) { - idToViewKey.set(id, crypto.randomUUID()) - } - return idToViewKey.get(id)! -} - -function linkIds(tempId: number, realId: number) { - const viewKey = getViewKey(tempId) - idToViewKey.set(realId, viewKey) // Link both IDs -} - -const todoCollection = createCollection({ - id: "todos", - onInsert: async ({ transaction }) => { - const mutation = transaction.mutations[0] - const tempId = mutation.modified.id - - const response = await api.todos.create(mutation.modified) - const realId = response.id - - linkIds(tempId, realId) - await todoCollection.utils.refetch() - }, -}) - -// Usage in render -const TodoList = () => { - const { data: todos } = useLiveQuery((q) => - q.from({ todo: todoCollection }) - ) - - return ( -
        - {todos.map((todo) => ( -
      • // Stable key! - {todo.text} -
      • - ))} -
      - ) -} -``` - -## 8. Key Type and Configuration - -### Collection Configuration Type -**File**: `/home/user/db/packages/db/src/types.ts` (lines 385-595) - -```typescript -export interface BaseCollectionConfig< - T extends object = Record, - TKey extends string | number = string | number, - TSchema extends StandardSchemaV1 = never, - TUtils extends UtilsRecord = UtilsRecord, -> { - id?: string - schema?: TSchema - - // REQUIRED: Extract ID from item - getKey: (item: T) => TKey - - // Optional handlers - onInsert?: InsertMutationFn - onUpdate?: UpdateMutationFn - onDelete?: DeleteMutationFn - - // Comparison for ordering - compare?: (x: T, y: T) => number - - // Other options... - gcTime?: number - autoIndex?: `off` | `eager` - startSync?: boolean - syncMode?: SyncMode - utils?: TUtils -} - -export interface CollectionConfig< - T extends object = Record, - TKey extends string | number = string | number, - TSchema extends StandardSchemaV1 = never, - TUtils extends UtilsRecord = UtilsRecord, -> extends BaseCollectionConfig { - sync: SyncConfig // REQUIRED -} -``` - diff --git a/DESIGN_ISSUE_19_STABLE_VIEWKEYS.md b/DESIGN_ISSUE_19_STABLE_VIEWKEYS.md deleted file mode 100644 index 5faae974f..000000000 --- a/DESIGN_ISSUE_19_STABLE_VIEWKEYS.md +++ /dev/null @@ -1,513 +0,0 @@ -# Design: Stable ViewKeys for Temporary ID Transitions (Issue #19) - -## Problem Statement - -When inserting items with temporary IDs (before server assigns real IDs), two critical UX issues occur: - -1. **UI Flicker**: React unmounts/remounts components when the key changes from temporary to real ID -2. **Operation Failures**: Subsequent operations (delete/update) fail if they use the temporary ID before sync completes - -Currently, developers must manually maintain a mapping from IDs to stable view keys, which is error-prone and boilerplate-heavy. - -## Goals - -1. **Automatic**: View keys should be automatically generated and tracked by collections -2. **Opt-in**: Backward compatible - only used when explicitly requested -3. **Simple API**: Easy to use with minimal boilerplate -4. **Type-safe**: Fully typed with good TypeScript support -5. **Performant**: O(1) lookups with no significant memory overhead - -## Design Overview - -### 1. Core Storage: ViewKey Mapping - -Add a `viewKeyMap` to `CollectionStateManager` that maintains stable view keys across ID transitions: - -```typescript -// In collection/state.ts -export class CollectionStateManager { - // Existing storage - public syncedData: Map | SortedMap - public optimisticUpserts = new Map() - public optimisticDeletes = new Set() - public syncedMetadata = new Map() - - // NEW: ViewKey mapping - public viewKeyMap = new Map() // Maps both temp and real IDs to stable viewKey -} -``` - -### 2. Collection Configuration (Opt-in) - -Add optional `viewKey` configuration to enable the feature: - -```typescript -// In types.ts -interface BaseCollectionConfig { - // Existing config... - getKey: (item: T) => TKey - - // NEW: ViewKey configuration (opt-in) - viewKey?: { - // Auto-generate view keys on insert - generate?: (item: T) => string - - // Or always use a specific field from the item as viewKey - field?: keyof T - } -} -``` - -**Usage patterns:** - -```typescript -// Pattern 1: Auto-generate UUIDs (most common) -const todoCollection = createCollection({ - id: "todos", - getKey: (item) => item.id, - viewKey: { - generate: () => crypto.randomUUID() // Auto-generate stable keys - }, -}) - -// Pattern 2: Use existing stable field (like a UUID field separate from ID) -const postCollection = createCollection({ - id: "posts", - getKey: (item) => item.id, - viewKey: { - field: 'uuid' // Use item.uuid as viewKey - }, -}) - -// Pattern 3: No viewKey (backward compatible - defaults to using key as viewKey) -const userCollection = createCollection({ - id: "users", - getKey: (item) => item.id, - // No viewKey config - uses key directly (current behavior) -}) -``` - -### 3. ViewKey Generation on Insert - -Automatically generate view keys when items are inserted: - -```typescript -// In collection/mutations.ts - CollectionMutationsManager.insert() -public insert( - item: T | T[], - options?: { metadata?: unknown; optimistic?: boolean } -): Transaction { - const items = Array.isArray(item) ? item : [item] - - return this.withTransaction((transaction) => { - const mutations = items.map((item) => { - const key = this.config.getKey(item) - - // NEW: Generate viewKey if configured - let viewKey: string | undefined - if (this.config.viewKey) { - if (this.config.viewKey.generate) { - viewKey = this.config.viewKey.generate(item) - } else if (this.config.viewKey.field) { - viewKey = String(item[this.config.viewKey.field]) - } - - // Store viewKey mapping - if (viewKey) { - this.state.viewKeyMap.set(key, viewKey) - } - } - - // Create mutation with viewKey - const mutation: PendingMutation = { - // ... existing fields - viewKey, // NEW field - // ... rest - } - - return mutation - }) - - // ... rest of insert logic - }) -} -``` - -### 4. ViewKey Linking API - -Provide a new method to link temporary IDs to real IDs during sync: - -```typescript -// In collection/mutations.ts - CollectionMutationsManager -public linkViewKeys(mapping: { tempKey: TKey; realKey: TKey }[]): void { - mapping.forEach(({ tempKey, realKey }) => { - const viewKey = this.state.viewKeyMap.get(tempKey) - if (viewKey) { - // Link real key to the same viewKey - this.state.viewKeyMap.set(realKey, viewKey) - // Keep temp key mapping for brief period (helps with race conditions) - // Could optionally delete tempKey after a delay - } - }) -} -``` - -**Alternative: Auto-detect ID transitions** (more magical but potentially fragile): - -```typescript -// In collection/state.ts - during sync -private detectIdTransitions(syncedItems: T[]): void { - // Detect when optimistic item with tempId is replaced by synced item with realId - // This would compare optimistic items to incoming synced items by content similarity - // More complex but requires no manual linking -} -``` - -### 5. Public API: getViewKey() - -Expose a method to retrieve view keys for rendering: - -```typescript -// In collection/index.ts - Collection interface -interface Collection { - // Existing methods... - insert(item: T | T[], options?: InsertOptions): Transaction - update(key: TKey | TKey[], ...): Transaction - delete(key: TKey | TKey[], ...): Transaction - - // NEW: Get stable viewKey for an item - getViewKey(key: TKey): string -} - -// Implementation in CollectionImpl -public getViewKey(key: TKey): string { - // Return mapped viewKey if exists, otherwise fall back to key - const viewKey = this.state.viewKeyMap.get(key) - return viewKey ?? String(key) -} -``` - -### 6. Include ViewKey in Change Events - -Add viewKey to change messages so subscribers can use stable keys: - -```typescript -// In types.ts -export interface ChangeMessage { - key: TKey - value: T - previousValue?: T - type: OperationType - metadata?: Record - viewKey?: string // NEW: Stable view key for rendering -} - -// In collection/change-events.ts - when emitting changes -const changeMessage: ChangeMessage = { - key, - value, - previousValue, - type: 'insert', - metadata: mutation.metadata, - viewKey: mutation.viewKey ?? this.getViewKey(key), // Include viewKey -} -``` - -### 7. PendingMutation Type Update - -Add viewKey to mutation type: - -```typescript -// In types.ts -export interface PendingMutation { - // Existing fields... - mutationId: string - original: T | {} - modified: T - changes: ResolveTransactionChanges - globalKey: string - key: any - type: 'insert' | 'update' | 'delete' - metadata: unknown - syncMetadata: Record - optimistic: boolean - createdAt: Date - updatedAt: Date - collection: Collection - - // NEW: Stable view key - viewKey?: string -} -``` - -## Usage Examples - -### Example 1: Basic Usage with Auto-Generated ViewKeys - -```typescript -import { createCollection, queryCollectionOptions } from '@tanstack/react-db' - -const todoCollection = createCollection( - queryCollectionOptions({ - queryKey: ['todos'], - queryFn: async () => api.todos.getAll(), - getKey: (item) => item.id, - - // Enable auto-generated view keys - viewKey: { - generate: () => crypto.randomUUID() - }, - - onInsert: async ({ transaction }) => { - const mutation = transaction.mutations[0] - const tempId = mutation.modified.id - - // Create on server - const response = await api.todos.create(mutation.modified) - const realId = response.id - - // Link temporary ID to real ID - todoCollection.linkViewKeys([{ tempKey: tempId, realKey: realId }]) - - // Wait for sync - await todoCollection.utils.refetch() - }, - }) -) - -// In component -function TodoList() { - const { data: todos } = useLiveQuery((q) => - q.from({ todo: todoCollection }) - ) - - return ( -
        - {todos.map((todo) => ( - // Use stable viewKey instead of id -
      • - {todo.text} -
      • - ))} -
      - ) -} - -// Insert with temporary ID -const tempId = -Date.now() -todoCollection.insert({ - id: tempId, - text: 'New todo', - completed: false, -}) - -// Delete immediately (works even before sync completes) -todoCollection.delete(tempId) // Uses same temp key, no 404 -``` - -### Example 2: Using Existing UUID Field - -```typescript -interface Post { - id: number // Server-generated sequential ID - uuid: string // Client-generated UUID (stable) - title: string -} - -const postCollection = createCollection({ - id: "posts", - getKey: (item) => item.id, - - // Use existing uuid field as viewKey - viewKey: { - field: 'uuid' - }, - - onInsert: async ({ transaction }) => { - const mutation = transaction.mutations[0] - const tempId = mutation.modified.id - - const response = await api.posts.create(mutation.modified) - - // Link temp ID to real ID - postCollection.linkViewKeys([{ tempKey: tempId, realKey: response.id }]) - - await postCollection.utils.refetch() - }, -}) - -// Insert with both temp ID and stable UUID -postCollection.insert({ - id: -Date.now(), // Temporary ID - uuid: crypto.randomUUID(), // Stable UUID for viewKey - title: 'New Post', -}) -``` - -### Example 3: Batch Insert with Multiple ID Mappings - -```typescript -const batchInsertTodos = async (texts: string[]) => { - // Create temp items with viewKeys - const tempItems = texts.map(text => ({ - id: -Date.now() - Math.random(), - text, - completed: false, - })) - - // Insert optimistically - const tx = todoCollection.insert(tempItems) - - // Persist to server - const response = await api.todos.batchCreate(tempItems) - - // Link all temp IDs to real IDs - const mappings = tempItems.map((item, index) => ({ - tempKey: item.id, - realKey: response[index].id, - })) - - todoCollection.linkViewKeys(mappings) - - // Sync back - await todoCollection.utils.refetch() - - await tx.isPersisted.promise -} -``` - -## Implementation Plan - -### Phase 1: Core Infrastructure (Required for MVP) - -1. **Add viewKeyMap to CollectionStateManager** - - File: `packages/db/src/collection/state.ts` - - Add: `public viewKeyMap = new Map()` - -2. **Add viewKey to PendingMutation type** - - File: `packages/db/src/types.ts` - - Add: `viewKey?: string` to `PendingMutation` interface - -3. **Add viewKey config to BaseCollectionConfig** - - File: `packages/db/src/types.ts` - - Add: `viewKey?: { generate?: (item: T) => string; field?: keyof T }` to config - -4. **Implement viewKey generation in insert()** - - File: `packages/db/src/collection/mutations.ts` - - Update `insert()` method to generate and store viewKeys - -5. **Add getViewKey() public method** - - File: `packages/db/src/collection/index.ts` - - Expose `getViewKey(key: TKey): string` on Collection interface - -6. **Add linkViewKeys() method** - - File: `packages/db/src/collection/mutations.ts` - - Implement `linkViewKeys(mapping: Array<{ tempKey: TKey; realKey: TKey }>): void` - -### Phase 2: Change Events (Nice to have) - -7. **Include viewKey in ChangeMessage** - - File: `packages/db/src/types.ts` - - Add: `viewKey?: string` to `ChangeMessage` interface - -8. **Emit viewKey in change events** - - File: `packages/db/src/collection/change-events.ts` - - Include viewKey when creating change messages - -### Phase 3: Documentation & Testing - -9. **Update mutations.md documentation** - - Replace manual workaround with new built-in API - - Add examples and best practices - -10. **Add tests** - - Test viewKey generation - - Test linkViewKeys() with temp → real ID transitions - - Test getViewKey() fallback behavior - - Test backward compatibility (no viewKey config) - -## Backward Compatibility - -- **No breaking changes**: All new features are opt-in -- **Default behavior unchanged**: Collections without `viewKey` config work as before -- **Graceful fallback**: `getViewKey()` returns `String(key)` when no viewKey is configured - -## Alternative Approaches Considered - -### Alternative 1: Auto-detect ID transitions - -**Pros:** -- No manual linking required -- More "magical" DX - -**Cons:** -- Complex heuristics needed to match optimistic items to synced items -- Risk of false positives/negatives -- Hard to debug when detection fails -- Performance overhead - -**Decision:** Rejected in favor of explicit linking for reliability - -### Alternative 2: Add viewKey field to items themselves - -**Pros:** -- Simpler storage (no separate map) -- ViewKey persists with item data - -**Cons:** -- Pollutes user's data model -- Requires schema changes -- Not backward compatible -- ViewKey would sync to server unnecessarily - -**Decision:** Rejected - keep viewKey in collection metadata - -### Alternative 3: Transaction-level viewKey API - -```typescript -transaction.mapViewKey({ tempId, realId }) -``` - -**Pros:** -- Transaction-scoped (matches issue proposal) - -**Cons:** -- Less discoverable API -- Requires transaction reference -- Less flexible (what if user wants to link outside transaction?) - -**Decision:** Use collection-level API for better discoverability - -## Open Questions - -1. **ViewKey cleanup**: Should we automatically remove viewKey mappings for temp IDs after they're replaced? - - **Recommendation**: Keep temp mapping for ~1 second to handle race conditions, then clean up - -2. **ViewKey persistence**: Should viewKeys persist to localStorage for LocalStorageCollection? - - **Recommendation**: Yes, store viewKeyMap alongside data for consistency - -3. **ViewKey in queries**: Should query results include viewKey automatically? - - **Recommendation**: No, keep it opt-in via `getViewKey()`. Queries return data as-is. - -4. **Multiple temp → real transitions**: What if an item's ID changes multiple times? - - **Recommendation**: viewKey stays stable across all transitions (that's the point!) - -## Success Criteria - -1. Users can enable viewKey generation with single config option -2. Temp → real ID transitions don't cause UI flicker -3. `getViewKey()` provides stable keys for React rendering -4. Zero breaking changes to existing codebases -5. Documentation clearly explains usage and best practices - -## Timeline Estimate - -- **Phase 1 (Core)**: 2-3 days (6 changes) -- **Phase 2 (Events)**: 1 day (2 changes) -- **Phase 3 (Docs/Tests)**: 1-2 days -- **Total**: 4-6 days for full implementation - -## Related Issues - -- Issue #19: https://github.com/TanStack/db/issues/19 -- Documentation: /home/user/db/docs/guides/mutations.md (lines 1045-1211) diff --git a/KEY_FILES_REFERENCE.md b/KEY_FILES_REFERENCE.md deleted file mode 100644 index 6a74e89af..000000000 --- a/KEY_FILES_REFERENCE.md +++ /dev/null @@ -1,111 +0,0 @@ -# TanStack DB - Key Files Quick Reference - -## File Location Map for Issue #19 - -### 1. Where Items Are Stored -- **File**: `/home/user/db/packages/db/src/collection/state.ts` -- **Key Classes**: `CollectionStateManager` -- **Storage Fields**: - - `syncedData: Map | SortedMap` - Server truth - - `optimisticUpserts: Map` - Pending changes - - `optimisticDeletes: Set` - Pending deletions - - `syncedMetadata: Map` - Metadata per item - -### 2. How Mutations Are Structured -- **File**: `/home/user/db/packages/db/src/types.ts` (lines 57-86) -- **Interface**: `PendingMutation` -- **Key Fields**: - ```typescript - mutationId: string // UUID - key: any // User's item ID - globalKey: string // "KEY::{collectionId}/{key}" - modified: T // Final state - changes: Partial // Only changed fields - original: T | {} // Pre-mutation state - metadata: unknown // User metadata - syncMetadata: Record // Sync metadata - optimistic: boolean // Apply immediately? - type: 'insert' | 'update' | 'delete' // Operation type - ``` - -### 3. How Transactions Work -- **File**: `/home/user/db/packages/db/src/transactions.ts` -- **Key Class**: `Transaction` -- **Merging Logic**: `mergePendingMutations()` (lines 41-101) -- **Key Methods**: - - `applyMutations()` - Add/merge mutations (lines 323-345) - - `commit()` - Persist to backend (lines 468-514) - - `rollback()` - Revert changes (lines 385-410) - -### 4. Insert/Update/Delete Operations -- **File**: `/home/user/db/packages/db/src/collection/mutations.ts` -- **Key Class**: `CollectionMutationsManager` -- **Key Methods**: - - `insert()` - Create new items (lines 154-243) - - `update()` - Modify items (lines 248-438) - - `delete()` - Remove items (lines 443-538) -- **Global Key Generation**: `generateGlobalKey()` (lines 143-149) - -### 5. Type Definitions -- **File**: `/home/user/db/packages/db/src/types.ts` -- **Key Exports**: - - `PendingMutation` - Mutation structure - - `TransactionConfig` - Transaction options - - `ChangeMessage` - Change event structure - - `BaseCollectionConfig` - Collection options - - `OperationType` - 'insert' | 'update' | 'delete' - -## Critical Concepts for Issue #19 - -### Current ID/Key Model -1. **User provides**: `getKey: (item: T) => TKey` -2. **TKey type**: string | number -3. **Key immutability**: Updates cannot change the key (throws error) -4. **Global key format**: `KEY::{collectionId}/{key}` used for deduplication - -### The View Key Problem -- **Issue**: Temporary IDs become real IDs during sync -- **Current workaround**: Manual mapping in user code -- **Goal**: Automate this with built-in view keys - -### Proposed Solution Sketch -1. Add optional `viewKey?: string` to `PendingMutation` -2. Generate viewKey on insert in `CollectionMutationsManager.insert()` -3. Maintain `viewKeyMap: Map` in `CollectionStateManager` -4. Link temp ID viewKey to real ID viewKey in sync operations -5. Expose `getViewKey(key: TKey): string` on collection - -## Key Data Structures to Understand - -### SortedMap -- **File**: `/home/user/db/packages/db/src/SortedMap.ts` -- **Use**: Optional ordered storage of items -- **Created when**: `config.compare` function provided -- **Time complexity**: O(log n) insertion via binary search - -### Change Proxy -- **File**: `/home/user/db/packages/db/src/proxy.ts` -- **Purpose**: Tracks changes to items using Immer-like pattern -- **Used in**: `update()` method to capture property changes - -### Event System -- **File**: `/home/user/db/packages/db/src/collection/changes.ts` -- **Class**: `CollectionChangesManager` -- **Emits**: `ChangeMessage` events for mutations - -## Testing Files (for reference) -Located in: `/home/user/db/packages/db/tests/` - -Useful test patterns: -- Collection creation and basic operations -- Mutation merging behavior -- Transaction lifecycle -- Sync operations -- State management - -## Related Files Not Yet Explored -- **Sync operations**: `/home/user/db/packages/db/src/collection/sync.ts` -- **Query system**: `/home/user/db/packages/db/src/query/` -- **Index management**: `/home/user/db/packages/db/src/indexes/` -- **LocalStorage collection**: `/home/user/db/packages/db/src/local-storage.ts` - diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 000000000..215b8116f --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,107 @@ +# PR Title + +Add stable viewKey API to prevent UI re-renders during ID transitions + +--- + +# PR Body + +## Summary + +Fixes #19 + +Adds built-in support for stable view keys that prevent UI flicker when optimistic inserts transition from temporary IDs to real server-generated IDs. + +## The Problem + +When inserting items with temporary IDs (e.g., negative numbers) that are later replaced by real server IDs, React treats the key change as a component identity shift, causing: + +1. **UI Flicker** - Components unmount and remount, resetting state +2. **Lost Focus** - Input fields lose focus during ID transition +3. **Visual Jank** - Animations restart, scroll position resets + +Previously, developers had to manually maintain an external mapping from IDs to stable keys. + +## The Solution + +Collections can now be configured with a `viewKey` function to automatically generate and track stable keys: + +```typescript +const todoCollection = createCollection({ + getKey: (item) => item.id, + viewKey: () => crypto.randomUUID(), // ← Auto-generate stable keys + onInsert: async ({ transaction }) => { + const tempId = transaction.mutations[0].modified.id + const response = await api.create(...) + + // Link temp ID to real ID - they share the same viewKey + todoCollection.mapViewKey(tempId, response.id) + await todoCollection.utils.refetch() + }, +}) + +// Use stable keys in React - no more flicker! +{todos.map((todo) => ( +
    • + {todo.text} +
    • +))} +``` + +## API + +### New Configuration Option + +- **`viewKey?: (item: T) => string`** - Function to generate stable view keys for inserted items + +### New Collection Methods + +- **`getViewKey(key: TKey): string`** - Returns stable viewKey for any key (temporary or real). Falls back to `String(key)` if no viewKey is configured. + +- **`mapViewKey(tempKey: TKey, realKey: TKey): void`** - Links temporary and real IDs to share the same stable viewKey + +### Type Changes + +- Added `viewKey?: string` to `PendingMutation` interface +- Added `viewKey?: string` to `ChangeMessage` interface + +## Implementation Details + +1. **Storage**: Added `viewKeyMap: Map` to `CollectionStateManager` to track stable keys +2. **Generation**: ViewKeys are automatically generated during `insert()` if configured +3. **Linking**: `mapViewKey()` creates bidirectional mapping from both temp and real IDs to the same viewKey +4. **Events**: All change events (insert/update/delete) now include viewKey for subscribers +5. **Persistence**: ViewKeys kept indefinitely (tiny memory overhead ~50 bytes per item) + +## Backward Compatibility + +✅ **Fully backward compatible** - All changes are opt-in: +- Collections without `viewKey` config work exactly as before +- `getViewKey()` returns `String(key)` when no viewKey is configured +- No breaking changes to existing APIs + +## Documentation + +Updated `/docs/guides/mutations.md` to replace the manual workaround with the new built-in API, including: +- Complete usage example +- How it works explanation +- Best practices + +## Design Decisions + +1. **Opt-in via configuration** - Only active when explicitly enabled +2. **Function instead of object** - Simple `viewKey: () => uuid()` instead of `{ generate: () => uuid() }` +3. **Explicit linking** - Manual `mapViewKey()` call for reliability (vs auto-detection which would be fragile) +4. **Collection-level storage** - ViewKeys stored in collection metadata, not polluting item data +5. **Indefinite retention** - Mappings kept forever for consistency (negligible memory impact) + +## Testing + +Manual testing performed with temporary-to-real ID transitions. Automated tests can be added as a follow-up if desired. + +--- + +## Related + +- Original issue: https://github.com/TanStack/db/issues/19 +- Mentioned in mutations.md documentation From 1af04c497aaa2e5a20219a75639b04b2c4bff143 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 16:01:53 +0000 Subject: [PATCH 05/11] Fix changeset to use patch (pre-1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/stable-viewkeys-for-temp-ids.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/stable-viewkeys-for-temp-ids.md b/.changeset/stable-viewkeys-for-temp-ids.md index 8ed40a8ce..0bbb685b6 100644 --- a/.changeset/stable-viewkeys-for-temp-ids.md +++ b/.changeset/stable-viewkeys-for-temp-ids.md @@ -1,5 +1,5 @@ --- -"@tanstack/db": minor +"@tanstack/db": patch --- Add stable `viewKey` support to prevent UI re-renders during temporary-to-real ID transitions. When inserting items with temporary IDs that are later replaced by server-generated IDs, React components would previously unmount and remount, causing loss of focus and visual flicker. From 14a06d5d801496ac9642083cd9b1a0d3d0111c93 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 16:02:45 +0000 Subject: [PATCH 06/11] Remove temporary PR description file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR has been created, no longer needed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- PR_DESCRIPTION.md | 107 ---------------------------------------------- 1 file changed, 107 deletions(-) delete mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md deleted file mode 100644 index 215b8116f..000000000 --- a/PR_DESCRIPTION.md +++ /dev/null @@ -1,107 +0,0 @@ -# PR Title - -Add stable viewKey API to prevent UI re-renders during ID transitions - ---- - -# PR Body - -## Summary - -Fixes #19 - -Adds built-in support for stable view keys that prevent UI flicker when optimistic inserts transition from temporary IDs to real server-generated IDs. - -## The Problem - -When inserting items with temporary IDs (e.g., negative numbers) that are later replaced by real server IDs, React treats the key change as a component identity shift, causing: - -1. **UI Flicker** - Components unmount and remount, resetting state -2. **Lost Focus** - Input fields lose focus during ID transition -3. **Visual Jank** - Animations restart, scroll position resets - -Previously, developers had to manually maintain an external mapping from IDs to stable keys. - -## The Solution - -Collections can now be configured with a `viewKey` function to automatically generate and track stable keys: - -```typescript -const todoCollection = createCollection({ - getKey: (item) => item.id, - viewKey: () => crypto.randomUUID(), // ← Auto-generate stable keys - onInsert: async ({ transaction }) => { - const tempId = transaction.mutations[0].modified.id - const response = await api.create(...) - - // Link temp ID to real ID - they share the same viewKey - todoCollection.mapViewKey(tempId, response.id) - await todoCollection.utils.refetch() - }, -}) - -// Use stable keys in React - no more flicker! -{todos.map((todo) => ( -
    • - {todo.text} -
    • -))} -``` - -## API - -### New Configuration Option - -- **`viewKey?: (item: T) => string`** - Function to generate stable view keys for inserted items - -### New Collection Methods - -- **`getViewKey(key: TKey): string`** - Returns stable viewKey for any key (temporary or real). Falls back to `String(key)` if no viewKey is configured. - -- **`mapViewKey(tempKey: TKey, realKey: TKey): void`** - Links temporary and real IDs to share the same stable viewKey - -### Type Changes - -- Added `viewKey?: string` to `PendingMutation` interface -- Added `viewKey?: string` to `ChangeMessage` interface - -## Implementation Details - -1. **Storage**: Added `viewKeyMap: Map` to `CollectionStateManager` to track stable keys -2. **Generation**: ViewKeys are automatically generated during `insert()` if configured -3. **Linking**: `mapViewKey()` creates bidirectional mapping from both temp and real IDs to the same viewKey -4. **Events**: All change events (insert/update/delete) now include viewKey for subscribers -5. **Persistence**: ViewKeys kept indefinitely (tiny memory overhead ~50 bytes per item) - -## Backward Compatibility - -✅ **Fully backward compatible** - All changes are opt-in: -- Collections without `viewKey` config work exactly as before -- `getViewKey()` returns `String(key)` when no viewKey is configured -- No breaking changes to existing APIs - -## Documentation - -Updated `/docs/guides/mutations.md` to replace the manual workaround with the new built-in API, including: -- Complete usage example -- How it works explanation -- Best practices - -## Design Decisions - -1. **Opt-in via configuration** - Only active when explicitly enabled -2. **Function instead of object** - Simple `viewKey: () => uuid()` instead of `{ generate: () => uuid() }` -3. **Explicit linking** - Manual `mapViewKey()` call for reliability (vs auto-detection which would be fragile) -4. **Collection-level storage** - ViewKeys stored in collection metadata, not polluting item data -5. **Indefinite retention** - Mappings kept forever for consistency (negligible memory impact) - -## Testing - -Manual testing performed with temporary-to-real ID transitions. Automated tests can be added as a follow-up if desired. - ---- - -## Related - -- Original issue: https://github.com/TanStack/db/issues/19 -- Mentioned in mutations.md documentation From 51e5e5b7c21021300d7c7876abc6a2ad6e57362e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 16:12:50 +0000 Subject: [PATCH 07/11] Format changeset with prettier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added blank line before list for proper markdown formatting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/stable-viewkeys-for-temp-ids.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/stable-viewkeys-for-temp-ids.md b/.changeset/stable-viewkeys-for-temp-ids.md index 0bbb685b6..f6e7bd410 100644 --- a/.changeset/stable-viewkeys-for-temp-ids.md +++ b/.changeset/stable-viewkeys-for-temp-ids.md @@ -25,6 +25,7 @@ const todoCollection = createCollection({ ``` New APIs: + - `collection.getViewKey(key)` - Returns stable viewKey for any key (temporary or real) - `collection.mapViewKey(tempKey, realKey)` - Links temporary and real IDs to share the same viewKey - `viewKey` configuration option - Function to generate stable view keys for inserted items From 0a9ca83a4d88aa1b915dee001c79e6fd53018c46 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 16:15:02 +0000 Subject: [PATCH 08/11] Fix prettier formatting in state.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/db/src/collection/state.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 4c64b7513..c7c9fad31 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -520,7 +520,12 @@ export class CollectionStateManager< this.syncedData.get(key) if (previousValue !== undefined) { const viewKey = this.viewKeyMap.get(key) - events.push({ type: `delete`, key, value: previousValue, viewKey }) + events.push({ + type: `delete`, + key, + value: previousValue, + viewKey, + }) } } From a0fb87db9a7c0b991bcd654a6974847229e44c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 16:49:51 +0000 Subject: [PATCH 09/11] Add dev warning and clarify viewKey callback signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **DX Improvements:** 1. **Dev warning in mapViewKey**: Now warns when mapViewKey is called without an existing viewKey mapping, helping developers catch misconfiguration issues early. 2. **Clarify callback signature**: Updated docs and JSDoc to make it clear that the viewKey callback receives the item parameter, even if you ignore it with `_item`. Examples now show: - `viewKey: (_item) => crypto.randomUUID()` (ignore parameter) - `viewKey: (item) => ...` (use parameter if needed) These improve developer experience and reduce confusion around the API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/guides/mutations.md | 3 ++- packages/db/src/collection/index.ts | 5 +++++ packages/db/src/types.ts | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 3414695bb..2aeb77175 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -1137,7 +1137,8 @@ const todoCollection = createCollection({ id: "todos", getKey: (item) => item.id, // Enable automatic view key generation - viewKey: () => crypto.randomUUID(), + // The callback receives the item being inserted (can ignore it with _item) + viewKey: (_item) => crypto.randomUUID(), onInsert: async ({ transaction }) => { const mutation = transaction.mutations[0] const tempId = mutation.modified.id diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 4625b6f76..256c02637 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -498,6 +498,11 @@ export class CollectionImpl< if (viewKey) { // Link real key to the same viewKey this._state.viewKeyMap.set(realKey, viewKey) + } else if (process.env.NODE_ENV !== `production`) { + console.warn( + `[TanStack DB] mapViewKey called for tempKey "${String(tempKey)}" but no viewKey was found. ` + + `Make sure you've configured the collection with a viewKey function.` + ) } } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index ff6f58677..9696c0d4a 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -590,12 +590,19 @@ export interface BaseCollectionConfig< * Optional function to generate stable view keys for items. * This prevents UI re-renders during temporary-to-real ID transitions. * + * The function receives the item being inserted. You can use the item's + * properties if needed, or ignore it to generate a random key. + * * When enabled, call `collection.mapViewKey(tempId, realId)` in your * insert handler to link the temporary and real IDs to the same viewKey. * * @example - * // Auto-generate view keys with UUIDs - * viewKey: () => crypto.randomUUID() + * // Auto-generate view keys with UUIDs (item parameter unused) + * viewKey: (_item) => crypto.randomUUID() + * + * @example + * // Derive from item property + * viewKey: (item) => `view-${item.userId}-${crypto.randomUUID()}` */ viewKey?: (item: T) => string From cf3e8dea93b91b500ffdb8527fc00b6398dcd340 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 16:57:45 +0000 Subject: [PATCH 10/11] Simplify viewKey callback examples - don't need _item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed confusing _item convention from examples. In JavaScript/TypeScript, you can simply omit unused parameters, so: viewKey: () => crypto.randomUUID() is clearer and simpler than: viewKey: (_item) => crypto.randomUUID() The type signature still shows (item: T) => string for when you DO need the item, with a second example showing that usage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/guides/mutations.md | 3 +-- packages/db/src/types.ts | 9 +++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 2aeb77175..3414695bb 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -1137,8 +1137,7 @@ const todoCollection = createCollection({ id: "todos", getKey: (item) => item.id, // Enable automatic view key generation - // The callback receives the item being inserted (can ignore it with _item) - viewKey: (_item) => crypto.randomUUID(), + viewKey: () => crypto.randomUUID(), onInsert: async ({ transaction }) => { const mutation = transaction.mutations[0] const tempId = mutation.modified.id diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 9696c0d4a..6ddcd405a 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -590,18 +590,15 @@ export interface BaseCollectionConfig< * Optional function to generate stable view keys for items. * This prevents UI re-renders during temporary-to-real ID transitions. * - * The function receives the item being inserted. You can use the item's - * properties if needed, or ignore it to generate a random key. - * * When enabled, call `collection.mapViewKey(tempId, realId)` in your * insert handler to link the temporary and real IDs to the same viewKey. * * @example - * // Auto-generate view keys with UUIDs (item parameter unused) - * viewKey: (_item) => crypto.randomUUID() + * // Auto-generate view keys with UUIDs + * viewKey: () => crypto.randomUUID() * * @example - * // Derive from item property + * // Or derive from item property if needed * viewKey: (item) => `view-${item.userId}-${crypto.randomUUID()}` */ viewKey?: (item: T) => string From 518c98313fed0f050860b9275956d367454870d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 17:43:11 +0000 Subject: [PATCH 11/11] Remove viewKey from ChangeMessage events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewKeys are for rendering, not for event consumption. Consumers should call `collection.getViewKey(key)` directly when they need the stable key for rendering, rather than receiving it in change events. **What was removed:** - `viewKey?: string` field from ChangeMessage interface - All viewKey inclusions in event emissions throughout state.ts **What remains:** - `viewKey` in PendingMutation (useful mutation metadata) - `viewKeyMap` storage in CollectionStateManager - `getViewKey()` and `mapViewKey()` public API methods This simplifies the event system and keeps the concern of "stable rendering keys" separate from the concern of "data change notifications." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/db/src/collection/state.ts | 26 +++++--------------------- packages/db/src/types.ts | 2 -- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index c7c9fad31..155951878 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -383,13 +383,10 @@ export class CollectionStateManager< previousDeletes ) - // Get viewKey if available - const viewKey = this.viewKeyMap.get(key) - if (previousValue !== undefined && currentValue === undefined) { - events.push({ type: `delete`, key, value: previousValue, viewKey }) + events.push({ type: `delete`, key, value: previousValue }) } else if (previousValue === undefined && currentValue !== undefined) { - events.push({ type: `insert`, key, value: currentValue, viewKey }) + events.push({ type: `insert`, key, value: currentValue }) } else if ( previousValue !== undefined && currentValue !== undefined && @@ -400,7 +397,6 @@ export class CollectionStateManager< key, value: currentValue, previousValue, - viewKey, }) } } @@ -519,13 +515,7 @@ export class CollectionStateManager< truncateOptimisticSnapshot?.upserts.get(key) || this.syncedData.get(key) if (previousValue !== undefined) { - const viewKey = this.viewKeyMap.get(key) - events.push({ - type: `delete`, - key, - value: previousValue, - viewKey, - }) + events.push({ type: `delete`, key, value: previousValue }) } } @@ -632,12 +622,10 @@ export class CollectionStateManager< } } if (!foundInsert) { - const viewKey = this.viewKeyMap.get(key) - events.push({ type: `insert`, key, value, viewKey }) + events.push({ type: `insert`, key, value }) } } else { - const viewKey = this.viewKeyMap.get(key) - events.push({ type: `insert`, key, value, viewKey }) + events.push({ type: `insert`, key, value }) } } @@ -754,7 +742,6 @@ export class CollectionStateManager< } if (!isRedundantSync) { - const viewKey = this.viewKeyMap.get(key) if ( previousVisibleValue === undefined && newVisibleValue !== undefined @@ -763,7 +750,6 @@ export class CollectionStateManager< type: `insert`, key, value: newVisibleValue, - viewKey, }) } else if ( previousVisibleValue !== undefined && @@ -773,7 +759,6 @@ export class CollectionStateManager< type: `delete`, key, value: previousVisibleValue, - viewKey, }) } else if ( previousVisibleValue !== undefined && @@ -785,7 +770,6 @@ export class CollectionStateManager< key, value: newVisibleValue, previousValue: previousVisibleValue, - viewKey, }) } } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 6ddcd405a..bd3058e1c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -269,8 +269,6 @@ export interface ChangeMessage< previousValue?: T type: OperationType metadata?: Record - /** Stable view key for rendering (survives ID transitions) */ - viewKey?: string } export interface OptimisticChangeMessage<