Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion addons/y-dexie/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"exports": {
".": {
"import": "./dist/y-dexie.js",
"require": false
"require": null
}
},
"types": "dist/y-dexie.d.ts",
Expand Down
12 changes: 6 additions & 6 deletions libs/dexie-react-hooks/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dexie-react-hooks",
"version": "4.2.0",
"version": "4.2.1-alpha.1",
"description": "React hooks for reactive data fetching using Dexie.js",
"main": "dist/dexie-react-hooks.js",
"typings": "dist/dexie-react-hooks.d.ts",
Expand Down Expand Up @@ -63,12 +63,13 @@
"@rollup/plugin-node-resolve": "^11.1.1",
"@rollup/plugin-typescript": "^8.1.1",
"@types/qunit": "^2.9.6",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"dexie": "workspace:>=4.2.0-alpha.1 <5.0.0",
"just-build": "^0.9.24",
"qunit": "^2.12.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"rollup": "^2.40.0",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-commonjs": "^10.1.0",
Expand All @@ -78,7 +79,6 @@
"typescript": "^5.3.3",
"webpack": "^5.74.0",
"webpack-cli": "^4.5.0",
"dexie": "workspace:>=4.2.0-alpha.1 <5.0.0",
"y-dexie": "workspace:>=4.2.0-alpha.1 <5.0.0",
"yjs": "^13.6.27"
}
Expand Down
4 changes: 3 additions & 1 deletion libs/dexie-react-hooks/src/dexie-react-hooks.ts
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';
28 changes: 28 additions & 0 deletions libs/dexie-react-hooks/src/types/y-dexie.d.ts
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;
}
7 changes: 7 additions & 0 deletions libs/dexie-react-hooks/src/types/yjs.d.ts
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;
};
}
4 changes: 3 additions & 1 deletion libs/dexie-react-hooks/src/useObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ export interface InteropableObservable<T> {
subscribe(
onNext: (x: T) => any,
onError?: (error: any) => any
): (() => any) | { unsubscribe(): any };
): AnySubscription;
getValue?(): T; // For BehaviorSubject
hasValue?(): boolean; // For liveQuery observable returning false until a value is available
}

export type AnySubscription = { unsubscribe(): any } | (() => any);

export function useObservable<T, TDefault>(
observable: InteropableObservable<T>
): T | undefined;
Expand Down
37 changes: 37 additions & 0 deletions libs/dexie-react-hooks/src/usePromise.ts
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' }
>();
17 changes: 17 additions & 0 deletions libs/dexie-react-hooks/src/useSuspendingLiveQuery.ts
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]
);
}
149 changes: 149 additions & 0 deletions libs/dexie-react-hooks/src/useSuspendingObservable.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks 👍

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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@phaux @dfahlander I've noticed an issue with useSuspendingLiveQuery where the querier function is executed twice even though the dependencies and underlying data doesn't change.

Upon further investigation, calling subscribe on an observable appears to re-executes the query. Is this correct? I'm surprised the observable doesn't return the existing value when re-subscribing.

Since we subscribe both here and in the useEffect block below this is causing the querier to execute twice, once for each subscribe call.

I want to verify my assumptions here before creating an issue for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Copy link

@coderabbitai coderabbitai bot Nov 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Call usePromise consistently to avoid breaking hook order.

On React 19, usePromise resolves to React.use, so it must be called every render in the same order. The current conditional means the first render without a cached value calls the hook, but once VALUES is populated subsequent renders skip it, triggering React’s “rendered fewer hooks than expected” error. Please invoke usePromise(promise) unconditionally so the hook order stays stable while still relying on the resolved promise to short‑circuit suspense.

-  if (!VALUES.has(observable)) {
-    usePromise(promise);
-  }
+  usePromise(promise);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!VALUES.has(observable)) {
usePromise(promise);
}
usePromise(promise);
🧰 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.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

🤖 Prompt for AI Agents
In libs/dexie-react-hooks/src/useSuspendingObservable.ts around lines 99-101,
the call to usePromise is currently wrapped in a conditional which causes hook
order to change across renders; call usePromise(promise) unconditionally every
render (remove the if) so the hook runs in the same order, while still relying
on VALUES to determine whether to use the resolved value or short-circuit
suspense.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Call usePromise consistently to avoid breaking hook order.

