diff --git a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts index f4c5cd7..5904a81 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.test.ts @@ -170,6 +170,40 @@ describe("createCachedSelectorStoreAsync", () => { store.destroy(); }); + it("reruns subscribed full-index selectors after inserts", () => { + const spacesTable = defineTable("asyncStoreFullScanSpaces", { + id: v.string(), + name: v.string(), + }).index("byIds", ["id"]); + const db = createTestDB(spacesTable); + const listSpaces = selector({ + name: "asyncStoreFullScanListSpaces", + args: {}, + *handler() { + return yield* selectFrom(spacesTable, "byIds"); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: listSpaces, + args: {}, + defaultValue: [], + }); + + expect(store.getSnapshot().data).toEqual([]); + const subscriber = vi.fn(); + const unsubscribe = store.subscribe(subscriber); + + execSync(db.insert(spacesTable, [{ id: "space-1", name: "First space" }])); + + expect(subscriber).toHaveBeenCalled(); + expect(store.getSnapshot().data).toEqual([ + { id: "space-1", name: "First space" }, + ]); + + unsubscribe(); + store.destroy(); + }); + it("collapses overlapping reruns and publishes the latest result", async () => { const tasksTable = defineTable("asyncStoreOverlapTasks", { id: v.string(), @@ -390,9 +424,8 @@ describe("createCachedSelectorStoreAsync", () => { await flushPromises(); }); - it("restarts after unsubscribe and resubscribe while a selector run is in-flight", async () => { + it("reuses an in-flight subscribed run after unsubscribe and resubscribe", async () => { const first = deferred(); - const second = deferred(); const db = createTestDB(); let runCount = 0; const asyncValue = selector({ @@ -400,12 +433,7 @@ describe("createCachedSelectorStoreAsync", () => { args: {}, *handler() { runCount++; - - if (runCount === 1) { - return yield* unwrap(first.promise); - } - - return yield* unwrap(second.promise); + return yield* unwrap(first.promise); }, }); const store = createCachedSelectorStoreAsync(db, { @@ -426,11 +454,41 @@ describe("createCachedSelectorStoreAsync", () => { const subscriber = vi.fn(); const secondUnsubscribe = store.subscribe(subscriber); - expect(runCount).toBe(2); + expect(runCount).toBe(1); - first.resolve("stale"); + first.resolve("fresh"); await flushPromises(); + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: "fresh", + fetchStatus: "idle", + status: "success", + }), + ); + expect(subscriber).toHaveBeenCalled(); + + secondUnsubscribe(); + store.destroy(); + }); + + it("reuses an in-flight getSnapshot run after subscribe churn", async () => { + const gate = deferred(); + const db = createTestDB(); + let runCount = 0; + const asyncValue = selector({ + name: "asyncStoreSnapshotStartedValue", + args: {}, + *handler() { + runCount++; + return yield* unwrap(gate.promise); + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: asyncValue, + args: {}, + }); + expect(store.getSnapshot()).toEqual( expect.objectContaining({ data: undefined, @@ -438,8 +496,16 @@ describe("createCachedSelectorStoreAsync", () => { status: "pending", }), ); + expect(runCount).toBe(1); + + const firstUnsubscribe = store.subscribe(vi.fn()); + firstUnsubscribe(); + const subscriber = vi.fn(); + const secondUnsubscribe = store.subscribe(subscriber); + + expect(runCount).toBe(1); - second.resolve("fresh"); + gate.resolve("fresh"); await flushPromises(); expect(store.getSnapshot()).toEqual( @@ -455,6 +521,163 @@ describe("createCachedSelectorStoreAsync", () => { store.destroy(); }); + it("reruns a reused in-flight getSnapshot run after missed DB changes", async () => { + const tasksTable = defineTable("asyncStoreSnapshotMissedTasks", { + id: v.string(), + projectId: v.string(), + title: v.string(), + }).index("byProject", ["projectId"]); + const firstGate = deferred(); + const secondGate = deferred(); + const db = createTestDB(tasksTable); + let runCount = 0; + const projectTasks = selector({ + name: "asyncStoreSnapshotMissedProjectTasks", + args: { projectId: v.string() }, + *handler({ projectId }) { + runCount++; + const rows = yield* selectFrom(tasksTable, "byProject").where((q) => + q.eq("projectId", projectId), + ); + + if (runCount === 1) { + yield* unwrap(firstGate.promise); + } else if (runCount === 2) { + yield* unwrap(secondGate.promise); + } + + return rows; + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "project-1" }, + defaultValue: [], + }); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: [], + fetchStatus: "fetching", + status: "pending", + }), + ); + expect(runCount).toBe(1); + + execSync( + db.insert(tasksTable, [ + { id: "task-1", projectId: "project-1", title: "Fresh" }, + ]), + ); + const subscriber = vi.fn(); + const unsubscribe = store.subscribe(subscriber); + + expect(runCount).toBe(1); + + firstGate.resolve(); + await flushPromises(); + + expect(runCount).toBe(2); + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: [], + fetchStatus: "fetching", + status: "pending", + }), + ); + + secondGate.resolve(); + await flushPromises(); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: [{ id: "task-1", projectId: "project-1", title: "Fresh" }], + fetchStatus: "idle", + status: "success", + }), + ); + expect(subscriber).toHaveBeenCalled(); + + unsubscribe(); + store.destroy(); + }); + + it("reruns a reused in-flight subscribed run after missed DB changes", async () => { + const tasksTable = defineTable("asyncStoreResubscribeMissedTasks", { + id: v.string(), + projectId: v.string(), + title: v.string(), + }).index("byProject", ["projectId"]); + const firstGate = deferred(); + const secondGate = deferred(); + const db = createTestDB(tasksTable); + let runCount = 0; + const projectTasks = selector({ + name: "asyncStoreResubscribeMissedProjectTasks", + args: { projectId: v.string() }, + *handler({ projectId }) { + runCount++; + const rows = yield* selectFrom(tasksTable, "byProject").where((q) => + q.eq("projectId", projectId), + ); + + if (runCount === 1) { + yield* unwrap(firstGate.promise); + } else if (runCount === 2) { + yield* unwrap(secondGate.promise); + } + + return rows; + }, + }); + const store = createCachedSelectorStoreAsync(db, { + selector: projectTasks, + args: { projectId: "project-1" }, + defaultValue: [], + }); + const firstUnsubscribe = store.subscribe(vi.fn()); + + expect(runCount).toBe(1); + + firstUnsubscribe(); + execSync( + db.insert(tasksTable, [ + { id: "task-1", projectId: "project-1", title: "Fresh" }, + ]), + ); + const subscriber = vi.fn(); + const secondUnsubscribe = store.subscribe(subscriber); + + expect(runCount).toBe(1); + + firstGate.resolve(); + await flushPromises(); + + expect(runCount).toBe(2); + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: [], + fetchStatus: "fetching", + status: "pending", + }), + ); + + secondGate.resolve(); + await flushPromises(); + + expect(store.getSnapshot()).toEqual( + expect.objectContaining({ + data: [{ id: "task-1", projectId: "project-1", title: "Fresh" }], + fetchStatus: "idle", + status: "success", + }), + ); + expect(subscriber).toHaveBeenCalled(); + + secondUnsubscribe(); + store.destroy(); + }); + it("refetch rejects selector errors when throwOnError is true", async () => { const error = new Error("selector failed"); const db = createTestDB(); diff --git a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts index 67daa90..95c8594 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/async-selector-store.ts @@ -283,6 +283,7 @@ const createAsyncSelectorStoreInternal = < token: number; dataController: PromiseController; resultController: PromiseController>; + cancelOnLastUnsubscribe: boolean; } | null = null; let dbUnsubscribe: (() => void) | undefined; let runToken = 0; @@ -308,11 +309,25 @@ const createAsyncSelectorStoreInternal = < } }; + const refreshStaleSnapshot = () => { + if ( + queryState.status !== "success" || + staleTime <= 0 || + snapshot.isStale || + Date.now() <= queryState.dataUpdatedAt + staleTime + ) { + return; + } + + snapshot = buildSnapshot(queryState, { enabled, staleTime }); + }; + const scheduleStaleTimer = () => { clearStaleTimer(); if ( destroyed || + listeners.size === 0 || queryState.status !== "success" || staleTime <= 0 || snapshot.isStale @@ -399,6 +414,7 @@ const createAsyncSelectorStoreInternal = < const run = ( options?: AsyncSelectorRefetchOptions, + runOptions: { cancelOnLastUnsubscribe?: boolean } = {}, ): Promise> => { const applyRefetchOptions = ( resultPromise: Promise>, @@ -437,6 +453,7 @@ const createAsyncSelectorStoreInternal = < token: currentToken, dataController: promiseController, resultController, + cancelOnLastUnsubscribe: runOptions.cancelOnLastUnsubscribe !== false, }; const resultPromise = resultController.promise; @@ -448,7 +465,10 @@ const createAsyncSelectorStoreInternal = < resultController.resolve(snapshot); }; const finishSuccess = (value: TData, cmds: SelectRangeCmd[]) => { - if (!isCurrentRun()) return; + const currentRun = activeRun; + if (!isCurrentRun() || !currentRun) return; + + const cancelOnLastUnsubscribe = currentRun.cancelOnLastUnsubscribe; selectRangeCmds = cmds; setQueryState((previous) => ({ @@ -471,7 +491,7 @@ const createAsyncSelectorStoreInternal = < resolveCurrentSnapshot(); if (rerunRequested && !destroyed) { - void run(); + void run(undefined, { cancelOnLastUnsubscribe }); } }; const finishError = (error: unknown) => { @@ -564,7 +584,7 @@ const createAsyncSelectorStoreInternal = < const ensureStarted = () => { if (started || !canAutoFetch || destroyed) return; - void run(); + void run(undefined, { cancelOnLastUnsubscribe: false }); }; const ensureDBSubscription = () => { @@ -583,13 +603,15 @@ const createAsyncSelectorStoreInternal = < return; } - void run(); + void run(undefined, { cancelOnLastUnsubscribe: false }); }); }; return { subscribe: (callback) => { listeners.add(callback); + refreshStaleSnapshot(); + scheduleStaleTimer(); ensureDBSubscription(); ensureStarted(); @@ -597,7 +619,7 @@ const createAsyncSelectorStoreInternal = < if (isRunning) { rerunRequested = true; } else { - void run(); + void run(undefined, { cancelOnLastUnsubscribe: false }); } } @@ -607,13 +629,19 @@ const createAsyncSelectorStoreInternal = < if (listeners.size > 0) return; stopDBSubscription(); - cancelInFlightRun(); - started = false; + rerunRequested = false; + if (activeRun?.cancelOnLastUnsubscribe !== false) { + cancelInFlightRun(); + } + if (!activeRun && queryState.status !== "success") { + started = false; + } clearStaleTimer(); }; }, getSnapshot: () => { ensureStarted(); + refreshStaleSnapshot(); return snapshot; }, refetch: (options) => run(options), diff --git a/packages/hyperdb/src/hyperdb/commands/selector/selector.test.ts b/packages/hyperdb/src/hyperdb/commands/selector/selector.test.ts index 31fdd0c..b3800f4 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/selector.test.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/selector.test.ts @@ -953,6 +953,106 @@ describe("selector", () => { expect(runCount).toBe(3); }); + test("async cached selector joins a pending run for the same revision", async () => { + const gate = deferred(); + const testDb = createTestDB(); + let runCount = 0; + const asyncValue = selector({ + name: "asyncPendingSharedValue", + args: {}, + *handler() { + runCount++; + return yield* unwrap(gate.promise); + }, + }); + + const first = runCachedSelectorMaybeAsync(testDb, { + selector: asyncValue, + args: {}, + }); + const second = runCachedSelectorMaybeAsync(testDb, { + selector: asyncValue, + args: {}, + }); + + expect(first).toBeInstanceOf(Promise); + expect(second).toBeInstanceOf(Promise); + expect(runCount).toBe(1); + + gate.resolve("ready"); + + await expect(first).resolves.toBe("ready"); + await expect(second).resolves.toBe("ready"); + expect( + runCachedSelectorMaybeAsync(testDb, { + selector: asyncValue, + args: {}, + }), + ).toBe("ready"); + expect(runCount).toBe(1); + }); + + test("older pending async cached selector does not overwrite newer revision", async () => { + const firstGate = deferred(); + const secondGate = deferred(); + const testDb = createTestDB(tasksTable); + let runCount = 0; + const asyncValue = selector({ + name: "asyncPendingRevisionValue", + args: {}, + *handler() { + runCount++; + + if (runCount === 1) { + return yield* unwrap(firstGate.promise); + } + + return yield* unwrap(secondGate.promise); + }, + }); + + const first = runCachedSelectorMaybeAsync(testDb, { + selector: asyncValue, + args: {}, + }); + + execSync( + testDb.insert(tasksTable, [ + { + type: "task", + id: "task-1", + title: "Fresh", + state: "todo", + projectId: "project-1", + orderToken: "a", + }, + ]), + ); + + const second = runCachedSelectorMaybeAsync(testDb, { + selector: asyncValue, + args: {}, + }); + + expect(first).toBeInstanceOf(Promise); + expect(second).toBeInstanceOf(Promise); + expect(runCount).toBe(2); + + secondGate.resolve("fresh"); + await expect(second).resolves.toBe("fresh"); + + firstGate.resolve("stale"); + await expect(first).resolves.toBe("stale"); + + expect( + runCachedSelectorMaybeAsync(testDb, { + selector: asyncValue, + args: {}, + }), + ).toBe("fresh"); + expect(runCount).toBe(2); + }); + test("cached object-form selector reuse is recorded as cached in the trace", async () => { const deactivateTrace = hyperDBTraceStore.activate(); hyperDBTraceStore.clear(); diff --git a/packages/hyperdb/src/hyperdb/commands/selector/selector.ts b/packages/hyperdb/src/hyperdb/commands/selector/selector.ts index b8475a6..699cc44 100644 --- a/packages/hyperdb/src/hyperdb/commands/selector/selector.ts +++ b/packages/hyperdb/src/hyperdb/commands/selector/selector.ts @@ -515,6 +515,12 @@ type SelectorCacheEntry = { gcExpiresAt?: number; }; +type PendingSelectorCacheEntry = { + revision: number; + promise: Promise; + selectRangeCmds: SelectRangeCmd[]; +}; + type SelectorCacheStore = { subscribe: (callback: () => void) => () => void; getSnapshot: () => TReturn; @@ -526,6 +532,10 @@ const selectorCache = new WeakMap< SubscribableDB, WeakMap>> >(); +const pendingSelectorCache = new WeakMap< + SubscribableDB, + WeakMap>> +>(); const DEFAULT_SELECTOR_CACHE_GC_TIME = 30_000; const SELECTOR_CACHE_GC_TICK_MS = 1_000; const selectorGcEntries = new Set>(); @@ -594,6 +604,25 @@ const getSelectorCacheMap = ( return byArgs; }; +const getPendingSelectorCacheMap = ( + db: SubscribableDB, + selector: object, +): Map> => { + let bySelector = pendingSelectorCache.get(db); + if (!bySelector) { + bySelector = new WeakMap(); + pendingSelectorCache.set(db, bySelector); + } + + let byArgs = bySelector.get(selector); + if (!byArgs) { + byArgs = new Map(); + bySelector.set(selector, byArgs); + } + + return byArgs; +}; + const stopSelectorCacheGcTickerIfIdle = () => { if (selectorGcEntries.size > 0 || !selectorGcTicker) return; @@ -938,6 +967,7 @@ const runCachedSelectorMaybeAsyncInternal = < options.freezeArgs ?? db.getOptions?.().freezeArgs ?? false; const cachedArgs = freezeArgs ? deepFreeze(args) : args; const byArgs = getSelectorCacheMap(db, selector); + const pendingByArgs = getPendingSelectorCacheMap(db, selector); runSelectorCacheGc(); let entry = byArgs.get(argsKey) as | SelectorCacheEntry> @@ -976,6 +1006,20 @@ const runCachedSelectorMaybeAsyncInternal = < return result; } + const pending = pendingByArgs.get(argsKey) as + | PendingSelectorCacheEntry> + | undefined; + if (pending) { + if (pending.revision === db.getRevision()) { + return pending.promise.then((value) => { + copySelectRangeCmds(selectRangeCmds, pending.selectRangeCmds); + return value; + }); + } + + pendingByArgs.delete(argsKey); + } + const entrySelectRangeCmds: SelectRangeCmd[] = []; const childMemo: ChildMemo = new Map(); const gen = () => selector(cachedArgs); @@ -983,7 +1027,19 @@ const runCachedSelectorMaybeAsyncInternal = < const result = runSelectorGeneratorMaybeAsync(db, gen, entrySelectRangeCmds, { childMemo, }); + let pendingEntry: + | PendingSelectorCacheEntry> + | undefined; const storeEntry = (value: SelectorReturn, needsRerun = false) => { + if (pendingEntry && pendingByArgs.get(argsKey) !== pendingEntry) { + copySelectRangeCmds(selectRangeCmds, entrySelectRangeCmds); + return value; + } + + if (pendingEntry) { + pendingByArgs.delete(argsKey); + } + entry = { argsKey, args: cachedArgs, @@ -1005,9 +1061,24 @@ const runCachedSelectorMaybeAsyncInternal = < }; if (result instanceof Promise) { - return result.then((value) => - storeEntry(value, db.getRevision() !== revision), + pendingEntry = { + revision, + promise: undefined as unknown as Promise>, + selectRangeCmds: entrySelectRangeCmds, + }; + const promise = result.then( + (value) => storeEntry(value, db.getRevision() !== revision), + (error: unknown) => { + if (pendingByArgs.get(argsKey) === pendingEntry) { + pendingByArgs.delete(argsKey); + } + + throw error; + }, ); + pendingEntry.promise = promise; + pendingByArgs.set(argsKey, pendingEntry); + return promise; } return storeEntry(result); diff --git a/packages/hyperdb/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 5255d93..398cb5c 100644 --- a/packages/hyperdb/src/react/hooks.test.ts +++ b/packages/hyperdb/src/react/hooks.test.ts @@ -176,7 +176,7 @@ function createStatefulFakeReactHooks() { useSyncExternalStore: vi.fn((subscribe, getSnapshot) => { const slot = index++; const previous = state[slot]; - if (!previous || previous.value !== subscribe) { + if (!previous || previous.value !== subscribe || !previous.cleanup) { previous?.cleanup?.(); state[slot] = { cleanup: subscribe(() => undefined), value: subscribe }; } @@ -562,6 +562,123 @@ describe("useAsyncSelector", () => { reactHooks.cleanup(); }); + it("does not destroy a stable async selector store during strict cleanup", () => { + restoreHookDeps?.(); + const reactHooks = createStatefulFakeReactHooks(); + const selector = vi.fn(function* selector() { + return ["unused"]; + }); + const store = { + subscribe: vi.fn(() => vi.fn()), + getSnapshot: vi.fn(() => ({ + data: [], + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + errorUpdateCount: 0, + failureCount: 0, + failureReason: null, + fetchStatus: "idle", + isEnabled: true, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPaused: false, + isPending: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: false, + isSuccess: true, + promise: Promise.resolve([]), + status: "success", + })), + refetch: vi.fn(), + destroy: vi.fn(), + }; + const createCachedSelectorStoreAsync = vi.fn(() => store); + restoreHookDeps = setHyperDBHookDepsForTest({ + ...reactHooks, + useDB: () => mocks.db, + createCachedSelectorStoreAsync, + stableSerializeSelectorArgs: () => "args-key", + }); + const render = () => { + reactHooks.resetRender(); + useAsyncSelector({ + selector, + args: {}, + defaultValue: [], + }); + }; + + render(); + reactHooks.cleanup(); + render(); + + expect(createCachedSelectorStoreAsync).toHaveBeenCalledTimes(1); + expect(store.subscribe).toHaveBeenCalledTimes(2); + expect(store.destroy).not.toHaveBeenCalled(); + + reactHooks.cleanup(); + }); + + it("does not rerun a getSnapshot-started async selector across strict cleanup", async () => { + restoreHookDeps?.(); + const reactHooks = createStatefulFakeReactHooks(); + const useSyncExternalStore = vi.fn((subscribe, getSnapshot) => { + const snapshot = getSnapshot(); + + reactHooks.useSyncExternalStore(subscribe, () => snapshot); + return snapshot; + }); + const gate = deferred(); + const runCachedSelectorMaybeAsync = vi.fn(() => gate.promise); + const selector = vi.fn(function* selector() { + return "unused"; + }); + restoreHookDeps = setHyperDBHookDepsForTest({ + ...reactHooks, + useSyncExternalStore, + useDB: () => mocks.db, + runCachedSelectorMaybeAsync, + stableSerializeSelectorArgs: () => "args-key", + }); + const render = () => { + reactHooks.resetRender(); + return useAsyncSelector({ + selector, + args: {}, + }); + }; + + expect(render()).toEqual( + expect.objectContaining({ + fetchStatus: "fetching", + status: "pending", + }), + ); + reactHooks.cleanup(); + expect(render()).toEqual( + expect.objectContaining({ + fetchStatus: "fetching", + status: "pending", + }), + ); + + expect(runCachedSelectorMaybeAsync).toHaveBeenCalledTimes(1); + + gate.resolve("fresh"); + await flushPromises(); + + expect(runCachedSelectorMaybeAsync).toHaveBeenCalledTimes(1); + reactHooks.cleanup(); + }); + it("passes the latest async selector option values when the store is recreated", () => { restoreHookDeps?.(); const reactHooks = createStatefulFakeReactHooks(); diff --git a/packages/hyperdb/src/react/hooks.ts b/packages/hyperdb/src/react/hooks.ts index a6032fb..96fad45 100644 --- a/packages/hyperdb/src/react/hooks.ts +++ b/packages/hyperdb/src/react/hooks.ts @@ -1,10 +1,4 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useSyncExternalStore, -} from "react"; +import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { createCachedSelectorStoreSync, runCachedSelectorMaybeAsync, @@ -146,7 +140,6 @@ const createDisabledStore = (getDefaultValue: () => TReturn) => ({ const defaultHookDeps = { useCallback, - useEffect, useMemo, useRef, useSyncExternalStore, @@ -351,12 +344,6 @@ export function useAsyncSelector< [snapshot, refetch], ); - hookDeps.useEffect(() => { - return () => { - store.destroy(); - }; - }, [store]); - if (!result.isPlaceholderData) { previousDataRef.current = result.data; }