From b94bb31c570e330563543702e430039acff122a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:25:31 +0000 Subject: [PATCH] fix: restart the persistence process when usePersistSignals entries change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries changes were piped through concatMap, but the inner persistSignals observable never completes (it keeps persisting signal updates), so a new entries list was queued forever: signals added after the initial hydration were never hydrated nor persisted. Switch to switchMap — mirroring the adapter-change path — so a new list restarts the process as documented. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0152ZzwVACMdMfk6jt7Ezzkn --- .../state/react/usePersistSignals.test.tsx | 79 +++++++++++++++++++ src/lib/state/react/usePersistSignals.tsx | 15 ++-- 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/lib/state/react/usePersistSignals.test.tsx diff --git a/src/lib/state/react/usePersistSignals.test.tsx b/src/lib/state/react/usePersistSignals.test.tsx new file mode 100644 index 0000000..0d986d9 --- /dev/null +++ b/src/lib/state/react/usePersistSignals.test.tsx @@ -0,0 +1,79 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import type { Adapter } from "../persistence/adapters/Adapter" +import { IDENTIFIER_PERSISTENCE_KEY } from "../persistence/constants" +import type { + PersistenceEntry, + SignalPersistenceConfig, +} from "../persistence/types" +import { signal } from "../Signal" +import { usePersistSignals } from "./usePersistSignals" + +const createMemoryAdapter = ( + storage: Record = {}, +): Adapter & { storage: Record } => ({ + storage, + getItem: async (key: string) => storage[key], + setItem: async (key: string, value: unknown) => { + storage[key] = value + }, + removeItem: async (key: string) => { + delete storage[key] + }, + clear: async () => {}, +}) + +describe("Given an entry added after the initial hydration", () => { + it("should hydrate and persist the new entry", { + timeout: 3000, + }, async () => { + const signalA = signal({ default: 0, key: "a" }) + const signalB = signal({ default: 0, key: "b" }) + + const adapter = createMemoryAdapter({ + b: { + [IDENTIFIER_PERSISTENCE_KEY]: IDENTIFIER_PERSISTENCE_KEY, + value: 7, + migrationVersion: 0, + } satisfies PersistenceEntry, + }) + + // biome-ignore lint/suspicious/noExplicitAny: test + const initialEntries: Array> = [ + { signal: signalA, version: 0 }, + ] + // biome-ignore lint/suspicious/noExplicitAny: test + const updatedEntries: Array> = [ + { signal: signalA, version: 0 }, + { signal: signalB, version: 0 }, + ] + + const { result, rerender } = renderHook( + ({ entries }) => usePersistSignals({ entries, adapter }), + { initialProps: { entries: initialEntries } }, + ) + + await waitFor(() => { + expect(result.current.isHydrated).toBe(true) + }) + + expect(signalB.getValue()).toBe(0) + + rerender({ entries: updatedEntries }) + + await waitFor(() => { + expect(signalB.getValue()).toBe(7) + }) + + signalB.update(9) + + await waitFor( + () => { + expect((adapter.storage.b as PersistenceEntry | undefined)?.value).toBe( + 9, + ) + }, + { timeout: 2000 }, + ) + }) +}) diff --git a/src/lib/state/react/usePersistSignals.tsx b/src/lib/state/react/usePersistSignals.tsx index 109a950..69e1771 100644 --- a/src/lib/state/react/usePersistSignals.tsx +++ b/src/lib/state/react/usePersistSignals.tsx @@ -1,4 +1,4 @@ -import { concatMap, merge, of, scan, switchMap } from "rxjs" +import { merge, of, scan, switchMap } from "rxjs" import { useLiveBehaviorSubject } from "../../binding/useLiveBehaviorSubject" import { useObserve } from "../../binding/useObserve/useObserve" import { useLiveRef } from "../../utils/react/useLiveRef" @@ -20,9 +20,8 @@ export function usePersistSignals({ adapter, }: { /** - * Passing a new list of entries will start over the process - * once the current one is finished. Use a stable reference to avoid - * infinite loop. + * Passing a new list of entries will start over the process. + * Use a stable reference to avoid infinite loop. */ // biome-ignore lint/suspicious/noExplicitAny: TODO @@ -51,7 +50,13 @@ export function usePersistSignals({ return merge( of({ type: "reset" }), entriesSubject.pipe( - concatMap((entries) => + /** + * `persistSignals` never completes on its own (it keeps + * persisting signal updates), so a sequential operator would + * queue new entries forever. Restart the process instead + * whenever a new list is emitted. + */ + switchMap((entries) => persistSignals({ adapter, entries,