On React 19, usePromise resolves to React.use, so it must be called every render in the same order. The current conditional means the first render without a cached value calls the hook, but once VALUES is populated subsequent renders skip it, triggering React’s “rendered fewer hooks than expected” error. Please invoke usePromise(promise) unconditionally so the hook order stays stable while still relying on the resolved promise to short‑circuit suspense.

-  if (!VALUES.has(observable)) {
-    usePromise(promise);
-  }
+  usePromise(promise);

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

🧰 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. See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

🤖 Prompt for AI Agents

Not true. React use is not a hook, it's allowed in conditional paths.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

React.use conditional calls rules of hooks

💡 Result:

Short answer

  • Don’t call Hooks conditionally — always call them at the top level of a function component or a custom Hook. React relies on the call order to associate Hook state across renders. [1][2]

Why

  • If you call a Hook inside a condition/loop/nested fn, the number and order of Hook calls can change between renders, which breaks React’s internal state mapping and causes bugs or the “Hooks can only be called…” error. [1][2]

How to handle conditional behavior

  • Keep Hook calls unconditional, put the condition inside the Hook callback:
    • Bad: if (cond) useEffect(...);
    • Good: useEffect(() => { if (!cond) return; /* do work */ }, [cond]); [1]
  • Or wrap logic in a custom Hook that always runs (but internally short-circuits). [1]
  • Use the official linter (eslint-plugin-react-hooks) to catch mistakes. [1][3]

References

  • React Rules of Hooks. [1]
  • Invalid Hook Call / Rules of Hooks guide. [2]
  • Example explanation / community answers. [3]

🌐 Web query:

React.use API can be called conditionally

💡 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:
[1] React — Rules of Hooks.
[2] React (legacy docs) — Rules of Hooks.


🌐 Web query:

React.use API React 19 documentation

💡 Result:

React 19 introduced the new React.use API (commonly written import { use } from "react") — a render-time API that can synchronously read:

  • a Thenable/Promise (causes the component to suspend until it resolves) and
  • a React Context value (an alternative to useContext).
    Unlike ordinary hooks, use may be called conditionally or in loops (but must be called from a component or a hook) and is designed to integrate with Suspense and streaming/server features. Note: promises created directly in render are not supported — use must consume Suspense-compatible/cached resources or promises provided by a Suspense-aware library/framework. [1][2][3]

References:

  • React 19 blog & feature overview. [1]
  • React.use coverage in community docs/articles (examples, caveats on promises-in-render). [2][3]

@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 usePromise (which wraps React.use) is perfectly valid here.

The Biome linter warning appears to be a false positive—it's treating usePromise as a traditional hook when React.use is explicitly designed to be more flexible. The conditional pattern if (!VALUES.has(observable)) { usePromise(promise); } is correct and leverages the fact that React.use doesn't follow the standard Rules of Hooks.

Thank you for the correction!


✏️ Learnings added
Learnt from: dfahlander
Repo: dexie/Dexie.js PR: 2205
File: libs/dexie-react-hooks/src/useSuspendingObservable.ts:99-101
Timestamp: 2025-11-01T15:56:12.570Z
Learning: In React 19, React.use is a special render-time API that can be called conditionally or in loops, unlike traditional hooks (useState, useEffect, etc.). It's designed to read Promises/Thenables and Context values, and does not need to follow the Rules of Hooks regarding unconditional calls at the top level.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


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();
}
}
6 changes: 3 additions & 3 deletions libs/dexie-react-hooks/test/components/ErrorBoundrary.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { Component } from "react";

export class ErrorBoundary extends Component<{}, { error: any }> {
constructor(props) {
export class ErrorBoundary extends Component<React.PropsWithChildren<{}>, { error: any }> {
constructor(props: React.PropsWithChildren<{}>) {
super(props);
this.state = { error: null };
}
Expand All @@ -11,7 +11,7 @@ export class ErrorBoundary extends Component<{}, { error: any }> {
return { error };
}

componentDidCatch(error, errorInfo) {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
//console.error(error, errorInfo);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { useLiveQuery } from "../../src";
import { useSuspendingLiveQuery } from "../../src";
import { Item } from "../models/Item";
import { ItemComponent } from "./ItemComponent";

Expand All @@ -9,8 +9,7 @@ export interface Props {
}

export function ItemLoaderComponent({ id, loadItem }: Props) {
const item = useLiveQuery(() => loadItem(id), [id], "loading");
if (item === "loading") return <p>Loading...</p>;
const item = useSuspendingLiveQuery(() => loadItem(id), ["item", id]);
if (!item)
return (
<p className="not-found-item">
Expand Down
Loading