diff --git a/packages/atom/src/Atom.ts b/packages/atom/src/Atom.ts index 55f10ce..08726e2 100644 --- a/packages/atom/src/Atom.ts +++ b/packages/atom/src/Atom.ts @@ -1802,19 +1802,23 @@ export const kvs = (options: { const resultAtom = options.runtime.atom( Effect.flatMap( KeyValueStore.KeyValueStore, - (store) => Effect.flatten(store.forSchema(options.schema).get(options.key)) + (store) => store.forSchema(options.schema).get(options.key) ) ) return writable( (get) => { get.mount(setAtom) - const value = Result.value(get(resultAtom)) - if (Option.isSome(value)) { - return value.value - } - const defaultValue = options.defaultValue() - get.set(setAtom, defaultValue) - return defaultValue + get.subscribe(resultAtom, (result) => { + if (!Result.isSuccess(result)) return + if (Option.isSome(result.value)) { + get.setSelf(result.value.value) + } else { + const value = Option.getOrElse(get.self(), options.defaultValue) + get.setSelf(value) + get.set(setAtom, value) + } + }, { immediate: true }) + return Option.getOrElse(get.self(), options.defaultValue) }, (ctx, value: A) => { ctx.set(setAtom, value as any) diff --git a/packages/atom/test/Atom.test.ts b/packages/atom/test/Atom.test.ts index 5d76dc0..68ca9ab 100644 --- a/packages/atom/test/Atom.test.ts +++ b/packages/atom/test/Atom.test.ts @@ -2,6 +2,7 @@ import * as Atom from "@effect-atom/atom/Atom" import * as Hydration from "@effect-atom/atom/Hydration" import * as Registry from "@effect-atom/atom/Registry" import * as Result from "@effect-atom/atom/Result" +import * as KeyValueStore from "@effect/platform/KeyValueStore" import { addEqualityTesters, afterEach, assert, beforeEach, describe, expect, it, test, vitest } from "@effect/vitest" import { Cause, Either, Equal, FiberRef, Schema, Struct, Subscribable, SubscriptionRef } from "effect" import * as Arr from "effect/Array" @@ -1501,6 +1502,109 @@ describe("Atom", () => { assert.strictEqual(result.value.b, 4) assert.strictEqual(runs, 2) }) + + describe("kvs", () => { + it("memoizes defaultValue while loading empty storage", async () => { + let calls = 0 + const storage = new Map() + + const DelayedKVS = Layer.succeed( + KeyValueStore.KeyValueStore, + KeyValueStore.makeStringOnly({ + get: (key) => + Effect.gen(function*() { + yield* Effect.sleep(20) + return Option.fromNullable(storage.get(key)) + }), + set: (key, value) => + Effect.sync(() => { + storage.set(key, value) + }), + remove: (key) => + Effect.sync(() => { + storage.delete(key) + }), + clear: Effect.sync(() => storage.clear()), + size: Effect.sync(() => storage.size) + }) + ) + + const kvsRuntime = Atom.runtime(DelayedKVS) + const atom = Atom.kvs({ + runtime: kvsRuntime, + key: "default-value-key", + schema: Schema.Number, + defaultValue: () => { + calls++ + return 0 + } + }) + + const r = Registry.make() + r.mount(atom) + + expect(r.get(atom)).toEqual(0) + expect(calls).toEqual(1) + + await vitest.advanceTimersByTimeAsync(50) + + expect(r.get(atom)).toEqual(0) + expect(calls).toEqual(1) + }) + + it("preserves existing value after async load completes", async () => { + vitest.useRealTimers() + // Create an in-memory store with a pre-existing value + const storage = new Map() + storage.set("test-key", JSON.stringify(42)) + + // Create a delayed KeyValueStore to simulate async loading + // Use KeyValueStore.make to get proper forSchema support + const DelayedKVS = Layer.succeed( + KeyValueStore.KeyValueStore, + KeyValueStore.makeStringOnly({ + get: (key) => + Effect.gen(function*() { + yield* Effect.sleep(20) // Short delay to create Initial state window + return Option.fromNullable(storage.get(key)) + }), + set: (key, value) => + Effect.sync(() => { + storage.set(key, value) + }), + remove: (key) => + Effect.sync(() => { + storage.delete(key) + }), + clear: Effect.sync(() => storage.clear()), + size: Effect.sync(() => storage.size) + }) + ) + + const kvsRuntime = Atom.runtime(DelayedKVS) + const atom = Atom.kvs({ + runtime: kvsRuntime, + key: "test-key", + schema: Schema.Number, + defaultValue: () => 0 + }) + + const r = Registry.make() + r.mount(atom) + + // First read during Initial state returns default + const value = r.get(atom) + expect(value).toEqual(0) + + // Wait for async load AND any set effects to complete + await new Promise((resolve) => setTimeout(resolve, 50)) + + // THE KEY ASSERTION: After load completes, storage should still have original value. + // The bug was that the default (0) would be written during Initial state, + // corrupting the storage before the async load could read it. + expect(storage.get("test-key")).toEqual(JSON.stringify(42)) + }) + }) }) interface BuildCounter {