Skip to content

Commit e19afbd

Browse files
committed
test: add regression tests for createOptimisticAction with syncMode on-demand
Add comprehensive tests to verify that createOptimisticAction works correctly with syncMode "on-demand" collections. These tests were created while investigating a reported bug where mutationFn was not being called when using on-demand collections. Tests added: - Basic on-demand collection with insert - Collection not started (idle state) - Collection in loading state (not ready) - On-demand collection with live query filter - UPDATE operation on on-demand collection - Debug test verifying mutations array is populated All tests pass, suggesting the reported issue may have been fixed by recent scheduler bug fixes or is specific to a configuration not covered here.
1 parent 489ed26 commit e19afbd

1 file changed

Lines changed: 352 additions & 0 deletions

File tree

packages/db/tests/optimistic-action.test.ts

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, expectTypeOf, it, vi } from "vitest"
22
import { createCollection, createOptimisticAction } from "../src"
3+
import { createLiveQueryCollection, eq } from "../src/query"
34
import type {
45
MutationFnParams,
56
Transaction,
@@ -207,6 +208,357 @@ describe(`createOptimisticAction`, () => {
207208
expectTypeOf(userAction).returns.toEqualTypeOf<Transaction>()
208209
})
209210

211+
// Test with syncMode "on-demand"
212+
it(`should call mutationFn when using syncMode on-demand`, async () => {
213+
// This test reproduces the bug where mutationFn is not called
214+
// when the collection is configured with syncMode: "on-demand"
215+
// Bug report: https://discord.com/channels/...
216+
// - onMutate runs but mutationFn never runs
217+
// - works with eager mode but not on-demand
218+
219+
const onMutateMock = vi.fn()
220+
const mutationFnMock = vi.fn().mockResolvedValue({ success: true })
221+
222+
// Create a collection with syncMode: "on-demand"
223+
// This requires a loadSubset handler
224+
const collection = createCollection<{ id: string; text: string }>({
225+
id: `on-demand-collection`,
226+
getKey: (item) => item.id,
227+
syncMode: `on-demand`,
228+
sync: {
229+
sync: ({ markReady }) => {
230+
// For on-demand mode, we mark ready immediately but don't load data
231+
// Data is loaded on-demand via loadSubset
232+
markReady()
233+
234+
return {
235+
loadSubset: () => {
236+
// No-op for testing - just return true to indicate sync
237+
return true
238+
},
239+
}
240+
},
241+
},
242+
})
243+
244+
// Create an optimistic action
245+
const addTodo = createOptimisticAction<string>({
246+
onMutate: (text) => {
247+
collection.insert({ id: `1`, text })
248+
onMutateMock(text)
249+
},
250+
mutationFn: async (text, params) => {
251+
return Promise.resolve(mutationFnMock(text, params))
252+
},
253+
})
254+
255+
// Execute the optimistic action
256+
const transaction = addTodo(`Test Todo`)
257+
258+
// Verify onMutate was called
259+
expect(onMutateMock).toHaveBeenCalledWith(`Test Todo`)
260+
261+
// Verify the optimistic update was applied to the collection
262+
expect(collection.get(`1`)).toEqual({ id: `1`, text: `Test Todo` })
263+
264+
// Wait for the mutation to complete
265+
await transaction.isPersisted.promise
266+
267+
// BUG: mutationFn should be called but it's not!
268+
expect(mutationFnMock).toHaveBeenCalledTimes(1)
269+
})
270+
271+
// Test with syncMode "on-demand" where collection has NOT started sync yet
272+
it(`should call mutationFn when collection is not started (idle)`, async () => {
273+
// This test checks if mutationFn is called when the collection hasn't started sync
274+
const onMutateMock = vi.fn()
275+
const mutationFnMock = vi.fn().mockResolvedValue({ success: true })
276+
277+
// Create a collection that doesn't start sync automatically
278+
const collection = createCollection<{ id: string; text: string }>({
279+
id: `idle-collection`,
280+
getKey: (item) => item.id,
281+
startSync: false,
282+
sync: {
283+
sync: () => {
284+
// No-op sync for testing
285+
},
286+
},
287+
})
288+
289+
// Create an optimistic action
290+
const addTodo = createOptimisticAction<string>({
291+
onMutate: (text) => {
292+
collection.insert({ id: `1`, text })
293+
onMutateMock(text)
294+
},
295+
mutationFn: async (text, params) => {
296+
return Promise.resolve(mutationFnMock(text, params))
297+
},
298+
})
299+
300+
// Execute the optimistic action (collection is in idle state)
301+
const transaction = addTodo(`Test Todo`)
302+
303+
// Verify onMutate was called
304+
expect(onMutateMock).toHaveBeenCalledWith(`Test Todo`)
305+
306+
// Verify the optimistic update was applied
307+
expect(collection.get(`1`)).toEqual({ id: `1`, text: `Test Todo` })
308+
309+
// Wait for the mutation to complete
310+
await transaction.isPersisted.promise
311+
312+
// mutationFn should be called
313+
expect(mutationFnMock).toHaveBeenCalledTimes(1)
314+
})
315+
316+
// Test with syncMode "on-demand" where sync is in loading state (not ready yet)
317+
it(`should call mutationFn when collection is loading (not ready)`, async () => {
318+
// This test checks if mutationFn is called when the collection is still loading
319+
const onMutateMock = vi.fn()
320+
const mutationFnMock = vi.fn().mockResolvedValue({ success: true })
321+
322+
// Create a collection that starts sync but doesn't call markReady
323+
const collection = createCollection<{ id: string; text: string }>({
324+
id: `loading-collection`,
325+
getKey: (item) => item.id,
326+
startSync: true,
327+
sync: {
328+
sync: () => {
329+
// Intentionally don't call markReady - collection stays in "loading" state
330+
return () => {}
331+
},
332+
},
333+
})
334+
335+
// Create an optimistic action
336+
const addTodo = createOptimisticAction<string>({
337+
onMutate: (text) => {
338+
collection.insert({ id: `1`, text })
339+
onMutateMock(text)
340+
},
341+
mutationFn: async (text, params) => {
342+
return Promise.resolve(mutationFnMock(text, params))
343+
},
344+
})
345+
346+
// Execute the optimistic action (collection is in loading state)
347+
const transaction = addTodo(`Test Todo`)
348+
349+
// Verify onMutate was called
350+
expect(onMutateMock).toHaveBeenCalledWith(`Test Todo`)
351+
352+
// Verify the optimistic update was applied
353+
expect(collection.get(`1`)).toEqual({ id: `1`, text: `Test Todo` })
354+
355+
// Wait for the mutation to complete
356+
await transaction.isPersisted.promise
357+
358+
// mutationFn should be called
359+
expect(mutationFnMock).toHaveBeenCalledTimes(1)
360+
})
361+
362+
// Test with on-demand collection and a live query with filters - the reported scenario
363+
it(`should call mutationFn with on-demand collection and live query filter`, async () => {
364+
// This test attempts to reproduce the exact bug scenario:
365+
// - Base collection with syncMode: "on-demand"
366+
// - Live query collection with filters on top
367+
// - createOptimisticAction used to mutate the base collection
368+
// - Bug: onMutate runs but mutationFn never runs
369+
370+
const onMutateMock = vi.fn()
371+
const mutationFnMock = vi.fn().mockResolvedValue({ success: true })
372+
373+
// Create a base collection with syncMode: "on-demand"
374+
type Todo = { id: string; text: string; status: string }
375+
const baseCollection = createCollection<Todo>({
376+
id: `on-demand-base-collection`,
377+
getKey: (item) => item.id,
378+
syncMode: `on-demand`,
379+
startSync: true,
380+
sync: {
381+
sync: ({ markReady, begin, write, commit }) => {
382+
// Simulate on-demand mode: mark ready immediately, load data on demand
383+
markReady()
384+
385+
// Pre-populate with some data
386+
begin()
387+
write({
388+
type: `insert`,
389+
value: { id: `1`, text: `Existing todo`, status: `active` },
390+
})
391+
commit()
392+
393+
return {
394+
loadSubset: () => {
395+
return true
396+
},
397+
}
398+
},
399+
},
400+
})
401+
402+
// Create a live query collection with a filter on status
403+
const activeTodos = createLiveQueryCollection({
404+
id: `active-todos-query`,
405+
startSync: true,
406+
query: (q) =>
407+
q
408+
.from({ todo: baseCollection })
409+
.where(({ todo }) => eq(todo.status, `active`))
410+
.select(({ todo }) => ({ todo })),
411+
})
412+
413+
// Wait for the live query to be ready
414+
await activeTodos.preload()
415+
416+
// Verify initial state
417+
expect([...activeTodos.values()].length).toBe(1)
418+
expect([...activeTodos.values()][0]?.todo.text).toBe(`Existing todo`)
419+
420+
// Create an optimistic action to INSERT a new todo
421+
const addTodo = createOptimisticAction<{ text: string }>({
422+
onMutate: (input) => {
423+
baseCollection.insert({ id: `2`, text: input.text, status: `active` })
424+
onMutateMock(input)
425+
},
426+
mutationFn: async (input, params) => {
427+
return Promise.resolve(mutationFnMock(input, params))
428+
},
429+
})
430+
431+
// Execute the optimistic action
432+
const transaction = addTodo({ text: `New todo` })
433+
434+
// Verify onMutate was called
435+
expect(onMutateMock).toHaveBeenCalledWith({ text: `New todo` })
436+
437+
// Verify the optimistic update was applied to the base collection
438+
expect(baseCollection.get(`2`)).toEqual({
439+
id: `2`,
440+
text: `New todo`,
441+
status: `active`,
442+
})
443+
444+
// Wait for the mutation to complete
445+
await transaction.isPersisted.promise
446+
447+
// BUG: mutationFn should be called!
448+
expect(mutationFnMock).toHaveBeenCalledTimes(1)
449+
})
450+
451+
// Test UPDATE scenario which might have different behavior
452+
it(`should call mutationFn when UPDATE is performed on on-demand collection`, async () => {
453+
const onMutateMock = vi.fn()
454+
const mutationFnMock = vi.fn().mockResolvedValue({ success: true })
455+
456+
type Todo = { id: string; text: string; status: string }
457+
const collection = createCollection<Todo>({
458+
id: `on-demand-update-collection`,
459+
getKey: (item) => item.id,
460+
syncMode: `on-demand`,
461+
startSync: true,
462+
sync: {
463+
sync: ({ markReady, begin, write, commit }) => {
464+
markReady()
465+
466+
// Pre-populate with data
467+
begin()
468+
write({
469+
type: `insert`,
470+
value: { id: `1`, text: `Original text`, status: `active` },
471+
})
472+
commit()
473+
474+
return {
475+
loadSubset: () => true,
476+
}
477+
},
478+
},
479+
})
480+
481+
// Create an optimistic action to UPDATE an existing todo
482+
const updateTodo = createOptimisticAction<{ id: string; text: string }>({
483+
onMutate: (input) => {
484+
collection.update(input.id, (draft) => {
485+
draft.text = input.text
486+
})
487+
onMutateMock(input)
488+
},
489+
mutationFn: async (input, params) => {
490+
return Promise.resolve(mutationFnMock(input, params))
491+
},
492+
})
493+
494+
// Execute the optimistic action
495+
const transaction = updateTodo({ id: `1`, text: `Updated text` })
496+
497+
// Verify onMutate was called
498+
expect(onMutateMock).toHaveBeenCalledWith({ id: `1`, text: `Updated text` })
499+
500+
// Verify the optimistic update was applied
501+
expect(collection.get(`1`)?.text).toBe(`Updated text`)
502+
503+
// Wait for the mutation to complete
504+
await transaction.isPersisted.promise
505+
506+
// mutationFn should be called
507+
expect(mutationFnMock).toHaveBeenCalledTimes(1)
508+
})
509+
510+
// Debug test: verify mutations array is populated correctly
511+
it(`should have mutations in the transaction after onMutate completes`, async () => {
512+
const onMutateMock = vi.fn()
513+
const mutationFnMock = vi.fn().mockResolvedValue({ success: true })
514+
515+
// Create an on-demand collection with live query filter
516+
type Todo = { id: string; text: string; status: string }
517+
const baseCollection = createCollection<Todo>({
518+
id: `debug-collection`,
519+
getKey: (item) => item.id,
520+
syncMode: `on-demand`,
521+
startSync: true,
522+
sync: {
523+
sync: ({ markReady }) => {
524+
markReady()
525+
return {
526+
loadSubset: () => true,
527+
}
528+
},
529+
},
530+
})
531+
532+
// Track mutations count at mutationFn call time
533+
let mutationsAtMutationFn: number | undefined
534+
535+
const addTodo = createOptimisticAction<{ text: string }>({
536+
onMutate: (input) => {
537+
baseCollection.insert({ id: `1`, text: input.text, status: `active` })
538+
onMutateMock(input)
539+
},
540+
mutationFn: async (input, params) => {
541+
// Record the number of mutations at this point
542+
mutationsAtMutationFn = params.transaction.mutations.length
543+
return Promise.resolve(mutationFnMock(input, params))
544+
},
545+
})
546+
547+
const transaction = addTodo({ text: `Test` })
548+
549+
// Verify onMutate was called
550+
expect(onMutateMock).toHaveBeenCalled()
551+
552+
// Wait for the transaction to complete
553+
await transaction.isPersisted.promise
554+
555+
// Verify mutationFn was called
556+
expect(mutationFnMock).toHaveBeenCalledTimes(1)
557+
558+
// Verify there was at least one mutation
559+
expect(mutationsAtMutationFn).toBeGreaterThan(0)
560+
})
561+
210562
// Test error handling
211563
it(`should handle errors in mutationFn correctly`, async () => {
212564
// Setup a mock collection

0 commit comments

Comments
 (0)