typescriptsveltevitestdexiesvelte-5

$effect “forgets” state change, test crashes with “Cannot read properties of undefined”


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

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!


Solution

  • 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) }