Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/tall-mails-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/react': minor
---

Added `useSuspenseQuery` hook, allowing queries to suspend instead of returning `isLoading`/`isFetching` state.
158 changes: 155 additions & 3 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const TodoListDisplay = () => {
}
```

### Accessing PowerSync Status
## Accessing PowerSync Status

The provided PowerSync client status is available with the `useStatus` hook.

Expand All @@ -63,7 +63,7 @@ const Component = () => {
};
```

### Queries
## Queries

Queries will automatically update when a dependant table is updated unless you set the `runQueryOnce` flag. You are also able to use a compilable query (e.g. [Kysely queries](https://github.com/powersync-ja/powersync-js/tree/main/packages/kysely-driver)) as a query argument in place of a SQL statement string.

Expand All @@ -82,7 +82,7 @@ export const TodoListDisplay = () => {
}
```

#### Query Loading
### Query Loading

The response from `useQuery` includes the `isLoading` and `isFetching` properties, which indicate the current state of data retrieval. This can be used to show loading spinners or conditional widgets.

Expand Down Expand Up @@ -116,3 +116,155 @@ export const TodoListsDisplayDemo = () => {
};

```

### Suspense

The `useSuspenseQuery` hook is available to handle the loading/fetching state through suspense.
Unlike `useQuery`, the hook doesn't return `isLoading` or `isFetching` for the loading states nor `error` for the error state. These should be handled with variants of `<Suspense>` and `<ErrorBoundary>` respectively.

```JSX
// TodoListDisplaySuspense.jsx
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
import { useSuspenseQuery } from '@powersync/react';

const TodoListContent = () => {
const { data: todoLists } = useSuspenseQuery("SELECT * FROM lists");

return (
<ul>
{todoLists.map((list) => (
<li key={list.id}>{list.name}</li>
))}
</ul>
);
};


export const TodoListDisplaySuspense = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading todo lists...</div>}>
<TodoListContent />
</Suspense>
</ErrorBoundary>
);
};
```

#### Blocking navigation on Suspense

When you provide a suspense fallback, suspending components will cause the fallback to render. Alternatively, React's [startTransition](https://react.dev/reference/react/startTransition) allows navigation to be blocked until the suspending components have completed, preventing the fallback from displaying. This behavior can be facilitated by your router — for example, react-router supports this with its [startTransition flag](https://reactrouter.com/en/main/upgrading/future#v7_starttransition).

> Note: In this example, the `<Suspense>` boundary is intentionally omitted to delegate the handling of the suspending state to the router.

```JSX
// routerAndLists.jsx
import { RouterProvider } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { useSuspenseQuery } from '@powersync/react';

export const Index() {
return <RouterProvider router={router} future={{v7_startTransition: true}} />
}

const TodoListContent = () => {
const { data: todoLists } = useSuspenseQuery("SELECT * FROM lists");

return (
<ul>
{todoLists.map((list) => (
<li key={list.id}>{list.name}</li>
))}
</ul>
);
};


export const TodoListsPage = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<TodoListContent />
</ErrorBoundary>
);
};
```

#### Managing Suspense When Updating `useSuspenseQuery` Parameters

When data in dependent tables changes, `useSuspenseQuery` automatically updates without suspending. However, changing the query parameters causes the hook to restart and enter a suspending state again, which triggers the suspense fallback. To prevent this and keep displaying the stale data until the new data is loaded, wrap the parameter changes in React's [startTransition](https://react.dev/reference/react/startTransition) or use [useDeferredValue](https://react.dev/reference/react/useDeferredValue).

```JSX
// TodoListDisplaySuspenseTransition.jsx
import { ErrorBoundary } from 'react-error-boundary';
import React, { Suspense } from 'react';
import { useSuspenseQuery } from '@powersync/react';

const TodoListContent = () => {
const [query, setQuery] = React.useState('SELECT * FROM lists');
const { data: todoLists } = useSuspenseQuery(query);

return (
<div>
<button
onClick={() => {
React.startTransition(() => setQuery('SELECT * from lists limit 1'));
}}>
Update
</button>
<ul>
{todoLists.map((list) => (
<li key={list.id}>{list.name}</li>
))}
</ul>
</div>
);
};

export const TodoListDisplaySuspense = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading todo lists...</div>}>
<TodoListContent />
</Suspense>
</ErrorBoundary>
);
};
```

and

```JSX
// TodoListDisplaySuspenseDeferred.jsx
import { ErrorBoundary } from 'react-error-boundary';
import React, { Suspense } from 'react';
import { useSuspenseQuery } from '@powersync/react';

const TodoListContent = () => {
const [query, setQuery] = React.useState('SELECT * FROM lists');
const deferredQueryQuery = React.useDeferredValue(query);

const { data: todoLists } = useSuspenseQuery(deferredQueryQuery);

return (
<div>
<button onClick={() => setQuery('SELECT * from lists limit 1')}>Update</button>
<ul>
{todoLists.map((list) => (
<li key={list.id}>{list.name}</li>
))}
</ul>
</div>
);
};

export const TodoListDisplaySuspense = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading todo lists...</div>}>
<TodoListContent />
</Suspense>
</ErrorBoundary>
);
};
```
34 changes: 34 additions & 0 deletions packages/react/src/QueryStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { AbstractPowerSyncDatabase, CompilableQuery, SQLWatchOptions } from '@powersync/common';
import { Query, WatchedQuery } from './WatchedQuery';

export class QueryStore {
cache = new Map<string, WatchedQuery>();

constructor(private db: AbstractPowerSyncDatabase) {}

getQuery(key: string, query: Query<unknown>, options: SQLWatchOptions) {
if (this.cache.has(key)) {
return this.cache.get(key);
}
const disposer = () => {
this.cache.delete(key);
};
const q = new WatchedQuery(this.db, query, options, disposer);
this.cache.set(key, q);

return q;
}
}

let queryStores: WeakMap<AbstractPowerSyncDatabase, QueryStore> | undefined = undefined;

export function getQueryStore(db: AbstractPowerSyncDatabase): QueryStore {
queryStores ||= new WeakMap();
const existing = queryStores.get(db);
if (existing) {
return existing;
}
const store = new QueryStore(db);
queryStores.set(db, store);
return store;
}
175 changes: 175 additions & 0 deletions packages/react/src/WatchedQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { AbstractPowerSyncDatabase, CompilableQuery, QueryResult, SQLWatchOptions } from '@powersync/common';
import { AdditionalOptions } from 'src/hooks/useQuery';

export class Query<T> {
rawQuery: string | CompilableQuery<T>;
sqlStatement: string;
queryParameters: any[];
}

export class WatchedQuery {
listeners = new Set<() => void>();

readyPromise: Promise<void>;
isReady: boolean = false;
currentData: any[] | undefined;
currentError: any;
tables: any[] | undefined;

private temporaryHolds = new Set();
private controller: AbortController | undefined;
private db: AbstractPowerSyncDatabase;

private resolveReady: undefined | (() => void);

readonly query: Query<unknown>;
readonly options: AdditionalOptions;
private disposer: () => void;

constructor(db: AbstractPowerSyncDatabase, query: Query<unknown>, options: AdditionalOptions, disposer: () => void) {
this.db = db;
this.query = query;
this.options = options;
this.disposer = disposer;

this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}

addTemporaryHold() {
const ref = new Object();
this.temporaryHolds.add(ref);
this.maybeListen();

let timeout: any;
const release = () => {
this.temporaryHolds.delete(ref);
if (timeout) {
clearTimeout(timeout);
}
this.maybeDispose();
};

const timeoutRelease = () => {
if (this.isReady || this.controller == null) {
release();
} else {
// If the query is taking long, keep the temporary hold.
timeout = setTimeout(release, 5_000);
}
};

timeout = setTimeout(timeoutRelease, 5_000);

return release;
}

addListener(l: () => void) {
this.listeners.add(l);

this.maybeListen();
return () => {
this.listeners.delete(l);
this.maybeDispose();
};
}

private async fetchTables() {
try {
this.tables = await this.db.resolveTables(this.query.sqlStatement, this.query.queryParameters, this.options);
} catch (e) {
console.error('Failed to fetch tables:', e);
this.setError(e);
}
}

async fetchData() {
try {
const result =
typeof this.query.rawQuery == 'string'
? await this.db.getAll(this.query.sqlStatement, this.query.queryParameters)
: await this.query.rawQuery.execute();

const data = result ?? [];
this.setData(data);
} catch (e) {
console.error('Failed to fetch data:', e);
this.setError(e);
}
}

private maybeListen() {
if (this.controller != null) {
return;
}
if (this.listeners.size == 0 && this.temporaryHolds.size == 0) {
return;
}

const controller = new AbortController();
this.controller = controller;

const onError = (error: Error) => {
this.setError(error);
};

(async () => {
await this.fetchTables();
await this.fetchData();

if (!this.options.runQueryOnce) {
this.db.onChangeWithCallback(
{
onChange: async () => {
await this.fetchData();
},
onError
},
{
...this.options,
signal: this.controller.signal,
tables: this.tables
}
);
}
})();
}

private setData(results: any[]) {
this.isReady = true;
this.currentData = results;
this.currentError = undefined;
this.resolveReady?.();

for (let listener of this.listeners) {
listener();
}
}

private setError(error: any) {
this.isReady = true;
this.currentData = undefined;
this.currentError = error;
this.resolveReady?.();

for (let listener of this.listeners) {
listener();
}
}

private maybeDispose() {
if (this.listeners.size == 0 && this.temporaryHolds.size == 0) {
this.controller?.abort();
this.controller = undefined;
this.isReady = false;
this.currentData = undefined;
this.currentError = undefined;
this.disposer?.();

this.readyPromise = new Promise((resolve, reject) => {
this.resolveReady = resolve;
});
}
}
}
Loading
Loading