-
-
Notifications
You must be signed in to change notification settings - Fork 685
feat: useSuspendingLiveQuery #2205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3a5d310
b468aac
5eb9764
3791fd0
f3e1494
01e9b2e
c3f501f
06b78aa
b8ae8da
dcaa3fb
aa44260
84245c2
f59f948
726977a
05faa4a
add660d
d9faa36
e9362f0
48ce3f3
8cb7556
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| export * from './useLiveQuery'; | ||
| export * from './useObservable'; | ||
| export * from './usePermissions'; | ||
| export * from './useDocument'; | ||
| export * from './useDocument'; | ||
| export * from './useSuspendingLiveQuery'; | ||
| export * from './useSuspendingObservable'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| // Minimal ambient declarations for 'y-dexie' to satisfy type imports | ||
| // This is a local quickfix for TypeScript ESM/CJS resolution issues. | ||
| declare module 'y-dexie' { | ||
| // Inline minimal Y.Doc type to avoid depending on 'yjs' ambient declaration | ||
| export type YDoc = { | ||
| // minimal shape used by dexie-react-hooks | ||
| toJSON?: () => any; | ||
| }; | ||
|
|
||
| export type DexieYProvider = { | ||
| doc?: YDoc | null; | ||
| // Partial API surface used by dexie-react-hooks | ||
| release?: (doc?: YDoc) => void; | ||
| }; | ||
|
|
||
| export const DexieYProvider: { | ||
| load(doc: YDoc, options?: any): DexieYProvider; | ||
| for(doc: YDoc): DexieYProvider | undefined; | ||
| release(doc: YDoc): void; | ||
| }; | ||
|
|
||
| export type YUpdateRow = any; | ||
| export type YSyncState = any; | ||
|
|
||
| export function compressYDocs(docs: unknown): unknown; | ||
|
|
||
| export default function yDexie(dbOrOptions?: any): any; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| // Minimal ambient declaration for 'yjs' used for type Doc | ||
| declare module 'yjs' { | ||
| export type Doc = { | ||
| // Minimal Doc shape used by dexie-react-hooks; real API is larger | ||
| toJSON?: () => any; | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import * as React from 'react'; | ||
|
|
||
| /** {@link React.use} if supported, else fallback */ | ||
| export const usePromise: <T>(promise: PromiseLike<T>) => T = | ||
| React.use ?? fallbackUsePromise; | ||
|
|
||
| /** Fallback for `React.use` with promise */ | ||
| function fallbackUsePromise<T>(promise: PromiseLike<T>): T { | ||
| const state = PROMISE_STATE_MAP.get(promise); | ||
|
|
||
| if (!state) { | ||
| PROMISE_STATE_MAP.set(promise, { status: 'pending' }); | ||
| promise.then( | ||
| (value) => { | ||
| PROMISE_STATE_MAP.set(promise, { status: 'fulfilled', value }); | ||
| }, | ||
| (reason) => { | ||
| PROMISE_STATE_MAP.set(promise, { status: 'rejected', reason }); | ||
| } | ||
| ); | ||
| throw promise; | ||
| } | ||
|
|
||
| switch (state.status) { | ||
| case 'pending': | ||
| throw promise; | ||
| case 'rejected': | ||
| throw state.reason; | ||
| case 'fulfilled': | ||
| return state.value; | ||
| } | ||
| } | ||
|
|
||
| const PROMISE_STATE_MAP = new WeakMap< | ||
| PromiseLike<any>, | ||
| PromiseSettledResult<any> | { status: 'pending' } | ||
| >(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { Dexie } from 'dexie'; | ||
| import { useSuspendingObservable } from './useSuspendingObservable'; | ||
|
|
||
| /** | ||
| * Observe IndexedDB data in your React component. Make the component re-render when the observed data changes. | ||
| * | ||
| * Suspends until first value is available. | ||
| */ | ||
| export function useSuspendingLiveQuery<T>( | ||
| querier: () => Promise<T> | T, | ||
| cacheKey: React.DependencyList | ||
| ): T { | ||
| return useSuspendingObservable( | ||
| () => Dexie.liveQuery(querier), | ||
| ['dexie', ...cacheKey] | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,149 @@ | ||||||||||
| import * as React from 'react'; | ||||||||||
| import { AnySubscription, InteropableObservable } from './useObservable'; | ||||||||||
| import { usePromise } from './usePromise'; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Subscribes to an observable and returns the latest value. | ||||||||||
| * Suspends until the first value is received. | ||||||||||
| * | ||||||||||
| * Calls with the same cacheKey will use the same observable. | ||||||||||
| * cacheKey must be globally unique. | ||||||||||
| */ | ||||||||||
| export function useSuspendingObservable<T>( | ||||||||||
| getObservable: (() => InteropableObservable<T>) | InteropableObservable<T>, | ||||||||||
| cacheKey: React.DependencyList | ||||||||||
| ): T { | ||||||||||
| let observable: InteropableObservable<T>; | ||||||||||
| for (const [key, val] of OBSERVABLES.entries()) { | ||||||||||
| if ( | ||||||||||
| key.length === cacheKey.length && | ||||||||||
| key.every((k, i) => Object.is(k, cacheKey[i])) | ||||||||||
| ) { | ||||||||||
| observable = val; | ||||||||||
| break; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| //@ts-ignore (because observable might be undefined here) | ||||||||||
| if (!observable) { | ||||||||||
| observable = typeof getObservable === 'function' | ||||||||||
| ? getObservable() | ||||||||||
| : getObservable; | ||||||||||
| OBSERVABLES.set(cacheKey, observable); | ||||||||||
| } | ||||||||||
| // At this point, observable is always set. | ||||||||||
|
|
||||||||||
| const incrementRef = () => { | ||||||||||
| const timeout = TIMEOUTS.get(observable); | ||||||||||
| if (timeout != null) clearTimeout(timeout); | ||||||||||
|
|
||||||||||
| let refCount = REF_COUNTS.get(observable) ?? 0; | ||||||||||
| refCount += 1; | ||||||||||
| REF_COUNTS.set(observable, refCount); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| const decrementRef = () => { | ||||||||||
| let refCount = REF_COUNTS.get(observable)!; | ||||||||||
| refCount -= 1; | ||||||||||
| REF_COUNTS.set(observable, refCount); | ||||||||||
|
|
||||||||||
| if (refCount > 0) return; | ||||||||||
|
|
||||||||||
| const timeout = setTimeout(() => { | ||||||||||
| for (const [key, val] of OBSERVABLES.entries()) { | ||||||||||
| if (val === observable) { | ||||||||||
| OBSERVABLES.delete(key); | ||||||||||
| break; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| PROMISES.delete(observable); | ||||||||||
| VALUES.delete(observable); | ||||||||||
| TIMEOUTS.delete(observable); | ||||||||||
| REF_COUNTS.delete(observable); | ||||||||||
| }, 1000); | ||||||||||
| TIMEOUTS.set(observable, timeout); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| let promise: Promise<T> | undefined = PROMISES.get(observable); | ||||||||||
| if (!promise) { | ||||||||||
| promise = new Promise<T>((resolve, reject) => { | ||||||||||
| if (VALUES.has(observable)) { | ||||||||||
| resolve(VALUES.get(observable)!); | ||||||||||
| return; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| incrementRef(); | ||||||||||
|
|
||||||||||
| let calledSynchronously = false; | ||||||||||
| let sub: AnySubscription; | ||||||||||
| sub = observable.subscribe( | ||||||||||
| (val) => { | ||||||||||
| resolve(val); | ||||||||||
| VALUES.set(observable, val); | ||||||||||
| if (!sub) calledSynchronously = true; | ||||||||||
| else unsub(sub); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @phaux @dfahlander I've noticed an issue with Upon further investigation, calling Since we subscribe both here and in the I want to verify my assumptions here before creating an issue for this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That seems correct. it's something that could be improved. |
||||||||||
| decrementRef(); | ||||||||||
| }, | ||||||||||
| (err) => { | ||||||||||
| reject(err); | ||||||||||
| if (!sub) calledSynchronously = true; | ||||||||||
| else unsub(sub); | ||||||||||
| decrementRef(); | ||||||||||
| } | ||||||||||
| ); | ||||||||||
| if (calledSynchronously) unsub(sub); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| PROMISES.set(observable, promise); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (!VALUES.has(observable)) { | ||||||||||
| usePromise(promise); | ||||||||||
| } | ||||||||||
|
Comment on lines
+99
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Call On React 19, - if (!VALUES.has(observable)) {
- usePromise(promise);
- }
+ usePromise(promise);📝 Committable suggestion
Suggested change
🧰 Tools🪛 Biome (2.1.2)[error] 100-100: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render. For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order. (lint/correctness/useHookAtTopLevel) 🤖 Prompt for AI Agents
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not true. React use is not a hook, it's allowed in conditional paths. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query: 💡 Result: Short answer
Why
How to handle conditional behavior
References
🌐 Web query: 💡 Result: No — you must not call React Hooks (e.g. useState, useEffect, custom hooks) conditionally. Hooks must be called unconditionally at the top level of a function component or custom hook so they run in the same order on every render; otherwise React can’t match hook calls to stored state and you get bugs or the invalid-hook-call error. Use a conditional inside the hook (e.g. inside useEffect/useMemo) or render different components / custom hooks instead. [1][2] Sources: 🌐 Web query: 💡 Result: React 19 introduced the new React.use API (commonly written import { use } from "react") — a render-time API that can synchronously read:
References:
@dfahlander You're absolutely correct, and I apologize for the incorrect review comment. React.use is a special API introduced in React 19 that, unlike traditional hooks, can be called conditionally or in loops. The conditional call to The Biome linter warning appears to be a false positive—it's treating Thank you for the correction! ✏️ Learnings added
|
||||||||||
|
|
||||||||||
| const [value, setValue] = React.useState<T>(VALUES.get(observable)); | ||||||||||
| const [error, setError] = React.useState<any>(null); | ||||||||||
|
|
||||||||||
| React.useEffect(() => { | ||||||||||
| incrementRef(); | ||||||||||
|
|
||||||||||
| const sub = observable.subscribe( | ||||||||||
| (val) => { | ||||||||||
| VALUES.set(observable, val); | ||||||||||
| setValue(val); | ||||||||||
| }, | ||||||||||
| (err) => { | ||||||||||
| setError(err); | ||||||||||
| } | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| return () => { | ||||||||||
| unsub(sub); | ||||||||||
| decrementRef(); | ||||||||||
| }; | ||||||||||
| }, [observable]); | ||||||||||
|
|
||||||||||
| if (error) throw error; | ||||||||||
| return value; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const OBSERVABLES = new Map<React.DependencyList, InteropableObservable<any>>(); | ||||||||||
|
|
||||||||||
| const PROMISES = new WeakMap<InteropableObservable<any>, Promise<any>>(); | ||||||||||
|
|
||||||||||
| const VALUES = new WeakMap<InteropableObservable<any>, any>(); | ||||||||||
|
|
||||||||||
| const TIMEOUTS = new WeakMap< | ||||||||||
| InteropableObservable<any>, | ||||||||||
| ReturnType<typeof setTimeout> | ||||||||||
| >(); | ||||||||||
|
|
||||||||||
| const REF_COUNTS = new WeakMap<InteropableObservable<any>, number>(); | ||||||||||
|
|
||||||||||
| /** Unsubscribes from an observable */ | ||||||||||
| function unsub(sub: AnySubscription) { | ||||||||||
| if (typeof sub === 'function') { | ||||||||||
| sub(); | ||||||||||
| } else { | ||||||||||
| sub.unsubscribe(); | ||||||||||
| } | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks 👍