From 46ff4cc9a3f0934c209bf0ed746eadf59bba8189 Mon Sep 17 00:00:00 2001 From: Just One More Night Date: Thu, 10 Sep 2026 19:56:56 +0700 Subject: [PATCH] fix(lang-core): update pristine declaration defaults during streaming (#767) In `createStore().initialize()`, declaration defaults previously only applied to newly encountered keys (`!state.has(key)`). During streaming, an incomplete `$binding` declaration (e.g. truncated by lexer auto-closing an open quote) would initialize the key with the partial default, and subsequent `initialize` calls with the completed declaration would ignore it because the key already existed. This commit tracks pristine keys (keys initialized from defaults that have not been modified via `set()` or `persisted`). When `initialize` runs again with updated defaults, pristine keys are safely updated to the latest declaration default, while user-modified and persisted values remain untouched. --- .../src/runtime/__tests__/store.test.ts | 114 ++++++++++++++++++ packages/lang-core/src/runtime/store.ts | 40 ++++-- 2 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 packages/lang-core/src/runtime/__tests__/store.test.ts diff --git a/packages/lang-core/src/runtime/__tests__/store.test.ts b/packages/lang-core/src/runtime/__tests__/store.test.ts new file mode 100644 index 000000000..c8dddaad0 --- /dev/null +++ b/packages/lang-core/src/runtime/__tests__/store.test.ts @@ -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"); + }); +}); diff --git a/packages/lang-core/src/runtime/store.ts b/packages/lang-core/src/runtime/store.ts index ac05d5dbc..1470c776a 100644 --- a/packages/lang-core/src/runtime/store.ts +++ b/packages/lang-core/src/runtime/store.ts @@ -13,6 +13,7 @@ export interface Store { export function createStore(): Store { const state = new Map(); + const pristine = new Set(); const listeners = new Set<() => void>(); let snapshot: Record = {}; @@ -57,6 +58,7 @@ export function createStore(): Store { return; } } + pristine.delete(name); state.set(name, value); rebuildSnapshot(); notify(); @@ -74,24 +76,46 @@ export function createStore(): Store { } function initialize(defaults: Record, persisted: Record): 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 = {}; }