diff --git a/.changeset/sweet-kings-wear.md b/.changeset/sweet-kings-wear.md new file mode 100644 index 000000000..cfddc8891 --- /dev/null +++ b/.changeset/sweet-kings-wear.md @@ -0,0 +1,6 @@ +--- +"@tanstack/react-optimistic": patch +"@tanstack/optimistic": patch +--- + +Make transactions first class & move ownership of mutationFn from collections to transactions diff --git a/README.md b/README.md index ec857d083..232e26b73 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,25 @@ The library uses proxies to create immutable snapshots and track changes: The primary hook for interacting with collections in React components. ```typescript +// Create a collection const { data, insert, update, delete: deleteFn } = useCollection({ id: 'todos', sync: { /* sync configuration */ }, - mutationFn: { /* mutation functions */ }, schema: /* optional schema */ }); + +// Create a mutation +const mutation = useOptimisticMutation({ + mutationFn: async ({ mutations }) => { + // Implement your mutation logic here + // This function is called when mutations are committed + } +}); + +// Use the mutation with collection operations +mutation.mutate(() => { + insert({ text: 'New todo' }); +}); ``` Returns: @@ -167,10 +180,22 @@ const todoCollection = useCollection({ ## Transaction Management -The library includes a robust transaction management system: +The library includes a simple yet powerful transaction management system. Transactions are created using the `createTransaction` function: + +```typescript +const tx = createTransaction({ + mutationFn: async ({ transaction }) => { + // Implement your mutation logic here + // This function is called when the transaction is committed + }, +}) -- `TransactionManager`: Handles transaction lifecycle, persistence, and retry logic -- `TransactionStore`: Provides persistent storage for transactions using IndexedDB +// Apply mutations within the transaction +tx.mutate(() => { + // All collection operations (insert/update/delete) within this callback + // will be part of this transaction +}) +``` Transactions progress through several states: @@ -204,35 +229,56 @@ const todosConfig = { primaryKey: ['id'], } ), - // Persist mutations to ElectricSQL - mutationFn: async (mutations, transaction, config) => { - const response = await fetch(`http://localhost:3001/api/mutations`, { - method: `POST`, - headers: { - "Content-Type": `application/json`, - }, - body: JSON.stringify(transaction.mutations), +}; + +// In your component +function TodoList() { + const { data, insert, update, delete: deleteFn } = useCollection(todosConfig) + + // Create a mutation for handling all todo operations + const todoMutation = useOptimisticMutation({ + mutationFn: async ({ transaction }) => { + // Filter out collection from mutations before sending to server + const payload = transaction.mutations.map(m => { + const { collection, ...payload } = m + return payload + }) + + const response = await fetch(`http://localhost:3001/api/mutations`, { + method: `POST`, + headers: { + "Content-Type": `application/json`, + }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + // Throwing an error will rollback the optimistic state. + throw new Error(`HTTP error! Status: ${response.status}`) + } + + const result = await response.json() + + try { + // Use the awaitTxid function from the ElectricSync configuration + // This waits for the specific transaction to be synced to the server + await transaction.mutations[0].collection.config.sync.awaitTxid(result.txid) + } catch (error) { + console.error('Error waiting for transaction to sync:', error); + // Throwing an error will rollback the optimistic state. + throw error; + } + }, + }) + + // Use the mutation for any todo operations + const addTodo = () => { + todoMutation.mutate(() => { + insert({ title: 'New todo', completed: false }) }) - if (!response.ok) { - // Throwing an error will rollback the optimistic state. - throw new Error(`HTTP error! Status: ${response.status}`) - } + } - const result = await response.json() - - try { - // Use the awaitTxid function from the ElectricSync configuration - // This waits for the specific transaction to be synced to the server - // The second parameter is an optional timeout in milliseconds - await config.sync.awaitTxid(persistResult.txid, 10000) - return true; - } catch (error) { - console.error('Error waiting for transaction to sync:', error); - // Throwing an error will rollback the optimistic state. - throw error; - } - }, -}; + // ... rest of your component +} // In a route loader export async function loader() { diff --git a/examples/react/todo/package.json b/examples/react/todo/package.json index cfec16b6a..b1fa984a9 100644 --- a/examples/react/todo/package.json +++ b/examples/react/todo/package.json @@ -44,8 +44,8 @@ "db:generate": "drizzle-kit generate", "db:push": "tsx scripts/migrate.ts", "db:studio": "drizzle-kit studio", - "dev": "docker-compose up -d && concurrently \"pnpm api:dev\" \"vite\"", - "lint": "eslint .", + "dev": "docker compose up -d && concurrently \"pnpm api:dev\" \"vite\"", + "lint": "eslint . --fix", "preview": "vite preview" }, "type": "module" diff --git a/examples/react/todo/src/App.tsx b/examples/react/todo/src/App.tsx index c9634a78c..973756e37 100644 --- a/examples/react/todo/src/App.tsx +++ b/examples/react/todo/src/App.tsx @@ -1,94 +1,112 @@ import React, { useState } from "react" -import { createElectricSync, useCollection } from "@tanstack/react-optimistic" +import { + Collection, + createElectricSync, + createTransaction, + useLiveQuery, +} from "@tanstack/react-optimistic" import { DevTools } from "./DevTools" import { updateConfigSchema, updateTodoSchema } from "./db/validation" +import type { MutationFn, PendingMutation } from "@tanstack/react-optimistic" import type { UpdateConfig, UpdateTodo } from "./db/validation" -import type { Collection } from "@tanstack/react-optimistic" import type { FormEvent } from "react" -export default function App() { - const [newTodo, setNewTodo] = useState(``) +const todoMutationFn: MutationFn = async ({ transaction }) => { + const payload = transaction.mutations.map( + (m: PendingMutation) => { + const { collection, ...rest } = m + return rest + } + ) + const response = await fetch(`http://localhost:3001/api/mutations`, { + method: `POST`, + headers: { + "Content-Type": `application/json`, + }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`) + } - const { - data: todos, - insert, - update, - delete: deleteTodo, - } = useCollection({ - id: `todos`, - sync: createElectricSync( - { - url: `http://localhost:3003/v1/shape`, - params: { - table: `todos`, - }, - parser: { - // Parse timestamp columns into JavaScript Date objects - timestamptz: (date: string) => new Date(date), - }, - }, - { primaryKey: [`id`] } - ), - schema: updateTodoSchema, - mutationFn: async ({ transaction, collection }) => { - const response = await fetch(`http://localhost:3001/api/mutations`, { - method: `POST`, - headers: { - "Content-Type": `application/json`, - }, - body: JSON.stringify(transaction.mutations), - }) - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) - } + const result = await response.json() - const result = await response.json() + // Start waiting for the txid + await transaction.mutations[0]!.collection.config.sync.awaitTxid(result.txid) +} - // Start waiting for the txid - await collection.config.sync.awaitTxid(result.txid) +const configMutationFn: MutationFn = async ({ transaction }) => { + const payload = transaction.mutations.map( + (m: PendingMutation) => { + const { collection, ...rest } = m + return rest + } + ) + const response = await fetch(`http://localhost:3001/api/mutations`, { + method: `POST`, + headers: { + "Content-Type": `application/json`, }, + body: JSON.stringify(payload), }) + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`) + } - const { - data: configData, - update: updateConfig, - insert: insertConfig, - } = useCollection({ - id: `config`, - sync: createElectricSync( - { - url: `http://localhost:3003/v1/shape`, - params: { - table: `config`, - }, - parser: { - // Parse timestamp columns into JavaScript Date objects - timestamptz: (date: string) => { - return new Date(date) - }, - }, + const result = await response.json() + + // Start waiting for the txid + await transaction.mutations[0]!.collection.config.sync.awaitTxid(result.txid) +} + +const todoCollection = new Collection({ + id: `todos`, + sync: createElectricSync( + { + url: `http://localhost:3003/v1/shape`, + params: { + table: `todos`, + }, + parser: { + // Parse timestamp columns into JavaScript Date objects + timestamptz: (date: string) => new Date(date), + }, + }, + { primaryKey: [`id`] } + ), + schema: updateTodoSchema, +}) + +const configCollection = new Collection({ + id: `config`, + sync: createElectricSync( + { + url: `http://localhost:3003/v1/shape`, + params: { + table: `config`, }, - { primaryKey: [`id`] } - ), - schema: updateConfigSchema, - mutationFn: async ({ transaction, collection }) => { - const response = await fetch(`http://localhost:3001/api/mutations`, { - method: `POST`, - headers: { - "Content-Type": `application/json`, + parser: { + // Parse timestamp columns into JavaScript Date objects + timestamptz: (date: string) => { + return new Date(date) }, - body: JSON.stringify(transaction.mutations), - }) - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) - } + }, + }, + { primaryKey: [`id`] } + ), + schema: updateConfigSchema, +}) + +export default function App() { + const [newTodo, setNewTodo] = useState(``) - const result = await response.json() + const { data: todos } = useLiveQuery((q) => + q.from({ todoCollection }).keyBy(`@id`).select(`@id`, `@text`, `@completed`) + ) - // Start waiting for the txid - await collection.config.sync.awaitTxid(result.txid) - }, - }) + const { data: configData } = useLiveQuery((q) => + q.from({ configCollection }).keyBy(`@id`).select(`@id`, `@key`, `@value`) + ) // Define a more robust type-safe helper function to get config values const getConfigValue = (key: string): string => { @@ -104,19 +122,26 @@ export default function App() { const setConfigValue = (key: string, value: string): void => { for (const config of configData) { if (config.key === key) { - updateConfig(config, (draft) => { - draft.value = value - }) + createTransaction({ mutationFn: configMutationFn }).mutate(() => + configCollection.update( + Array.from(configCollection.state.values())[0]!, + (draft) => { + draft.value = value + } + ) + ) return } } // If the config doesn't exist yet, create it - insertConfig({ - key, - value, - }) + createTransaction({ mutationFn: configMutationFn }).mutate(() => + configCollection.insert({ + key, + value, + }) + ) } const backgroundColor = getConfigValue(`backgroundColor`) @@ -176,17 +201,29 @@ export default function App() { e.preventDefault() if (!newTodo.trim()) return - insert({ - text: newTodo, - completed: false, - }) + const tx = createTransaction({ mutationFn: todoMutationFn }) + tx.mutate(() => + todoCollection.insert({ + text: newTodo, + completed: false, + id: Math.round(Math.random() * 1000000), + }) + ) setNewTodo(``) } const toggleTodo = (todo: UpdateTodo) => { - update(todo, (draft) => { - draft.completed = !draft.completed - }) + const tx = createTransaction({ mutationFn: todoMutationFn }) + tx.mutate(() => + todoCollection.update( + Array.from(todoCollection.state.values()).find( + (t) => t.id === todo.id + )!, + (draft) => { + draft.completed = !draft.completed + } + ) + ) } const activeTodos = todos.filter((todo) => !todo.completed) @@ -233,13 +270,23 @@ export default function App() { className="absolute left-0 w-12 h-full text-[30px] text-[#e6e6e6] hover:text-[#4d4d4d]" onClick={() => { const allCompleted = completedTodos.length === todos.length - update( - allCompleted ? completedTodos : activeTodos, - (drafts) => { - drafts.forEach( - (draft) => (draft.completed = !allCompleted) - ) - } + const tx = createTransaction({ mutationFn: todoMutationFn }) + const todosToToggle = allCompleted + ? completedTodos + : activeTodos + const togglingIds = new Set() + todosToToggle.forEach((t) => togglingIds.add(t.id)) + tx.mutate(() => + todoCollection.update( + Array.from(todoCollection.state.values()).filter((t) => + togglingIds.has(t.id) + ), + (drafts) => { + drafts.forEach( + (draft) => (draft.completed = !allCompleted) + ) + } + ) ) }} > @@ -281,7 +328,18 @@ export default function App() { {todo.text} +
    + {data.map(todo => ( +
  • + toggleTodo(todo)} + disabled={todoMutation.isPending} + /> + {todo.title} + +
  • + ))} +
+ + ) } ``` diff --git a/packages/react-optimistic/package.json b/packages/react-optimistic/package.json index ecd8b75a8..557251ab2 100644 --- a/packages/react-optimistic/package.json +++ b/packages/react-optimistic/package.json @@ -58,7 +58,8 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", - "test": "npx vitest --run" + "test": "npx vitest --run", + "lint": "eslint . --fix" }, "sideEffects": false, "type": "module", diff --git a/packages/react-optimistic/src/index.ts b/packages/react-optimistic/src/index.ts index f037b5d53..bac2ca6f2 100644 --- a/packages/react-optimistic/src/index.ts +++ b/packages/react-optimistic/src/index.ts @@ -1,7 +1,12 @@ // Re-export all public APIs export * from "./useCollection" +export * from "./useOptimisticMutation" export * from "./useLiveQuery" export * from "./electric" // Re-export everything from @tanstack/optimistic export * from "@tanstack/optimistic" + +// Re-export some stuff explicitly to ensure the type & value is exported +export { Collection } from "@tanstack/optimistic" +export { createTransaction } from "@tanstack/optimistic" diff --git a/packages/react-optimistic/src/useCollection.ts b/packages/react-optimistic/src/useCollection.ts index ee308713f..27071f78b 100644 --- a/packages/react-optimistic/src/useCollection.ts +++ b/packages/react-optimistic/src/useCollection.ts @@ -40,15 +40,8 @@ export function useCollections() { snapshotCache = null // Invalidate cache when state changes callback() }) - const transactionsUnsub = - collection.transactionManager.transactions.subscribe(() => { - snapshotCache = null // Invalidate cache when transactions change - callback() - }) - return () => { - derivedStateUnsub() - transactionsUnsub() - } + + return derivedStateUnsub }) return () => { @@ -72,7 +65,7 @@ export function useCollections() { for (const [id, collection] of collectionsStore.state) { snapshot.set(id, { state: collection.derivedState.state, - transactions: collection.transactionManager.transactions.state, + transactions: collection.transactions.state, }) } snapshotCache = snapshot @@ -94,7 +87,7 @@ export function useCollections() { for (const [id, collection] of collectionsStore.state) { snapshot.set(id, { state: collection.derivedState.state, - transactions: collection.transactionManager.transactions.state, + transactions: collection.transactions.state, }) } snapshotCache = snapshot @@ -308,7 +301,6 @@ export function useCollection( new Collection({ id: config.id, sync: config.sync, - mutationFn: config.mutationFn, schema: config.schema, }) ) diff --git a/packages/react-optimistic/src/useLiveQuery.ts b/packages/react-optimistic/src/useLiveQuery.ts index 3fe740975..cc0551096 100644 --- a/packages/react-optimistic/src/useLiveQuery.ts +++ b/packages/react-optimistic/src/useLiveQuery.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react" +import { useEffect, useMemo, useState } from "react" import { useStore } from "@tanstack/react-store" import { compileQuery, queryBuilder } from "@tanstack/optimistic" import type { @@ -22,22 +22,31 @@ export function useLiveQuery< ) => QueryBuilder, deps: Array = [] ): UseLiveQueryReturn> { + const [restart, forceRestart] = useState(0) + const compiledQuery = useMemo(() => { const query = queryFn(queryBuilder()) const compiled = compileQuery(query) compiled.start() return compiled - }, deps) + }, [...deps, restart]) + const state = useStore(compiledQuery.results.derivedState) + let data: Array> | undefined + + // Clean up on unmount useEffect(() => { + if (compiledQuery.state === `stopped`) { + forceRestart((count) => { + return (count += 1) + }) + } + return () => { compiledQuery.stop() } }, [compiledQuery]) - const state = useStore(compiledQuery.results.derivedState) - let data: Array> | undefined - return { state, get data() { diff --git a/packages/react-optimistic/src/useOptimisticMutation.ts b/packages/react-optimistic/src/useOptimisticMutation.ts new file mode 100644 index 000000000..dcd1f7d22 --- /dev/null +++ b/packages/react-optimistic/src/useOptimisticMutation.ts @@ -0,0 +1,15 @@ +import { createTransaction } from "@tanstack/optimistic" +import type { Transaction, TransactionConfig } from "@tanstack/optimistic" + +export function useOptimisticMutation(config: TransactionConfig) { + return { + mutate: (callback: () => void): Transaction => { + const transaction = createTransaction(config) + transaction.mutate(callback) + return transaction + }, + createTransaction: (): Transaction => { + return createTransaction({ ...config, autoCommit: false }) + }, + } +} diff --git a/packages/react-optimistic/tests/electric.test.ts b/packages/react-optimistic/tests/electric.test.ts index f71829460..8a3bb1225 100644 --- a/packages/react-optimistic/tests/electric.test.ts +++ b/packages/react-optimistic/tests/electric.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest" -import { Collection } from "@tanstack/optimistic" +import { Collection, createTransaction } from "@tanstack/optimistic" import { createElectricSync } from "../src/electric" import type { PendingMutation, Transaction } from "@tanstack/optimistic" import type { Message, Row } from "@electric-sql/client" @@ -48,7 +48,6 @@ describe(`Electric Integration`, () => { collection = new Collection({ id: `test`, sync: electricSync, - mutationFn: vi.fn().mockResolvedValue(undefined), }) }) @@ -362,17 +361,17 @@ describe(`Electric Integration`, () => { const testCollection = new Collection({ id: `ofo`, sync: electricSync, - mutationFn: testMutationFn, }) - let transaction = testCollection.insert( - { id: 1, name: `Test item 1` }, - { key: `item1` } + const tx1 = createTransaction({ mutationFn: testMutationFn }) + + let transaction = tx1.mutate(() => + testCollection.insert({ id: 1, name: `Test item 1` }, { key: `item1` }) ) - await transaction.isPersisted?.promise + await transaction.isPersisted.promise - transaction = testCollection.transactions.get(transaction.id)! + transaction = testCollection.transactions.state.get(transaction.id)! // Verify the mutation function was called correctly expect(testMutationFn).toHaveBeenCalledTimes(1) @@ -414,32 +413,27 @@ describe(`Electric Integration`, () => { it(`Transaction proxy with toObject method works correctly`, () => { // Create a collection with a simple mutation function - const testCollection = new Collection({ + new Collection({ id: `foo`, sync: createElectricSync({ url: `foo` }, { primaryKey: [`id`] }), // Add primary key - mutationFn: async () => {}, }) // Create a transaction - const transaction = testCollection.transactionManager.applyTransaction( - [] // No mutations - ) + const transaction = createTransaction({ + mutationFn: () => { + return Promise.resolve() + }, + }) // Test that we can access properties expect(transaction.id).toBeDefined() expect(transaction.state).toBe(`pending`) - // Test the toObject method - const transactionObj = transaction.toObject() - expect(transactionObj).toBeDefined() - expect(transactionObj.id).toBe(transaction.id) - expect(transactionObj.state).toBe(transaction.state) - // Test that we can create a modified clone const modifiedTransaction = { - ...transaction.toObject(), + ...transaction, metadata: { - ...transaction.toObject().metadata, + ...transaction.metadata, testKey: `testValue`, }, } diff --git a/packages/react-optimistic/tests/useCollection.test.tsx b/packages/react-optimistic/tests/useCollection.test.tsx index db3451d38..7aaf42fb9 100644 --- a/packages/react-optimistic/tests/useCollection.test.tsx +++ b/packages/react-optimistic/tests/useCollection.test.tsx @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from "vitest" import { act, renderHook } from "@testing-library/react" import mitt from "mitt" +import { createTransaction } from "@tanstack/optimistic" import { useCollection } from "../src/useCollection" -import type { PendingMutation } from "@tanstack/optimistic" +import { useOptimisticMutation } from "../src/useOptimisticMutation" +import type { MutationFn, PendingMutation } from "@tanstack/optimistic" describe(`useCollection`, () => { it(`should handle insert, update, and delete operations`, async () => { @@ -28,6 +30,10 @@ describe(`useCollection`, () => { }) }, }, + }) + ) + const mutationHook = renderHook(() => + useOptimisticMutation({ mutationFn: ({ transaction }) => { persistMock() act(() => { @@ -37,6 +43,7 @@ describe(`useCollection`, () => { }, }) ) + const { mutate } = mutationHook.result.current // Initial state should be empty expect(result.current.state.size).toBe(0) @@ -44,7 +51,9 @@ describe(`useCollection`, () => { // Test single insert with explicit key await act(async () => { - await result.current.insert({ name: `Alice` }, { key: `user1` }) + await Promise.resolve( + mutate(() => result.current.insert({ name: `Alice` }, { key: `user1` })) + ) }) // Verify insert @@ -54,9 +63,13 @@ describe(`useCollection`, () => { // Test bulk insert with sparse keys await act(async () => { - await result.current.insert([{ name: `Bob` }, { name: `Charlie` }], { - key: [`user2`, undefined], - }) + await Promise.resolve( + mutate(() => + result.current.insert([{ name: `Bob` }, { name: `Charlie` }], { + key: [`user2`, undefined], + }) + ) + ) }) // Get the auto-generated key for Charlie @@ -72,16 +85,17 @@ describe(`useCollection`, () => { // Test update with callback const updateTransaction = await act(async () => { - return await result.current.update( - result.current.state.get(`user1`)!, - (item) => { - item.name = `Alice Smith` - } + return Promise.resolve( + mutate(() => + result.current.update(result.current.state.get(`user1`)!, (item) => { + item.name = `Alice Smith` + }) + ) ) }) await act(async () => { - await updateTransaction.isPersisted?.promise + await updateTransaction.isPersisted.promise }) // Verify update @@ -94,18 +108,22 @@ describe(`useCollection`, () => { result.current.state.get(`user1`)!, result.current.state.get(`user2`)!, ] - return await result.current.update( - items, - { metadata: { bulkUpdate: true } }, - (drafts) => { - drafts.forEach((draft, i) => { - if (i === 0) { - draft.name = draft.name + ` Jr.` - } else if (i === 1) { - draft.name = draft.name + ` Sr.` + return await Promise.resolve( + mutate(() => + result.current.update( + items, + { metadata: { bulkUpdate: true } }, + (drafts) => { + drafts.forEach((draft, i) => { + if (i === 0) { + draft.name = draft.name + ` Jr.` + } else if (i === 1) { + draft.name = draft.name + ` Sr.` + } + }) } - }) - } + ) + ) ) }) @@ -119,7 +137,9 @@ describe(`useCollection`, () => { // Test single delete await act(async () => { - await result.current.delete(result.current.state.get(`user1`)!) + await Promise.resolve( + mutate(() => result.current.delete(result.current.state.get(`user1`)!)) + ) }) // Verify single delete @@ -132,9 +152,13 @@ describe(`useCollection`, () => { result.current.state.get(`user2`)!, result.current.state.get(charlieKey!)!, ] - await result.current.delete(items, { - metadata: { reason: `bulk cleanup` }, - }) + await Promise.resolve( + mutate(() => + result.current.delete(items, { + metadata: { reason: `bulk cleanup` }, + }) + ) + ) }) // Verify all items are deleted @@ -146,14 +170,33 @@ describe(`useCollection`, () => { expect(persistMock).toHaveBeenCalledTimes(6) // 2 inserts + 2 updates + 2 deletes }) - it(`should expose state, items, and data properties correctly`, async () => { + it(`should allow you to do manually committed transactions`, async () => { const emitter = mitt() const persistMock = vi.fn().mockResolvedValue(undefined) // Setup initial hook render const { result } = renderHook(() => - useCollection({ - id: `test-properties`, + useCollection<{ name: string }>({ + id: `test-collection`, + sync: { + sync: ({ begin, write, commit }) => { + emitter.on(`*`, (_, mutations) => { + begin() + ;(mutations as Array).forEach((mutation) => { + write({ + key: mutation.key, + type: mutation.type, + value: mutation.changes as { name: string }, + }) + }) + commit() + }) + }, + }, + }) + ) + const mutationHook = renderHook(() => + useOptimisticMutation({ mutationFn: ({ transaction }) => { persistMock() act(() => { @@ -161,6 +204,35 @@ describe(`useCollection`, () => { }) return Promise.resolve() }, + }) + ) + const transaction = mutationHook.result.current.createTransaction() + + // Initial state should be empty + expect(result.current.state.size).toBe(0) + expect(result.current.data).toEqual([]) + + // Test single insert with explicit key + await act(async () => { + await Promise.resolve() + transaction.mutate(() => + result.current.insert({ name: `Alice` }, { key: `user1` }) + ) + + transaction.commit() + }) + + expect(transaction.state).toBe(`completed`) + }) + + it(`should expose state, items, and data properties correctly`, async () => { + const emitter = mitt() + const persistMock = vi.fn().mockResolvedValue(undefined) + + // Setup initial hook render + const { result } = renderHook(() => + useCollection({ + id: `test-properties`, sync: { sync: ({ begin, write, commit }) => { emitter.on(`*`, (_, mutations) => { @@ -178,6 +250,13 @@ describe(`useCollection`, () => { }, }) ) + const mutationFn: MutationFn = ({ transaction }) => { + persistMock() + act(() => { + emitter.emit(`update`, transaction.mutations) + }) + return Promise.resolve() + } // Initial state should be empty expect(result.current.state).toBeInstanceOf(Map) @@ -187,13 +266,17 @@ describe(`useCollection`, () => { // Insert some test data await act(async () => { - await result.current.insert( - [ - { id: 1, name: `Item 1` }, - { id: 2, name: `Item 2` }, - { id: 3, name: `Item 3` }, - ], - { key: [`key1`, `key2`, `key3`] } + const tx = createTransaction({ mutationFn }) + await Promise.resolve() + tx.mutate(() => + result.current.insert( + [ + { id: 1, name: `Item 1` }, + { id: 2, name: `Item 2` }, + { id: 3, name: `Item 3` }, + ], + { key: [`key1`, `key2`, `key3`] } + ) ) emitter.emit(`update`, [ { key: `key1`, type: `insert`, changes: { id: 1, name: `Item 1` } }, @@ -223,13 +306,6 @@ describe(`useCollection`, () => { const { result } = renderHook(() => useCollection<{ id: number; name: string }>({ id: `test-selector`, - mutationFn: ({ transaction }) => { - persistMock() - act(() => { - emitter.emit(`update`, transaction.mutations) - }) - return Promise.resolve() - }, sync: { sync: ({ begin, write, commit }) => { emitter.on(`*`, (_, mutations) => { @@ -248,6 +324,13 @@ describe(`useCollection`, () => { }, }) ) + const mutationFn: MutationFn = ({ transaction }) => { + persistMock() + act(() => { + emitter.emit(`update`, transaction.mutations) + }) + return Promise.resolve() + } // Initial state expect(result.current.state).toBeInstanceOf(Map) @@ -257,13 +340,17 @@ describe(`useCollection`, () => { // Insert some test data await act(async () => { - await result.current.insert( - [ - { id: 1, name: `Alice` }, - { id: 2, name: `Bob` }, - { id: 3, name: `Charlie` }, - ], - { key: [`key1`, `key2`, `key3`] } + await Promise.resolve() + const tx = createTransaction({ mutationFn }) + tx.mutate(() => + result.current.insert( + [ + { id: 1, name: `Alice` }, + { id: 2, name: `Bob` }, + { id: 3, name: `Charlie` }, + ], + { key: [`key1`, `key2`, `key3`] } + ) ) emitter.emit(`update`, [ { key: `key1`, type: `insert`, changes: { id: 1, name: `Alice` } }, diff --git a/packages/react-optimistic/tests/useLiveQuery.test.tsx b/packages/react-optimistic/tests/useLiveQuery.test.tsx index 1e914ff43..2c2a3053b 100644 --- a/packages/react-optimistic/tests/useLiveQuery.test.tsx +++ b/packages/react-optimistic/tests/useLiveQuery.test.tsx @@ -95,10 +95,6 @@ describe(`Query Collections`, () => { }) }, }, - mutationFn: async ({ transaction }) => { - emitter.emit(`sync`, transaction.mutations) - return Promise.resolve() - }, }) // Sync from initial state @@ -249,10 +245,6 @@ describe(`Query Collections`, () => { ) }, }, - mutationFn: async ({ transaction }) => { - emitter.emit(`sync-person`, transaction.mutations) - return Promise.resolve() - }, }) // Create issue collection @@ -427,10 +419,6 @@ describe(`Query Collections`, () => { }) }, }, - mutationFn: async ({ transaction }) => { - emitter.emit(`sync`, transaction.mutations) - return Promise.resolve() - }, }) // Sync from initial state @@ -526,10 +514,6 @@ describe(`Query Collections`, () => { }) }, }, - mutationFn: async ({ transaction }) => { - emitter.emit(`sync`, transaction.mutations) - return Promise.resolve() - }, }) // Mock console.log to track when compiledQuery.stop() is called