From 23c31104ccbd74852887b3467e70665efa739b5b Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 3 Jul 2026 18:53:21 +0300 Subject: [PATCH 1/3] feat: improve react sync hook --- packages/hyperdb/src/react/hooks.test.ts | 90 +++------ packages/hyperdb/src/react/hooks.ts | 244 ++++++++++++++--------- 2 files changed, 174 insertions(+), 160 deletions(-) diff --git a/packages/hyperdb/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 514f744..9a48444 100644 --- a/packages/hyperdb/src/react/hooks.test.ts +++ b/packages/hyperdb/src/react/hooks.test.ts @@ -84,24 +84,6 @@ function createMockDB() { }; } -function createMockSelectorStore(initial: T, refreshed: T[] = []) { - let snapshot = initial; - - return { - getSnapshot: vi.fn(() => snapshot), - refresh: vi.fn(() => { - const next = refreshed.shift(); - if (next !== undefined) { - snapshot = next; - } - }), - setSnapshot: vi.fn((value: T) => { - snapshot = value; - }), - subscribe: vi.fn(() => vi.fn()), - }; -} - describe("useAsyncSelector", () => { beforeEach(() => { mocks.cleanup = undefined; @@ -294,7 +276,7 @@ describe("useAsyncSelector", () => { expect(mocks.db.subscribe).not.toHaveBeenCalled(); }); - it("reads HybridDB cache snapshots synchronously while preloading through the async db", async () => { + it("runs HybridDB selectors without reading cache snapshots during render", async () => { const cacheDB = { beginTx: vi.fn(), intervalScan: vi.fn(), @@ -307,9 +289,7 @@ describe("useAsyncSelector", () => { return ["unused"]; }); const cmd = { table: "tasks", range: "hybrid" }; - const selectorStore = createMockSelectorStore(["cached"], [["fresh"]]); - mocks.initCachedSelector.mockReturnValue(selectorStore); mocks.runCachedSelectorMaybeAsync.mockImplementation( (_db, _selector, _args, cmds: unknown[]) => { cmds.push(cmd); @@ -324,15 +304,11 @@ describe("useAsyncSelector", () => { defaultValue: [], }); - expect(result.data).toEqual(["cached"]); - expect(result.status).toBe("success"); - expect(result.isLoading).toBe(false); - expect(result.isRefetching).toBe(true); - expect(mocks.initCachedSelector).toHaveBeenCalledWith( - expect.objectContaining({ db: cacheDB }), - selector, - { projectId: "project-1" }, - ); + expect(result.data).toEqual([]); + expect(result.status).toBe("pending"); + expect(result.isLoading).toBe(true); + expect(result.isRefetching).toBe(false); + expect(mocks.initCachedSelector).not.toHaveBeenCalled(); expect(mocks.runCachedSelectorMaybeAsync).toHaveBeenCalledWith( mocks.db, selector, @@ -343,8 +319,7 @@ describe("useAsyncSelector", () => { await flushPromises(); - expect(selectorStore.refresh).not.toHaveBeenCalled(); - expect(selectorStore.setSnapshot).toHaveBeenCalledWith(["fresh"]); + expect(cmd).toEqual({ table: "tasks", range: "hybrid" }); expect(mocks.setState).toHaveBeenLastCalledWith( expect.objectContaining({ data: ["fresh"], @@ -353,7 +328,7 @@ describe("useAsyncSelector", () => { ); }); - it("does not refresh the HybridDB cache snapshot until preload finishes", async () => { + it("waits for a pending HybridDB cache run before applying the result", async () => { const cacheDB = { beginTx: vi.fn(), intervalScan: vi.fn(), @@ -366,9 +341,7 @@ describe("useAsyncSelector", () => { const selector = vi.fn(function* selector(_args: { projectId: string }) { return ["unused"]; }); - const selectorStore = createMockSelectorStore(["cached"], [["fresh"]]); - mocks.initCachedSelector.mockReturnValue(selectorStore); mocks.runCachedSelectorMaybeAsync.mockImplementation( (_db, _selector, _args, _cmds: unknown[]) => { selector({ projectId: "project-1" }); @@ -382,16 +355,21 @@ describe("useAsyncSelector", () => { defaultValue: [], }); - expect(selectorStore.setSnapshot).not.toHaveBeenCalled(); + expect(mocks.initCachedSelector).not.toHaveBeenCalled(); + expect(mocks.runCachedSelectorMaybeAsync).toHaveBeenCalledTimes(1); await flushPromises(); - expect(selectorStore.setSnapshot).not.toHaveBeenCalled(); + expect(mocks.setState).not.toHaveBeenLastCalledWith( + expect.objectContaining({ data: ["fresh"] }), + ); pending.resolve(["fresh"]); await flushPromises(); - expect(selectorStore.refresh).not.toHaveBeenCalled(); - expect(selectorStore.setSnapshot).toHaveBeenCalledWith(["fresh"]); + expect(mocks.runCachedSelectorMaybeAsync).toHaveBeenCalledTimes(1); + expect(mocks.setState).toHaveBeenLastCalledWith( + expect.objectContaining({ data: ["fresh"], status: "success" }), + ); }); it("ignores a late HybridDB preload after args change cleanup", async () => { @@ -404,12 +382,7 @@ describe("useAsyncSelector", () => { }); const first = deferred(); const second = deferred(); - const firstStore = createMockSelectorStore(["cached-1"], [["fresh-1"]]); - const secondStore = createMockSelectorStore(["cached-2"], [["fresh-2"]]); - mocks.initCachedSelector - .mockReturnValueOnce(firstStore) - .mockReturnValueOnce(secondStore); mocks.runCachedSelectorMaybeAsync .mockImplementationOnce((_db, _selector, _args, _cmds: unknown[]) => { return first.promise; @@ -439,13 +412,11 @@ describe("useAsyncSelector", () => { defaultValue: [], }); - expect(mocks.initCachedSelector).toHaveBeenCalledTimes(2); + expect(mocks.initCachedSelector).not.toHaveBeenCalled(); first.resolve(["stale-1"]); await flushPromises(); - expect(firstStore.setSnapshot).not.toHaveBeenCalled(); - expect(secondStore.setSnapshot).not.toHaveBeenCalled(); expect(mocks.setState).not.toHaveBeenLastCalledWith( expect.objectContaining({ data: ["stale-1"], @@ -455,9 +426,6 @@ describe("useAsyncSelector", () => { second.resolve(["fresh-2"]); await flushPromises(); - expect(firstStore.setSnapshot).not.toHaveBeenCalled(); - expect(secondStore.refresh).not.toHaveBeenCalled(); - expect(secondStore.setSnapshot).toHaveBeenCalledWith(["fresh-2"]); expect(mocks.setState).toHaveBeenLastCalledWith( expect.objectContaining({ data: ["fresh-2"], @@ -466,7 +434,7 @@ describe("useAsyncSelector", () => { ); }); - it("reuses the same HybridDB cache wrapper across selector args", () => { + it("does not initialize a sync HybridDB cache snapshot across selector args", () => { const cacheDB = { beginTx: vi.fn(), intervalScan: vi.fn(), @@ -475,10 +443,7 @@ describe("useAsyncSelector", () => { return ["unused"]; }); - mocks.initCachedSelector.mockReturnValue( - createMockSelectorStore(["cached"]), - ); - mocks.runCachedSelectorMaybeAsync.mockReturnValue(new Promise(() => {})); + mocks.runCachedSelectorMaybeAsync.mockReturnValue(["fresh"]); mocks.stableSerializeSelectorArgs .mockReturnValueOnce("project-1") .mockReturnValueOnce("project-2") @@ -506,14 +471,12 @@ describe("useAsyncSelector", () => { defaultValue: [], }); - expect(mocks.initCachedSelector).toHaveBeenCalledTimes(3); - expect(mocks.initCachedSelector.mock.calls[0]?.[0]).toBe( - mocks.initCachedSelector.mock.calls[2]?.[0], - ); - expect(mocks.initCachedSelector.mock.calls[0]?.[2]).toEqual({ + expect(mocks.initCachedSelector).not.toHaveBeenCalled(); + expect(mocks.runCachedSelectorMaybeAsync).toHaveBeenCalledTimes(3); + expect(mocks.runCachedSelectorMaybeAsync.mock.calls[0]?.[2]).toEqual({ projectId: "project-1", }); - expect(mocks.initCachedSelector.mock.calls[2]?.[2]).toEqual({ + expect(mocks.runCachedSelectorMaybeAsync.mock.calls[2]?.[2]).toEqual({ projectId: "project-1", }); }); @@ -581,12 +544,15 @@ describe("useAsyncSelector", () => { }, ); - useAsyncSelector({ + const result = useAsyncSelector({ selector, args: { projectId: "project-1" }, defaultValue: [], }); + expect(result.data).toEqual(["task-1"]); + expect(result.status).toBe("success"); + expect(result.isLoading).toBe(false); expect(selector).toHaveBeenCalledWith({ projectId: "project-1" }); expect(mocks.runSelectorMaybeAsync).toHaveBeenCalledTimes(1); expect(mocks.runSelectorAsync).not.toHaveBeenCalled(); diff --git a/packages/hyperdb/src/react/hooks.ts b/packages/hyperdb/src/react/hooks.ts index 828a256..f23f165 100644 --- a/packages/hyperdb/src/react/hooks.ts +++ b/packages/hyperdb/src/react/hooks.ts @@ -27,7 +27,6 @@ import { stableSerializeSelectorArgs, } from "../hyperdb/commands/selector/selector-memo"; import type { SelectRangeCmd } from "../hyperdb/commands/selector/commands"; -import { SubscribableDB } from "../hyperdb/runtime/subscribable-db"; type SyncSelectorEnabledOptions = { selector: TSelector; @@ -151,18 +150,20 @@ type AsyncSelectorState = { status: AsyncSelectorStatus; }; -type SyncSelectorSnapshotStore = { - enabled: boolean; - getSnapshot: () => TData | undefined; - publish: (value: TData) => void; - subscribe: (callback: () => void) => () => void; -}; +type AsyncSelectorValueSnapshot = + | { hasValue: false } + | { hasValue: true; value: TData }; -type CachedSelectorStore = { - getSnapshot: () => TData; - refresh: () => void; - setSnapshot: (value: TData) => void; +type AsyncSelectorInitialRun = + | { status: "sync"; value: TData; cmds: SelectRangeCmd[] } + | { status: "async"; promise: PromiseLike; cmds: SelectRangeCmd[] } + | { status: "error"; error: unknown }; + +type AsyncSelectorValueSnapshotStore = { + getSnapshot: () => AsyncSelectorValueSnapshot; + publish: (value: TData) => void; subscribe: (callback: () => void) => () => void; + takeInitialRun: () => AsyncSelectorInitialRun | undefined; }; const createDisabledStore = (defaultValue: TReturn) => ({ @@ -170,14 +171,54 @@ const createDisabledStore = (defaultValue: TReturn) => ({ getSnapshot: () => defaultValue, }); -const createInactiveSyncSelectorSnapshotStore = < - TData, ->(): SyncSelectorSnapshotStore => ({ - enabled: false, - getSnapshot: () => undefined, - publish: () => undefined, - subscribe: () => () => undefined, -}); +const createAsyncSelectorValueSnapshotStore = (options: { + runInitial?: () => AsyncSelectorInitialRun | undefined; +}): AsyncSelectorValueSnapshotStore => { + const subscribers = new Set<() => void>(); + let initialRun: AsyncSelectorInitialRun | undefined; + let initialRunComputed = false; + let snapshot: AsyncSelectorValueSnapshot = { hasValue: false }; + const notify = () => { + for (const subscriber of subscribers) { + subscriber(); + } + }; + const ensureInitialRun = () => { + if (initialRunComputed) { + return; + } + + initialRunComputed = true; + initialRun = options.runInitial?.(); + if (initialRun?.status === "sync") { + snapshot = { hasValue: true, value: initialRun.value }; + } + }; + + return { + getSnapshot: () => { + ensureInitialRun(); + return snapshot; + }, + publish: (value) => { + snapshot = { hasValue: true, value }; + notify(); + }, + subscribe: (callback) => { + subscribers.add(callback); + + return () => { + subscribers.delete(callback); + }; + }, + takeInitialRun: () => { + ensureInitialRun(); + const run = initialRun; + initialRun = undefined; + return run; + }, + }; +}; const isPromiseLike = (value: T | PromiseLike): value is PromiseLike => value !== null && @@ -323,53 +364,17 @@ const createUseAsyncSelectorResult = ( }; }; -const createSyncCacheSelectorSnapshotStore = < - TSelector extends AnyObjectSelector, ->(options: { - cacheDB: SubscribableDB; - initCachedSelector: typeof initCachedSelector; - selector: TSelector; - args: SelectorArgs; -}): SyncSelectorSnapshotStore> => { - const subscribers = new Set<() => void>(); - const cachedSelector = options.initCachedSelector( - options.cacheDB, - options.selector, - options.args, - ) as CachedSelectorStore>; - - return { - enabled: true, - getSnapshot: cachedSelector.getSnapshot, - publish: (value) => { - cachedSelector.setSnapshot(value); - for (const subscriber of subscribers) { - subscriber(); - } - }, - subscribe: (callback) => { - subscribers.add(callback); - const unsubscribe = cachedSelector.subscribe(callback); - - return () => { - subscribers.delete(callback); - unsubscribe(); - }; - }, - }; -}; - const createVisibleAsyncSelectorState = ( queryState: AsyncSelectorState, - syncStore: SyncSelectorSnapshotStore, + snapshot: AsyncSelectorValueSnapshot, ): AsyncSelectorState => { - if (!syncStore.enabled) { + if (!snapshot.hasValue) { return queryState; } return { ...queryState, - data: syncStore.getSnapshot(), + data: snapshot.value, isPlaceholderData: false, status: queryState.status === "pending" ? "success" : queryState.status, }; @@ -459,26 +464,47 @@ export function useAsyncSelector< const argsKey = enabled ? hookDeps.stableSerializeSelectorArgs(input.args) : undefined; - const syncSnapshotStore = hookDeps.useMemo(() => { - const cacheDB = - enabled && subscribed ? getSubscribableHybridCacheDB(db) : undefined; - if (!cacheDB) { - return createInactiveSyncSelectorSnapshotStore< - SelectorReturn - >(); - } + const hybridCacheDB = hookDeps.useMemo( + () => + enabled && subscribed ? getSubscribableHybridCacheDB(db) : undefined, + [db, enabled, subscribed], + ); + const valueSnapshotStore = hookDeps.useMemo(() => { + return createAsyncSelectorValueSnapshotStore>({ + runInitial: () => { + if (!canFetch) { + return undefined; + } - return createSyncCacheSelectorSnapshotStore({ - args: input.args, - cacheDB, - initCachedSelector: hookDeps.initCachedSelector, - selector: input.selector, + const cmds: SelectRangeCmd[] = []; + + try { + const value = hybridCacheDB + ? hookDeps.runCachedSelectorMaybeAsync( + db, + input.selector, + input.args, + cmds, + ) + : hookDeps.runSelectorMaybeAsync( + db, + () => input.selector(input.args), + cmds, + ); + + return isPromiseLike(value) + ? { status: "async", promise: value, cmds } + : { status: "sync", value, cmds }; + } catch (error) { + return { status: "error", error }; + } + }, }); - }, [db, input.selector, argsKey, enabled, subscribed]); - const syncSnapshot = hookDeps.useSyncExternalStore( - syncSnapshotStore.subscribe, - syncSnapshotStore.getSnapshot, - syncSnapshotStore.getSnapshot, + }, [db, input.selector, argsKey, canFetch, hybridCacheDB]); + const valueSnapshot = hookDeps.useSyncExternalStore( + valueSnapshotStore.subscribe, + valueSnapshotStore.getSnapshot, + valueSnapshotStore.getSnapshot, ); const promiseControllerRef = hookDeps.useRef( createPromiseController>(), @@ -493,7 +519,7 @@ export function useAsyncSelector< ); const queryStateRef = hookDeps.useRef(queryState); const selectRangeCmdsRef = hookDeps.useRef([]); - const syncSnapshotStoreRef = hookDeps.useRef(syncSnapshotStore); + const valueSnapshotStoreRef = hookDeps.useRef(valueSnapshotStore); const isRunningRef = hookDeps.useRef(false); const rerunRequestedRef = hookDeps.useRef(false); const cancelledRef = hookDeps.useRef(false); @@ -517,7 +543,7 @@ export function useAsyncSelector< ) => Promise, TError>> >(() => Promise.resolve(resultRef.current)); genRef.current = () => input.selector(input.args); - syncSnapshotStoreRef.current = syncSnapshotStore; + valueSnapshotStoreRef.current = valueSnapshotStore; const setQueryState = hookDeps.useCallback( ( @@ -538,10 +564,10 @@ export function useAsyncSelector< [], ); - const visibleQueryState = createVisibleAsyncSelectorState(queryState, { - ...syncSnapshotStore, - getSnapshot: () => syncSnapshot, - }); + const visibleQueryState = createVisibleAsyncSelectorState( + queryState, + valueSnapshot, + ); const result = createUseAsyncSelectorResult(visibleQueryState, { enabled, refetch, @@ -560,7 +586,7 @@ export function useAsyncSelector< promise: promiseController.promise, }), ); - }, [argsKey]); + }, [input.selector, argsKey]); hookDeps.useEffect(() => { cancelledRef.current = false; @@ -583,6 +609,7 @@ export function useAsyncSelector< const promiseController = createPromiseController>(); promiseControllerRef.current = promiseController; + let initialRun = valueSnapshotStoreRef.current.takeInitialRun(); const resultPromise = new Promise< UseAsyncSelectorResult, TError> >((resolve, reject) => { @@ -596,7 +623,7 @@ export function useAsyncSelector< if (cancelledRef.current) return; selectRangeCmdsRef.current = cmds; - syncSnapshotStoreRef.current.publish(value); + valueSnapshotStoreRef.current.publish(value); const nextState = setQueryState((previous) => ({ ...previous, data: value, @@ -611,10 +638,10 @@ export function useAsyncSelector< status: "success", })); resultRef.current = createUseAsyncSelectorResult( - createVisibleAsyncSelectorState( - nextState, - syncSnapshotStoreRef.current, - ), + createVisibleAsyncSelectorState(nextState, { + hasValue: true, + value, + }), { enabled, refetch, @@ -649,7 +676,7 @@ export function useAsyncSelector< resultRef.current = createUseAsyncSelectorResult( createVisibleAsyncSelectorState( nextState, - syncSnapshotStoreRef.current, + valueSnapshotStoreRef.current.getSnapshot(), ), { enabled, @@ -672,14 +699,35 @@ export function useAsyncSelector< do { rerunRequestedRef.current = false; const cmds: SelectRangeCmd[] = []; - const value = syncSnapshotStoreRef.current.enabled - ? hookDeps.runCachedSelectorMaybeAsync( - db, - input.selector, - input.args, - cmds, - ) - : hookDeps.runSelectorMaybeAsync(db, genRef.current, cmds); + const currentInitialRun = initialRun; + initialRun = undefined; + let value: + | SelectorReturn + | PromiseLike>; + + if (currentInitialRun) { + if (currentInitialRun.status === "error") { + finishError(currentInitialRun.error); + return; + } + + if (currentInitialRun.status === "sync") { + value = currentInitialRun.value; + cmds.push(...currentInitialRun.cmds); + } else { + value = currentInitialRun.promise; + cmds.push(...currentInitialRun.cmds); + } + } else { + value = hybridCacheDB + ? hookDeps.runCachedSelectorMaybeAsync( + db, + input.selector, + input.args, + cmds, + ) + : hookDeps.runSelectorMaybeAsync(db, genRef.current, cmds); + } if (isPromiseLike(value)) { void Promise.resolve(value).then( @@ -766,7 +814,7 @@ export function useAsyncSelector< isRunningRef.current = false; unsubscribe(); }; - }, [db, input.selector, argsKey, canFetch, enabled, refetch]); + }, [db, input.selector, argsKey, canFetch, enabled, hybridCacheDB, refetch]); if (result.isError && input.throwOnError) { const shouldThrow = From f99667d3fc33c7fb7f1e6afb4afd731f4f29d454 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 3 Jul 2026 19:17:45 +0300 Subject: [PATCH 2/3] fix: fix hooks race condition --- packages/hyperdb/src/react/hooks.test.ts | 63 ++++++++++++++++++++++-- packages/hyperdb/src/react/hooks.ts | 38 ++++++++++---- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/hyperdb/src/react/hooks.test.ts b/packages/hyperdb/src/react/hooks.test.ts index 9a48444..1b2fcaa 100644 --- a/packages/hyperdb/src/react/hooks.test.ts +++ b/packages/hyperdb/src/react/hooks.test.ts @@ -5,9 +5,10 @@ import { useSyncSelector, } from "./hooks"; -type Subscriber = (ops: unknown[]) => void; +type Subscriber = (ops: unknown[], traits: unknown[], revision: number) => void; type MockDB = { subscribe: (cb: Subscriber) => () => void; + getRevision: () => number; emit(ops: unknown[]): void; subscriberCount(): number; }; @@ -61,6 +62,7 @@ async function flushPromises() { function createMockDB() { const subscribers: Subscriber[] = []; + let revision = 0; return { subscribe: vi.fn((cb: Subscriber) => { @@ -73,9 +75,11 @@ function createMockDB() { } }; }), + getRevision: vi.fn(() => revision), emit(ops: unknown[]) { + revision++; for (const subscriber of [...subscribers]) { - subscriber(ops); + subscriber(ops, [], revision); } }, subscriberCount() { @@ -319,7 +323,7 @@ describe("useAsyncSelector", () => { await flushPromises(); - expect(cmd).toEqual({ table: "tasks", range: "hybrid" }); + expect(mocks.refs[2].current).toEqual([cmd]); expect(mocks.setState).toHaveBeenLastCalledWith( expect.objectContaining({ data: ["fresh"], @@ -372,6 +376,59 @@ describe("useAsyncSelector", () => { ); }); + it("reruns when the DB revision changes before the initial async run is consumed", async () => { + const stale = deferred(); + const fresh = deferred(); + const staleCmd = { table: "tasks", range: "stale" }; + const freshCmd = { table: "tasks", range: "fresh" }; + let runCount = 0; + + mocks.runSelectorMaybeAsync.mockImplementation( + (_db, _gen, cmds: unknown[]) => { + runCount++; + + if (runCount === 1) { + cmds.push(staleCmd); + mocks.db.emit([{ id: "between-render-and-effect" }]); + return stale.promise; + } + + cmds.push(freshCmd); + return fresh.promise; + }, + ); + + useAsyncSelector({ + selector: function* selector() { + return ["unused"]; + }, + args: {}, + defaultValue: [], + }); + + expect(mocks.runSelectorMaybeAsync).toHaveBeenCalledTimes(2); + + stale.resolve(["stale"]); + await flushPromises(); + + expect(mocks.setState).not.toHaveBeenLastCalledWith( + expect.objectContaining({ + data: ["stale"], + }), + ); + + fresh.resolve(["fresh"]); + await flushPromises(); + + expect(mocks.setState).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: ["fresh"], + status: "success", + }), + ); + expect(mocks.refs[2].current).toEqual([freshCmd]); + }); + it("ignores a late HybridDB preload after args change cleanup", async () => { const cacheDB = { beginTx: vi.fn(), diff --git a/packages/hyperdb/src/react/hooks.ts b/packages/hyperdb/src/react/hooks.ts index f23f165..5c19df0 100644 --- a/packages/hyperdb/src/react/hooks.ts +++ b/packages/hyperdb/src/react/hooks.ts @@ -155,9 +155,14 @@ type AsyncSelectorValueSnapshot = | { hasValue: true; value: TData }; type AsyncSelectorInitialRun = - | { status: "sync"; value: TData; cmds: SelectRangeCmd[] } - | { status: "async"; promise: PromiseLike; cmds: SelectRangeCmd[] } - | { status: "error"; error: unknown }; + | { status: "sync"; value: TData; cmds: SelectRangeCmd[]; revision: number } + | { + status: "async"; + promise: Promise; + cmds: SelectRangeCmd[]; + revision: number; + } + | { status: "error"; error: unknown; revision: number }; type AsyncSelectorValueSnapshotStore = { getSnapshot: () => AsyncSelectorValueSnapshot; @@ -477,6 +482,7 @@ export function useAsyncSelector< } const cmds: SelectRangeCmd[] = []; + const revision = db.getRevision(); try { const value = hybridCacheDB @@ -492,11 +498,16 @@ export function useAsyncSelector< cmds, ); - return isPromiseLike(value) - ? { status: "async", promise: value, cmds } - : { status: "sync", value, cmds }; + if (isPromiseLike(value)) { + const promise = Promise.resolve(value); + promise.catch(() => undefined); + + return { status: "async", promise, cmds, revision }; + } + + return { status: "sync", value, cmds, revision }; } catch (error) { - return { status: "error", error }; + return { status: "error", error, revision }; } }, }); @@ -699,12 +710,19 @@ export function useAsyncSelector< do { rerunRequestedRef.current = false; const cmds: SelectRangeCmd[] = []; - const currentInitialRun = initialRun; + let currentInitialRun = initialRun; initialRun = undefined; let value: | SelectorReturn | PromiseLike>; + if ( + currentInitialRun && + currentInitialRun.revision !== db.getRevision() + ) { + currentInitialRun = undefined; + } + if (currentInitialRun) { if (currentInitialRun.status === "error") { finishError(currentInitialRun.error); @@ -790,8 +808,6 @@ export function useAsyncSelector< return; } - void run(); - const unsubscribe = db.subscribe((ops) => { if (isRunningRef.current) { rerunRequestedRef.current = true; @@ -809,6 +825,8 @@ export function useAsyncSelector< void run(); }); + void run(); + return () => { cancelledRef.current = true; isRunningRef.current = false; From db2bac2919ad6d919437b315e20c4d1bf3497f71 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 3 Jul 2026 19:20:15 +0300 Subject: [PATCH 3/3] fix: fix agents.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d82d008..9e61939 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,4 +4,4 @@ Use packages/hyperdb-doc/summary.md to find which docs pages likely need to be updated for the change. Also update packages/hyperdb-doc/src/content/docs/start/llm-cheat-sheet.md whenever the public API, package entry points, common usage patterns, or -capabilities change. +capabilities change. But doc is public, so it doesn't need implementation details.