Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions packages/lang-core/src/runtime/__tests__/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { createStore } from "../store";

describe("createStore", () => {
it("initializes with defaults and persisted values", () => {
const store = createStore();
store.initialize({ $title: "Hello", $count: 0 }, { $theme: "dark" });

expect(store.get("$title")).toBe("Hello");
expect(store.get("$count")).toBe(0);
expect(store.get("$theme")).toBe("dark");
});

it("prioritizes persisted values over defaults", () => {
const store = createStore();
store.initialize({ $title: "Default" }, { $title: "Persisted" });

expect(store.get("$title")).toBe("Persisted");
});

it("updates pristine declaration defaults as streaming input completes", () => {
const store = createStore();

// Stream step 1: truncated default arrives
store.initialize({ $title: "issue" }, {});
expect(store.get("$title")).toBe("issue");

// Stream step 2: full default arrives, key is pristine (user hasn't touched it)
store.initialize({ $title: "issue with OpenUI, missing Vue 3 headless package" }, {});
expect(store.get("$title")).toBe("issue with OpenUI, missing Vue 3 headless package");
});

it("does not overwrite user-modified values when defaults update", () => {
const store = createStore();

// Initial default arrives
store.initialize({ $title: "initial" }, {});
expect(store.get("$title")).toBe("initial");

// User edits the field
store.set("$title", "user edited title");
expect(store.get("$title")).toBe("user edited title");

// Stream re-initializes or updates declaration
store.initialize({ $title: "stream updated title" }, {});
expect(store.get("$title")).toBe("user edited title");
});

it("does not overwrite persisted values when defaults update", () => {
const store = createStore();

// Initialized with persisted value
store.initialize({ $title: "default 1" }, { $title: "persisted title" });
expect(store.get("$title")).toBe("persisted title");

// Stream updates declaration default
store.initialize({ $title: "default 2" }, {});
expect(store.get("$title")).toBe("persisted title");
});

it("preserves pristine status if set is called with the identical value", () => {
const store = createStore();

store.initialize({ $title: "foo" }, {});
// Setting to identical value is a no-op
store.set("$title", "foo");

// Default updates
store.initialize({ $title: "foobar" }, {});
expect(store.get("$title")).toBe("foobar");
});

it("notifies subscribers when pristine default updates", () => {
const store = createStore();
const listener = vi.fn();
store.subscribe(listener);

store.initialize({ $title: "partial" }, {});
expect(listener).toHaveBeenCalledTimes(1);

store.initialize({ $title: "full" }, {});
expect(listener).toHaveBeenCalledTimes(2);
expect(store.getSnapshot()).toEqual({ $title: "full" });
});

it("does not notify subscribers if initialize makes no changes", () => {
const store = createStore();
store.initialize({ $title: "same" }, {});

const listener = vi.fn();
store.subscribe(listener);

// Call initialize again with same values
store.initialize({ $title: "same" }, {});
expect(listener).not.toHaveBeenCalled();
});

it("clears state and pristine tracking on dispose", () => {
const store = createStore();
store.initialize({ $title: "test" }, {});
store.set("$title", "modified");

store.dispose();
expect(store.getSnapshot()).toEqual({});
expect(store.get("$title")).toBeUndefined();

// Re-initialize after dispose
store.initialize({ $title: "new" }, {});
expect(store.get("$title")).toBe("new");
// Should be pristine again
store.initialize({ $title: "new updated" }, {});
expect(store.get("$title")).toBe("new updated");
});
});
40 changes: 32 additions & 8 deletions packages/lang-core/src/runtime/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface Store {

export function createStore(): Store {
const state = new Map<string, unknown>();
const pristine = new Set<string>();
const listeners = new Set<() => void>();
let snapshot: Record<string, unknown> = {};

Expand Down Expand Up @@ -57,6 +58,7 @@ export function createStore(): Store {
return;
}
}
pristine.delete(name);
state.set(name, value);
rebuildSnapshot();
notify();
Expand All @@ -74,24 +76,46 @@ export function createStore(): Store {
}

function initialize(defaults: Record<string, unknown>, persisted: Record<string, unknown>): void {
// Apply persisted values (explicit restore) and defaults for NEW keys only.
// Existing user-modified $binding values are always preserved — never
// overwrite with defaults, never delete. During streaming, declarations
// can temporarily disappear; deleting user state here would cause data loss.
let changed = false;

// Apply persisted values (explicit restore). These take precedence and are not pristine.
for (const key of Object.keys(persisted)) {
state.set(key, persisted[key]);
pristine.delete(key);
const val = persisted[key];
if (!state.has(key) || !Object.is(state.get(key), val)) {
state.set(key, val);
changed = true;
}
}

// Apply defaults:
// 1. For newly encountered keys, seed default and mark as pristine.
// 2. For existing keys that are STILL pristine (never modified by user or persisted),
// allow updated declaration defaults to take effect (e.g. streaming string recovery).
// Existing user-modified values (not in pristine) are always preserved.
for (const key of Object.keys(defaults)) {
const val = defaults[key];
if (!state.has(key)) {
state.set(key, defaults[key]);
state.set(key, val);
pristine.add(key);
changed = true;
} else if (pristine.has(key)) {
if (!Object.is(state.get(key), val)) {
state.set(key, val);
changed = true;
}
}
}
rebuildSnapshot();
notify();

if (changed) {
rebuildSnapshot();
notify();
}
}

function dispose(): void {
state.clear();
pristine.clear();
listeners.clear();
snapshot = {};
}
Expand Down