Files
testdrive-jsui/tutorials/Tutorial 7 Persistence Patterns.md

5.9 KiB
Raw Blame History

Tutorial 7: Persistence Patterns

This tutorial teaches both localStorage and IndexedDB persistence patterns for your reactive store.

“Saving Greetings” — Making the store survive reloads


🎯 Goal

Extend the helloStore so that all greetings persist between browser sessions. When the page reloads, previously added greeters are restored automatically.

Well implement this in two steps:

  1. Start with simple localStorage persistence (fast, synchronous).
  2. Upgrade to IndexedDB for larger data or structured offline storage.

Both variants will keep your TestDrive-UI environment self-contained and testable.


🧪 Step 1 — Write the failing tests

Create a new file: src/store/hello-store.persistence.test.js

import { helloStore } from "./hello-store.js";

describe("helloStore persistence", () => {
  beforeEach(() => {
    // Clean simulated storage
    global.localStorage = {
      store: {},
      getItem(k) { return this.store[k] || null; },
      setItem(k, v) { this.store[k] = v; },
      clear() { this.store = {}; }
    };
  });

  it("saves state to localStorage after modification", () => {
    helloStore.addGreeter("Persisty");
    const saved = JSON.parse(localStorage.getItem("helloStore"));
    expect(saved.greeters).to.include("Persisty");
  });

  it("restores saved state when re-initialized", () => {
    localStorage.setItem(
      "helloStore",
      JSON.stringify({ greeters: ["Restored"] })
    );
    const { HelloStore } = await import("./hello-store.js");
    const freshStore = new HelloStore();
    const state = freshStore.getState();
    expect(state.greeters).to.deep.equal(["Restored"]);
  });
});

Run:

npm test

All tests will fail initially — perfect.


⚙️ Step 2 — Implement localStorage persistence

Open src/store/hello-store.js and extend it like this:

class HelloStore {
  constructor() {
    const saved = localStorage.getItem("helloStore");
    this.state = saved ? JSON.parse(saved) : { greeters: ["World"] };
    this.listeners = new Set();
  }

  getState() {
    return this.state;
  }

  _notify() {
    // Save current state before notifying
    localStorage.setItem("helloStore", JSON.stringify(this.state));
    for (const cb of this.listeners) cb(this.state);
  }

  subscribe(callback) {
    this.listeners.add(callback);
    callback(this.state);
    return () => this.listeners.delete(callback);
  }

  addGreeter(name) {
    this.state = { ...this.state, greeters: [...this.state.greeters, name] };
    this._notify();
  }

  renameGreeter(oldName, newName) {
    const updated = this.state.greeters.map((n) =>
      n === oldName ? newName : n
    );
    this.state = { ...this.state, greeters: updated };
    this._notify();
  }
}

export const helloStore = new HelloStore();
export { HelloStore }; // exported for testing

Run npm test again — tests should now pass.


🌐 Step 3 — Try it live

Start your dev server:

npm run dev
  1. Add new greeters in the dashboard.
  2. Reload the browser. → The same greeters reappear immediately!

🧱 Step 4 — Optional: Switch to IndexedDB for larger data

localStorage is fine for small JSON objects. To persist more complex data or avoid blocking the main thread, you can use IndexedDB (via the tiny idb-keyval helper or native API).

Create src/store/storage.js:

// Minimal IndexedDB wrapper using idb-keyval style API
const DB_NAME = "testdrive-ui";
const STORE_NAME = "hello";

export async function saveState(state) {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
    req.onsuccess = () => {
      const tx = req.result.transaction(STORE_NAME, "readwrite");
      tx.objectStore(STORE_NAME).put(state, "store");
      tx.oncomplete = resolve;
      tx.onerror = reject;
    };
  });
}

export async function loadState() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
    req.onsuccess = () => {
      const tx = req.result.transaction(STORE_NAME, "readonly");
      const get = tx.objectStore(STORE_NAME).get("store");
      get.onsuccess = () => resolve(get.result);
      get.onerror = reject;
    };
  });
}

Now update the stores _notify() and constructor to use these async calls:

import { saveState, loadState } from "./storage.js";

class HelloStore {
  constructor() {
    this.listeners = new Set();
    loadState().then(
      (saved) =>
        (this.state = saved || { greeters: ["World"] }) &&
        this._notify()
    );
  }

  async _notify() {
    await saveState(this.state);
    for (const cb of this.listeners) cb(this.state);
  }
}

💡 Tip: because IndexedDB operations are async, wrap state changes in async methods and adjust your tests with await.


🧠 Step 5 — Testing IndexedDB logic

In Node environments, you can simulate IndexedDB with fake-indexeddb:

npm install --save-dev fake-indexeddb

Then in your test setup:

import "fake-indexeddb/auto";

Your tests now work without a browser.


Outcome

Your TestDrive-UI environment now supports:

Persistence Layer Use Case Pros
localStorage Small, synchronous, quick persistence Simple & immediate
IndexedDB Large, structured, async persistence Scalable & robust

🔍 Reflection

Persistence transforms your store into a long-term memory layer. With this, TestDrive-UI crosses into progressive-web-app territory — offline-ready, recoverable, and reliable.


Continue to Tutorial 8 on Agent Automation.

xxx