0

I’m trying to write a Vitest unit test for an auto-save feature in a Svelte 5 project. The test sets meta.settings.autoSaveIntervalMs = 50 so the save cycle finishes quickly, but the $effect still uses the old value 5000, nothing seems to be written to IndexedDB, and load() returns undefined, crashing the assertion. Below are the relevant file code.

  1. model.ts
export interface ProjectMeta {
  title: string;
  settings: { autoSaveIntervalMs: number };
}
  1. state.svelte.ts (reactive central state manager)
import { type ProjectMeta } from './model';
import { persist, load } from './persistence';

export const createProjectMeta = (title = 'Untitled'): ProjectMeta => ({
  title,
  settings: { autoSaveIntervalMs: 5_000 },
});

export const meta = $state<ProjectMeta>(createProjectMeta());

export function startAutoSave() {
  $effect(() => {
    // shallow copy captured once
    const dirty = { meta: { ...meta } };

    const handle = setInterval(() => {
      persist(dirty);
      console.log('Autosaving', dirty);
    }, meta.settings.autoSaveIntervalMs);

    return () => clearInterval(handle);
  });

  $inspect(meta);
}
  1. persistence.ts (Dexie)
import Dexie from 'dexie';
import type { ProjectMeta } from './model';

interface Persisted { meta: ProjectMeta; }
type PersistedSerialized = Persisted;

const db = new (class extends Dexie {
  project!: Dexie.Table<PersistedSerialized, string>;
  constructor() {
    super('persist-on-interval');
    this.version(1).stores({ project: '' });
  }
})();

export const persist = (data: { meta: ProjectMeta }) => {
  console.log('About to store:', data);
  return db.project.put(data, 'main');
};

export const load = async () => {
  const raw = await db.project.get('main');
  console.log('Raw from DB;', raw);
  return raw ?? null;
};
  1. TestShell.svelte
<script>
  import { startAutoSave } from './state.svelte';
  startAutoSave();
</script>
  1. state.svelte.test.ts (the failing test)
import { describe, it, expect, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/svelte';
import TestShell from './TestShell.svelte';
import { meta } from './state.svelte';
import { load } from './persistence';

describe('State manager', () => {
  beforeEach(() => {
    Object.assign(meta, createProjectMeta());
  });

  it('persists on interval', async () => {
    // speed up the interval
    meta.settings.autoSaveIntervalMs = 50;

    render(TestShell);                // starts the $effect
    await waitFor(() => document.body);

    meta.title = 'Renamed title for auto-save';

    // wait at least one cycle
    await new Promise(r => setTimeout(r, 100));

    const restored = await load();
    expect(restored!.meta.title).toBe('Renamed title for auto-save');
  });
});

Here's what observed in the console:

init { title: 'Untitled', settings: { autoSaveIntervalMs: 50 } }

update { title: 'Renamed title for auto-save', settings: { autoSaveIntervalMs: 50 } }

Autosaving { meta: { title: 'Renamed title for auto-save', settings: { autoSaveIntervalMs: 5000 } } }

About to store: { meta: { title: 'Renamed title for auto-save', settings: { autoSaveIntervalMs: 5000 } } }

Raw from DB: undefined

Problem summary

  • In the test I set meta.settings.autoSaveIntervalMs = 50.
  • The $effect runs once, captures a shallow copy of meta, then schedules setInterval(..., meta.settings.autoSaveIntervalMs /* 50 */).
  • Yet the very first autosave log shows autoSaveIntervalMs: 5000 again, proving the copy is stale.
  • After 100 ms, load() returns undefined, so the test explodes with TypeError: Cannot read properties of undefined (reading 'meta').

I have tried changing the timeout period in the test, waiting for TestShell to successfully mount (as seen in the test), etc. but still got back raw (in load()) as undefined.

My questions:

  1. Why doesn’t the $effect re-run (and grab a fresh copy) when meta.settings.autoSaveIntervalMs changes?
  2. Am I missing some Dexie/Vitest quirk that causes the store to come back undefined even when data should have been written?

Any pointers would be greatly appreciated!

1
  • Based on @david-fahlander’s advice I have found out an inexplicable non-serializable Dexie error (ProjectMeta is serializable). However, it remains unclear why autoSaveIntervalMs mysteriously switched back to 5000 when it was about to be auto-saved, as if the Svelte state rune had completely forgotten the change. Commented Aug 25 at 15:35

2 Answers 2

1

After some digging, I found that Objects wrapped in a Svelte 5 state rune don't behave just like a normal Object (unlike in Svelte 4), as $state(...) wraps plain objects/arrays in a Svelte Proxy. This is what most likely led to inconsistencies in the data and the Dexie error: IndexedDB (and Node’s structuredClone) cannot serialize these Proxies, so Dexie throws DataCloneError: #<Object> could not be cloned. The fix is to simply replace the plain object spread with $state.snapshot(), which takes a static serializable snapshot of a deeply reactive $state proxy:

- const dirty = { meta: { ...meta } }
+ const dirty = { meta: $state.snapshot(meta) }
Sign up to request clarification or add additional context in comments.

1 Comment

Great you found it!
0

Handle the promise from persist() and log the then - and catch handlers.

1 Comment

Thanks for the advice, I have added some error handling in persist(), and got the error: ❌ IndexedDB write failed: DexieError [DataCloneError]: #<Object> could not be cloned.I found that this happens when the value passed to db.project.put() includes a non-serialisable type (e.g. a function, Svelte proxy, DOM node, etc.). However, I can't see where the data I want to save (Persisted/ProjectMeta) is non-serializble.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.