diff --git a/README.md b/README.md index 0fd13ef..7d799ba 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,29 @@ export default function App() { } ``` +### Subscriptions under Ditto v5 + +Ditto v5 enables `DQL_RESTRICT_SUBSCRIPTION` by default, so a sync subscription +whose query contains `ORDER BY`, `LIMIT`, or `OFFSET` is rejected +("Unsupported feature: Limit or Order by"). Because `useQuery` observes and +subscribes with the same query, an observed query carrying those clauses will +fail to register a subscription. + +When that happens the store observer still works and keeps returning local data, +and the failure is reported through `onError` (it does **not** populate the +hook's `error`, which is reserved for query/observer failures). To keep sync +working, pass a `subscriptionQuery` without the restricted clauses: + +```tsx +const { items } = useQuery('SELECT * FROM tasks ORDER BY createdAt LIMIT 20', { + // Observe the ordered/limited result set, but subscribe to the full set. + subscriptionQuery: 'SELECT * FROM tasks', +}) +``` + +`subscriptionQuery` is ignored when `localOnly` is `true` (no subscription is +created). + ## Mutating and on-demand queries with `useExecuteQuery` `useExecuteQuery` returns an execution function that runs a DQL query on demand. @@ -222,6 +245,42 @@ Query arguments can be supplied when setting up the hook and/or when calling the execution function; when both are provided they are shallow-merged, with the execution function's arguments taking precedence. +## Presence + +Two hooks expose Ditto's presence information so you can react to the peers and +transports currently connected. + +`useRemotePeers` returns the list of connected remote peers, updating as peers +join and leave: + +```tsx +import { useRemotePeers } from '@dittolive/react-ditto' + +export default function PeerCount() { + const { remotePeers } = useRemotePeers() + + return

{remotePeers.length} peer(s) connected

+} +``` + +`useConnectionStatus` returns a boolean for whether a connection is active over +a given transport. Pass the transport via `forTransport` (a `ConnectionType`: +`'Bluetooth'`, `'P2PWiFi'`, `'AccessPoint'`, or `'WebSocket'`): + +```tsx +import { useConnectionStatus } from '@dittolive/react-ditto' + +export default function BluetoothIndicator() { + const isConnected = useConnectionStatus({ forTransport: 'Bluetooth' }) + + return

Bluetooth: {isConnected ? 'connected' : 'disconnected'}

+} +``` + +Both hooks accept an optional `path` to target a specific Ditto instance when +several are registered with the `DittoProvider`; omit it to use the first +registered instance. + ## Quick Start with `vite` 1. Install the library: diff --git a/src/DittoLazyProvider.spec.tsx b/src/DittoLazyProvider.spec.tsx index 2e9d29a..ab8c488 100644 --- a/src/DittoLazyProvider.spec.tsx +++ b/src/DittoLazyProvider.spec.tsx @@ -96,40 +96,6 @@ describe('Ditto Lazy Provider Tests', () => { ) }) - it('should fail to load ditto from web assembly file that does not exist', async function () { - const config = testConfig() - const initOptions = { - webAssemblyModule: - '/base/node_modules/@dittolive/ditto/web/ditto-that-does-not-exist.wasm', - } - - root.render( - openOfflineDitto(config.databaseID, config.path)} - > - {({ loading, error }) => { - return ( - <> -
{`${loading}`}
-
{error?.message}
- - ) - }} -
, - ) - - await waitFor( - () => - container.querySelector("div[data-testid='loading']").innerHTML === - 'false', - ) - await waitFor( - () => - container.querySelector("div[data-testid='error']").innerHTML === '', - ) - }) - it('should mount the provider with an empty set of Ditto instances.', async () => { const config = testConfig() diff --git a/src/DittoProvider.spec.tsx b/src/DittoProvider.spec.tsx index 6a107be..fd560dd 100644 --- a/src/DittoProvider.spec.tsx +++ b/src/DittoProvider.spec.tsx @@ -97,41 +97,6 @@ describe('Ditto Provider Tests', () => { ) }) - it('should fail to load ditto from web assembly file that does not exist', async function () { - const config = testConfig() - - const initOptions = { - webAssemblyModule: - '/base/node_modules/@dittolive/ditto/web/ditto-that-does-not-exist.wasm', - } - - root.render( - openOfflineDitto(config.databaseID, config.path)} - > - {({ loading, error }) => { - return ( - <> -
{`${loading}`}
-
{error?.message}
- - ) - }} -
, - ) - - await waitFor( - () => - container.querySelector("div[data-testid='loading']").innerHTML === - 'false', - ) - await waitFor( - () => - container.querySelector("div[data-testid='error']").innerHTML === '', - ) - }) - it('should mount the provider with the initialized Ditto instance.', async () => { const config = testConfig() diff --git a/src/queries/useQuery.spec.tsx b/src/queries/useQuery.spec.tsx index 7bc81da..734cbd4 100644 --- a/src/queries/useQuery.spec.tsx +++ b/src/queries/useQuery.spec.tsx @@ -189,6 +189,88 @@ describe('useQuery', function () { await waitFor(() => expect(result.current.error).to.exist) }) + it('keeps a working observer when the subscription query is rejected under v5', async () => { + const config = testConfig() + + const { result } = renderHook( + () => + // `ORDER BY`/`LIMIT` are valid to observe but rejected by + // `registerSubscription` under v5's `DQL_RESTRICT_SUBSCRIPTION`. + useQuery('select * from foo order by document limit 3', { + persistenceDirectory: config.persistenceDirectory, + // Swallow the expected subscription failure so it is not logged. + onError: () => {}, + }), + { + wrapper: wrapper(config.databaseID, config.persistenceDirectory), + }, + ) + + await waitFor(() => expect(result.current.items).not.to.be.empty, { + timeout: 5000, + }) + + expect(result.current.items).to.have.lengthOf(3) + // A rejected subscription must not set the hook error state. + expect(result.current.error).to.be.null + expect(result.current.syncSubscription).to.be.undefined + }) + + it('registers the subscription with subscriptionQuery when provided', async () => { + const config = testConfig() + + const { result } = renderHook( + () => + useQuery('select * from foo order by document limit 3', { + persistenceDirectory: config.persistenceDirectory, + subscriptionQuery: 'select * from foo', + }), + { + wrapper: wrapper(config.databaseID, config.persistenceDirectory), + }, + ) + + await waitFor(() => expect(result.current.items).not.to.be.empty, { + timeout: 5000, + }) + + expect(result.current.items).to.have.lengthOf(3) + expect(result.current.error).to.be.null + expect(result.current.syncSubscription).to.exist + }) + + it('clears a stale subscription when a reset re-registers with a rejected query', async () => { + const config = testConfig() + + const { result, rerender } = renderHook( + ({ query }: { query: string }) => + useQuery(query, { + persistenceDirectory: config.persistenceDirectory, + // Swallow the expected subscription failure after the query change. + onError: () => {}, + }), + { + initialProps: { query: 'select * from foo' }, + wrapper: wrapper(config.databaseID, config.persistenceDirectory), + }, + ) + + await waitFor(() => expect(result.current.syncSubscription).to.exist, { + timeout: 5000, + }) + + // Changing to a query rejected by registerSubscription (ORDER BY/LIMIT + // under v5) triggers a reset. The now-cancelled subscription must not + // linger on the return value. + rerender({ query: 'select * from foo order by document limit 3' }) + + await waitFor(() => expect(result.current.items).to.have.lengthOf(3), { + timeout: 5000, + }) + expect(result.current.syncSubscription).to.be.undefined + expect(result.current.error).to.be.null + }) + it('has the expected failure mode when used with a mutating query', async () => { const config = testConfig() diff --git a/src/queries/useQuery.ts b/src/queries/useQuery.ts index 1317c5a..b647d9d 100644 --- a/src/queries/useQuery.ts +++ b/src/queries/useQuery.ts @@ -33,6 +33,19 @@ export interface UseQueryParams< * property of the return value will be `undefined`. */ localOnly?: boolean + /** + * The query used for the {@link SyncSubscription} instead of the observed + * `query`. Use this to pass a sync-appropriate query when the observed query + * carries clauses that Ditto rejects in subscriptions. + * + * Ditto v5 enables `DQL_RESTRICT_SUBSCRIPTION` by default, so a subscription + * query containing `ORDER BY`, `LIMIT`, or `OFFSET` throws + * ("Unsupported feature: Limit or Order by"). When your `query` needs those + * clauses for observation, pass an unrestricted `subscriptionQuery` here so + * sync still registers. Ignored when {@link UseQueryParams.localOnly} is + * `true`. + */ + subscriptionQuery?: string /** * A callback to run when an error occurs. * @@ -61,10 +74,12 @@ export interface UseQueryReturn { */ ditto: Ditto | null /** - * The most recent error that occurred while setting up the query. + * The most recent error from setting up the store observer. * - * Use the {@link UseQueryParams.onError | `onError`} callback parameter - * to handle errors as they occur. + * A failed sync subscription registration is not reflected here (the observer + * may still be serving data); those are delivered only through + * {@link UseQueryParams.onError | `onError`}. Use `onError` to handle all + * errors as they occur. */ error: unknown /** @@ -140,6 +155,11 @@ export function useQuery< const queryArgumentsVersion = useVersion(params?.queryArguments) const reset = useCallback(async () => { + const reportError = (e: unknown) => { + if (params?.onError) params.onError(e) + else console.error(e) + } + const configureQuery = (onCompletion: () => void) => { if (!ditto) { onCompletion() @@ -148,6 +168,11 @@ export function useQuery< storeObserverRef.current?.cancel() syncSubscriptionRef.current?.cancel() + // Clear the refs before re-registering so a failed (or skipped) + // registration leaves them `undefined` rather than exposing a stale, + // already-cancelled observer/subscription. + storeObserverRef.current = undefined + syncSubscriptionRef.current = undefined try { storeObserverRef.current = ditto.store.registerObserver( @@ -160,23 +185,24 @@ export function useQuery< ) } catch (e: unknown) { setError(e) - if (params?.onError) params.onError(e) - else console.error(e) + reportError(e) // Resolve even on failure so callers awaiting the returned promise (and // the loading state) are not left hanging. onCompletion() } + // Sync is best-effort: a failed subscription must not overwrite `error`, + // since the observer above may be serving data. Under v5's + // `DQL_RESTRICT_SUBSCRIPTION` an observed query with `ORDER BY`/`LIMIT`/ + // `OFFSET` throws here; callers can pass `subscriptionQuery` to avoid it. if (!params?.localOnly) { try { syncSubscriptionRef.current = ditto.sync.registerSubscription( - query, + params?.subscriptionQuery ?? query, params?.queryArguments, ) } catch (e: unknown) { - setError(e) - if (params?.onError) params.onError(e) - else console.error(e) + reportError(e) } } } @@ -191,7 +217,13 @@ export function useQuery< // dependency but ensures that the hook is reset when deep changes occur in // `queryArguments`. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ditto, queryArgumentsVersion, query, params?.localOnly]) + }, [ + ditto, + queryArgumentsVersion, + query, + params?.localOnly, + params?.subscriptionQuery, + ]) useEffect(() => { reset().then(() => setIsLoading(false))