diff --git a/demos/react-supabase-todolist/package.json b/demos/react-supabase-todolist/package.json index c4a9055f..b2eac20e 100644 --- a/demos/react-supabase-todolist/package.json +++ b/demos/react-supabase-todolist/package.json @@ -19,6 +19,7 @@ "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.13", "react-router-dom": "^6.22.3" }, "devDependencies": { diff --git a/demos/react-supabase-todolist/src/app/index.tsx b/demos/react-supabase-todolist/src/app/index.tsx index 450cc12a..eb1169ba 100644 --- a/demos/react-supabase-todolist/src/app/index.tsx +++ b/demos/react-supabase-todolist/src/app/index.tsx @@ -8,9 +8,11 @@ const root = createRoot(document.getElementById('app')!); root.render(); export function App() { - return - - - - ; + return ( + + + + + + ); } diff --git a/demos/react-supabase-todolist/src/app/router.tsx b/demos/react-supabase-todolist/src/app/router.tsx index 8268cc66..1dc30bae 100644 --- a/demos/react-supabase-todolist/src/app/router.tsx +++ b/demos/react-supabase-todolist/src/app/router.tsx @@ -2,10 +2,10 @@ import { Outlet, createBrowserRouter } from "react-router-dom"; import LoginPage from "./auth/login/page"; import RegisterPage from "./auth/register/page"; import EntryPage from "./page"; -import TodoEditPage from "./views/todo-lists/edit/page"; -import TodoListsPage from "./views/todo-lists/page"; import ViewsLayout from "./views/layout"; import SQLConsolePage from "./views/sql-console/page"; +import TodoEditPage from "./views/todo-lists/edit/page"; +import TodoListsPage from "./views/todo-lists/page"; export const TODO_LISTS_ROUTE = '/views/todo-lists'; export const TODO_EDIT_ROUTE = '/views/todo-lists/:id'; diff --git a/demos/react-supabase-todolist/src/app/views/layout.tsx b/demos/react-supabase-todolist/src/app/views/layout.tsx index ccc256c1..b85df2f6 100644 --- a/demos/react-supabase-todolist/src/app/views/layout.tsx +++ b/demos/react-supabase-todolist/src/app/views/layout.tsx @@ -21,7 +21,7 @@ import { Typography, styled } from '@mui/material'; -import React from 'react'; +import React, { Suspense } from 'react'; import { useNavigationPanel } from '@/components/navigation/NavigationPanelContext'; import { useSupabase } from '@/components/providers/SystemProvider'; @@ -118,7 +118,9 @@ export default function ViewsLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + ); diff --git a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx index 949dff5e..0d8af258 100644 --- a/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/sql-console/page.tsx @@ -1,9 +1,11 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { usePowerSyncWatchedQuery } from '@journeyapps/powersync-react'; -import { Box, Button, Grid, TextField, styled } from '@mui/material'; +import { Box, Button, CircularProgress, Grid, TextField, styled } from '@mui/material'; import { DataGrid } from '@mui/x-data-grid'; import { NavigationPage } from '@/components/navigation/NavigationPage'; +import { ErrorBoundary } from "react-error-boundary"; + export type LoginFormParams = { email: string; password: string; @@ -14,21 +16,6 @@ const DEFAULT_QUERY = 'SELECT * FROM lists'; export default function SQLConsolePage() { const inputRef = React.useRef(); const [query, setQuery] = React.useState(DEFAULT_QUERY); - const querySQLResult = usePowerSyncWatchedQuery(query); - - const queryDataGridResult = React.useMemo(() => { - const firstItem = querySQLResult?.[0]; - - return { - columns: firstItem - ? Object.keys(firstItem).map((field) => ({ - field, - flex: 1 - })) - : [], - rows: querySQLResult - }; - }, [querySQLResult]); return ( @@ -64,31 +51,67 @@ export default function SQLConsolePage() { - {queryDataGridResult ? ( - - {queryDataGridResult.columns ? ( - ({ ...r, id: r.id ?? index })) ?? []} - columns={queryDataGridResult.columns} - initialState={{ - pagination: { - paginationModel: { - pageSize: 20 - } - } - }} - pageSizeOptions={[20]} - disableRowSelectionOnClick - /> - ) : null} - - ) : null} + }> + {/* Use resetKeys to dismiss the error when changing the query. */} + + + + + ); } +function SqlConsoleResults(props: { query: string }) { + const querySQLResult = usePowerSyncWatchedQuery(props.query); + + const queryDataGridResult = React.useMemo(() => { + const firstItem = querySQLResult?.[0]; + + return { + columns: firstItem + ? Object.keys(firstItem).map((field) => ({ + field, + flex: 1 + })) + : [], + rows: querySQLResult + }; + }, [querySQLResult]); + + return queryDataGridResult ? ( + + {queryDataGridResult.columns ? ( + ({ ...r, id: r.id ?? index })) ?? []} + columns={queryDataGridResult.columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 20 + } + } + }} + pageSizeOptions={[20]} + disableRowSelectionOnClick + /> + ) : null} + + ) : null +} + +function fallbackRender(options: { error: any }) { + return ( +
+
{options.error.message}
+
+ ); +} + + namespace S { export const MainContainer = styled(Box)` padding: 20px; diff --git a/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx b/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx index c92f52db..851afffb 100644 --- a/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/todo-lists/edit/page.tsx @@ -1,26 +1,27 @@ +import { NavigationPage } from '@/components/navigation/NavigationPage'; import { useSupabase } from '@/components/providers/SystemProvider'; import { TodoItemWidget } from '@/components/widgets/TodoItemWidget'; +import { TodoListsWidget } from '@/components/widgets/TodoListsWidget'; import { LISTS_TABLE, TODOS_TABLE, TodoRecord } from '@/library/powersync/AppSchema'; import { usePowerSync, usePowerSyncWatchedQuery } from '@journeyapps/powersync-react'; import AddIcon from '@mui/icons-material/Add'; import { Box, Button, - CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, + Grid, List, TextField, Typography, styled } from '@mui/material'; import Fab from '@mui/material/Fab'; -import React, { Suspense } from 'react'; +import React from 'react'; import { useParams } from 'react-router-dom'; -import { NavigationPage } from '@/components/navigation/NavigationPage'; /** * useSearchParams causes the entire element to fall back to client side rendering @@ -35,7 +36,6 @@ const TodoEditSection = () => { const [listRecord] = usePowerSyncWatchedQuery<{ name: string }>(`SELECT name FROM ${LISTS_TABLE} WHERE id = ?`, [ listID ]); - const todos = usePowerSyncWatchedQuery( `SELECT * FROM ${TODOS_TABLE} WHERE list_id=? ORDER BY created_at DESC, id`, [listID] @@ -104,17 +104,24 @@ const TodoEditSection = () => { - - {todos.map((r) => ( - deleteTodo(r.id)} - isComplete={r.completed == 1} - toggleCompletion={() => toggleCompletion(r, !r.completed)} - /> - ))} - + + + + + + + {todos.map((r) => ( + deleteTodo(r.id)} + isComplete={r.completed == 1} + toggleCompletion={() => toggleCompletion(r, !r.completed)} + /> + ))} + + + {/* TODO use a dialog service in future, this is just a simple example app */} { export default function TodoEditPage() { return ( - }> - - + ); } diff --git a/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx b/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx index 89c3ee97..1eba3b72 100644 --- a/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx @@ -1,3 +1,7 @@ +import { NavigationPage } from '@/components/navigation/NavigationPage'; +import { useSupabase } from '@/components/providers/SystemProvider'; +import { TodoListsWidget } from '@/components/widgets/TodoListsWidget'; +import { LISTS_TABLE } from '@/library/powersync/AppSchema'; import { usePowerSync } from '@journeyapps/powersync-react'; import AddIcon from '@mui/icons-material/Add'; import { @@ -13,10 +17,7 @@ import { } from '@mui/material'; import Fab from '@mui/material/Fab'; import React from 'react'; -import { NavigationPage } from '@/components/navigation/NavigationPage'; -import { useSupabase } from '@/components/providers/SystemProvider'; -import { TodoListsWidget } from '@/components/widgets/TodoListsWidget'; -import { LISTS_TABLE } from '@/library/powersync/AppSchema'; + export default function TodoListsPage() { const powerSync = usePowerSync(); diff --git a/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx b/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx index 2264e103..24170f81 100644 --- a/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx +++ b/demos/react-supabase-todolist/src/components/widgets/TodoListsWidget.tsx @@ -1,13 +1,14 @@ import { TODO_LISTS_ROUTE } from '@/app/router'; import { LISTS_TABLE, ListRecord, TODOS_TABLE } from '@/library/powersync/AppSchema'; -import { usePowerSync, usePowerSyncWatchedQuery } from "@journeyapps/powersync-react"; -import { List } from "@mui/material"; +import { usePowerSync, usePowerSyncWatchedQuery } from '@journeyapps/powersync-react'; +import { List } from '@mui/material'; import { useNavigate } from 'react-router-dom'; -import { ListItemWidget } from "./ListItemWidget"; +import { ListItemWidget } from './ListItemWidget'; +import React from 'react'; export type TodoListsWidgetProps = { selectedId?: string; -} +}; const description = (total: number, completed: number = 0) => { return `${total - completed} pending, ${completed} completed`; @@ -16,17 +17,18 @@ const description = (total: number, completed: number = 0) => { export function TodoListsWidget(props: TodoListsWidgetProps) { const powerSync = usePowerSync(); const navigate = useNavigate(); + const [isPending, startTransition] = React.useTransition(); const listRecords = usePowerSyncWatchedQuery(` - SELECT + SELECT ${LISTS_TABLE}.*, COUNT(${TODOS_TABLE}.id) AS total_tasks, SUM(CASE WHEN ${TODOS_TABLE}.completed = true THEN 1 ELSE 0 END) as completed_tasks - FROM + FROM ${LISTS_TABLE} - LEFT JOIN ${TODOS_TABLE} + LEFT JOIN ${TODOS_TABLE} ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_id - GROUP BY + GROUP BY ${LISTS_TABLE}.id; - `); + `); const deleteList = async (id: string) => { await powerSync.writeTransaction(async (tx) => { @@ -47,10 +49,10 @@ export function TodoListsWidget(props: TodoListsWidgetProps) { selected={r.id == props.selectedId} onDelete={() => deleteList(r.id)} onPress={() => { - navigate(TODO_LISTS_ROUTE + '/' + r.id) + navigate(TODO_LISTS_ROUTE + '/' + r.id); }} /> ))} ); -} \ No newline at end of file +} diff --git a/packages/powersync-react/src/QueryStore.ts b/packages/powersync-react/src/QueryStore.ts new file mode 100644 index 00000000..7f6fc232 --- /dev/null +++ b/packages/powersync-react/src/QueryStore.ts @@ -0,0 +1,35 @@ +import { AbstractPowerSyncDatabase, SQLWatchOptions } from '@journeyapps/powersync-sdk-common'; +import { WatchedQuery } from './WatchedQuery'; + +export class QueryStore { + cache = new Map(); + + constructor(private db: AbstractPowerSyncDatabase) {} + + getQuery(query: string, parameters: any[], options: SQLWatchOptions) { + const key = `${query} -- ${JSON.stringify(parameters)} -- ${JSON.stringify(options)}`; + if (this.cache.has(key)) { + return this.cache.get(key); + } + const disposer = () => { + this.cache.delete(key); + }; + const q = new WatchedQuery(this.db, query, parameters, options, disposer); + this.cache.set(key, q); + + return q; + } +} + +let queryStores: WeakMap | 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; +} diff --git a/packages/powersync-react/src/WatchedQuery.ts b/packages/powersync-react/src/WatchedQuery.ts new file mode 100644 index 00000000..634736b2 --- /dev/null +++ b/packages/powersync-react/src/WatchedQuery.ts @@ -0,0 +1,135 @@ +import { AbstractPowerSyncDatabase, SQLWatchOptions } from '@journeyapps/powersync-sdk-common'; + +export class WatchedQuery { + listeners = new Set<() => void>(); + + readyPromise: Promise; + isReady: boolean = false; + currentData: any[] | undefined; + currentError: any; + + private temporaryHolds = new Set(); + private controller: AbortController | undefined; + private db: AbstractPowerSyncDatabase; + + private resolveReady: undefined | (() => void); + + readonly query: string; + readonly parameters: any[]; + readonly options: SQLWatchOptions; + private disposer: () => void; + + constructor( + db: AbstractPowerSyncDatabase, + query: string, + parameters: any[], + options: SQLWatchOptions, + disposer: () => void + ) { + this.db = db; + this.query = query; + this.parameters = parameters; + 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(); + }; + + timeout = setTimeout(release, 5_000); + + return release; + } + + addListener(l: () => void) { + this.listeners.add(l); + + this.maybeListen(); + return () => { + this.listeners.delete(l); + this.maybeDispose(); + }; + } + + private maybeListen() { + if (this.controller != null) { + return; + } + if (this.listeners.size == 0 && this.temporaryHolds.size == 0) { + return; + } + + const controller = new AbortController(); + this.controller = controller; + + (async () => { + try { + for await (const result of this.db.watch(this.query, this.parameters, { + ...this.options, + signal: this.controller.signal + })) { + const data = result.rows?._array ?? []; + this.setData(data); + } + } catch (e) { + this.setError(e); + } finally { + if (this.controller === controller) { + this.controller = undefined; + } + } + })(); + } + + 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; + }); + } + } +} diff --git a/packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts b/packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts index b97b9a11..d3c77dbd 100644 --- a/packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts +++ b/packages/powersync-react/src/hooks/usePowerSyncWatchedQuery.ts @@ -1,42 +1,59 @@ import { SQLWatchOptions } from '@journeyapps/powersync-sdk-common'; import React from 'react'; +import { getQueryStore } from '../QueryStore'; +import { WatchedQuery } from '../WatchedQuery'; import { usePowerSync } from './PowerSyncContext'; /** * A hook to access the results of a watched query. */ export const usePowerSyncWatchedQuery = ( - sqlStatement: string, + query: string, parameters: any[] = [], options: Omit = {} ): T[] => { + let initialResults = []; + const powerSync = usePowerSync(); if (!powerSync) { - return []; + return initialResults; + } + + // When the component is suspended, all state is discarded. We don't get + // any notification of that. So checkoutQuery reserves a temporary hold + // on the query. + // Once the component "commits", we exchange that for a permanent hold. + const store = getQueryStore(powerSync); + const q = store.getQuery(query, parameters, options); + const addedHoldTo = React.useRef(undefined); + const releaseTemporaryHold = React.useRef<(() => void) | undefined>(undefined); + + if (addedHoldTo.current !== q) { + releaseTemporaryHold.current?.(); + releaseTemporaryHold.current = q.addTemporaryHold(); + addedHoldTo.current = q; } - const memoizedParams = React.useMemo(() => parameters, [...parameters]); - const memoizedOptions = React.useMemo(() => options, [JSON.stringify(options)]); - const [data, setData] = React.useState([]); - const abortController = React.useRef(new AbortController()); + const [_counter, setUpdateCounter] = React.useState(0); React.useEffect(() => { - // Abort any previous watches - abortController.current?.abort(); - abortController.current = new AbortController(); - (async () => { - for await (const result of powerSync.watch(sqlStatement, parameters, { - ...options, - signal: abortController.current.signal - })) { - setData(result.rows?._array ?? []); - } - })(); - - return () => { - abortController.current?.abort(); - }; - }, [powerSync, sqlStatement, memoizedParams, memoizedOptions]); - - return data; + const dispose = q.addListener(() => { + setUpdateCounter((counter) => { + return counter + 1; + }); + }); + + releaseTemporaryHold.current?.(); + releaseTemporaryHold.current = undefined; + + return dispose; + }, []); + + if (q.currentError != null) { + throw q.currentError; + } else if (q.currentData != null) { + return q.currentData; + } else { + throw q.readyPromise; + } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54e789e1..4e625afe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -614,6 +614,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^4.0.13 + version: 4.0.13(react@18.2.0) react-router-dom: specifier: ^6.22.3 version: 6.22.3(react-dom@18.2.0)(react@18.2.0) @@ -9649,8 +9652,8 @@ packages: dependencies: '@babel/runtime': 7.24.0 '@emotion/cache': 11.11.0 - '@emotion/react': 11.11.4(@types/react@18.2.65)(react@18.2.0) - '@emotion/styled': 11.11.0(@emotion/react@11.11.4)(@types/react@18.2.65)(react@18.2.0) + '@emotion/react': 11.11.4(@types/react@18.2.64)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.4)(@types/react@18.2.64)(react@18.2.0) csstype: 3.1.3 prop-types: 15.8.1 react: 18.2.0 @@ -26915,6 +26918,15 @@ packages: react: 18.2.0 dev: false + /react-error-boundary@4.0.13(react@18.2.0): + resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.24.0 + react: 18.2.0 + dev: false + /react-error-overlay@6.0.11: resolution: {integrity: sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==}