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
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task>('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.
Expand Down Expand Up @@ -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 <p>{remotePeers.length} peer(s) connected</p>
}
```

`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 <p>Bluetooth: {isConnected ? 'connected' : 'disconnected'}</p>
}
```

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:
Expand Down
34 changes: 0 additions & 34 deletions src/DittoLazyProvider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DittoLazyProvider
initOptions={initOptions}
setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{({ loading, error }) => {
return (
<>
<div data-testid="loading">{`${loading}`}</div>
<div data-testid="error">{error?.message}</div>
</>
)
}}
</DittoLazyProvider>,
)

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()

Expand Down
35 changes: 0 additions & 35 deletions src/DittoProvider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DittoProvider
initOptions={initOptions}
setup={() => openOfflineDitto(config.databaseID, config.path)}
>
{({ loading, error }) => {
return (
<>
<div data-testid="loading">{`${loading}`}</div>
<div data-testid="error">{error?.message}</div>
</>
)
}}
</DittoProvider>,
)

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()

Expand Down
82 changes: 82 additions & 0 deletions src/queries/useQuery.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
52 changes: 42 additions & 10 deletions src/queries/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -61,10 +74,12 @@ export interface UseQueryReturn<T> {
*/
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
/**
Expand Down Expand Up @@ -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()
Expand All @@ -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<T>(
Expand All @@ -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)
Comment thread
aaronleopold marked this conversation as resolved.
}
}
}
Expand All @@ -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))
Expand Down
Loading