-
Notifications
You must be signed in to change notification settings - Fork 124
Add stable viewKey API to prevent UI re-renders during ID transitions #734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6cd4951
c029be7
e3214ec
74c3c2f
1af04c4
14a06d5
51e5e5b
0a9ca83
a0fb87d
cf3e8de
518c983
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| --- | ||
| "@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. | ||
|
|
||
| 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 | ||
| <li key={todoCollection.getViewKey(todo.id)}> | ||
| ``` | ||
|
|
||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -450,6 +450,62 @@ 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) => ( | ||
| * <li key={collection.getViewKey(todo.id)}> | ||
| * {todo.text} | ||
| * </li> | ||
| * ))} | ||
| */ | ||
| public getViewKey(key: TKey): string { | ||
| const viewKey = this._state.viewKeyMap.get(key) | ||
| return viewKey ?? String(key) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keys are of type |
||
| } | ||
|
|
||
| /** | ||
| * 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also a leaky abstraction. Move this implementation to the |
||
| const viewKey = this._state.viewKeyMap.get(tempKey) | ||
| 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.` | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Creates an index on a collection for faster queries. | ||
| * Indexes significantly improve query performance by allowing constant time lookups | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -176,6 +176,14 @@ export class CollectionMutationsManager< | |
| } | ||
| const globalKey = this.generateGlobalKey(key, item) | ||
|
|
||
| // Generate viewKey if configured | ||
| let viewKey: string | undefined | ||
| if (this.config.viewKey) { | ||
| viewKey = this.config.viewKey(validatedData) | ||
| // Store viewKey mapping | ||
| this.state.viewKeyMap.set(key, viewKey) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaky abstraction. If the data structure ever changes, this call needs to be changed too. |
||
| } | ||
|
|
||
| const mutation: PendingMutation<TOutput, `insert`> = { | ||
| mutationId: crypto.randomUUID(), | ||
| original: {}, | ||
|
|
@@ -198,6 +206,7 @@ export class CollectionMutationsManager< | |
| createdAt: new Date(), | ||
| updatedAt: new Date(), | ||
| collection: this.collection, | ||
| viewKey, | ||
| } | ||
|
|
||
| mutations.push(mutation) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,6 +50,9 @@ export class CollectionStateManager< | |
| public optimisticUpserts = new Map<TKey, TOutput>() | ||
| public optimisticDeletes = new Set<TKey>() | ||
|
|
||
| // ViewKey mapping for stable rendering keys across ID transitions | ||
| public viewKeyMap = new Map<TKey, string>() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to describe the type of the data structure in the field's name, i'd simplify the name to
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. needs to be emptied in the cleanup at the bottom of this manger class. |
||
|
|
||
| // Cached size for performance | ||
| public size = 0 | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a leaky abstraction. The caller (i.e.
getViewKey) here should not be aware of the internal representation of the view keys. I'd prefer moving thisgetViewKeymethod to theCollectionStateManagerand then here define a method that forwards the call: