Skip to content

Commit 89ff4ed

Browse files
committed
FORGE-2185: react-ditto v5 migration follow-ups
- useQuery: add optional subscriptionQuery param, and stop a failed subscription registration from overwriting the hook error state so a healthy observer is not masked (v5 DQL_RESTRICT_SUBSCRIPTION rejects ORDER BY/LIMIT/OFFSET in subscriptions) - remove the trivially-passing "fails to load wasm" provider tests: init() is a process-global singleton so a bad wasm path is never exercised after the first init, and provider error-surfacing is already covered by the setup-failure tests - README: document the useRemotePeers/useConnectionStatus presence hooks and the v5 subscription constraint
1 parent d62b9c7 commit 89ff4ed

5 files changed

Lines changed: 139 additions & 72 deletions

File tree

‎README.md‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,29 @@ export default function App() {
195195
}
196196
```
197197

198+
### Subscriptions under Ditto v5
199+
200+
Ditto v5 enables `DQL_RESTRICT_SUBSCRIPTION` by default, so a sync subscription
201+
whose query contains `ORDER BY`, `LIMIT`, or `OFFSET` is rejected
202+
("Unsupported feature: Limit or Order by"). Because `useQuery` observes and
203+
subscribes with the same query, an observed query carrying those clauses will
204+
fail to register a subscription.
205+
206+
When that happens the store observer still works and keeps returning local data,
207+
and the failure is reported through `onError` (it does **not** populate the
208+
hook's `error`, which is reserved for query/observer failures). To keep sync
209+
working, pass a `subscriptionQuery` without the restricted clauses:
210+
211+
```tsx
212+
const { items } = useQuery<Task>('SELECT * FROM tasks ORDER BY createdAt LIMIT 20', {
213+
// Observe the ordered/limited result set, but subscribe to the full set.
214+
subscriptionQuery: 'SELECT * FROM tasks',
215+
})
216+
```
217+
218+
`subscriptionQuery` is ignored when `localOnly` is `true` (no subscription is
219+
created).
220+
198221
## Mutating and on-demand queries with `useExecuteQuery`
199222

200223
`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
222245
execution function; when both are provided they are shallow-merged, with the
223246
execution function's arguments taking precedence.
224247

248+
## Presence
249+
250+
Two hooks expose Ditto's presence information so you can react to the peers and
251+
transports currently connected.
252+
253+
`useRemotePeers` returns the list of connected remote peers, updating as peers
254+
join and leave:
255+
256+
```tsx
257+
import { useRemotePeers } from '@dittolive/react-ditto'
258+
259+
export default function PeerCount() {
260+
const { remotePeers } = useRemotePeers()
261+
262+
return <p>{remotePeers.length} peer(s) connected</p>
263+
}
264+
```
265+
266+
`useConnectionStatus` returns a boolean for whether a connection is active over
267+
a given transport. Pass the transport via `forTransport` (a `ConnectionType`:
268+
`'Bluetooth'`, `'P2PWiFi'`, `'AccessPoint'`, or `'WebSocket'`):
269+
270+
```tsx
271+
import { useConnectionStatus } from '@dittolive/react-ditto'
272+
273+
export default function BluetoothIndicator() {
274+
const isConnected = useConnectionStatus({ forTransport: 'Bluetooth' })
275+
276+
return <p>Bluetooth: {isConnected ? 'connected' : 'disconnected'}</p>
277+
}
278+
```
279+
280+
Both hooks accept an optional `path` to target a specific Ditto instance when
281+
several are registered with the `DittoProvider`; omit it to use the first
282+
registered instance.
283+
225284
## Quick Start with `vite`
226285

227286
1. Install the library:

‎src/DittoLazyProvider.spec.tsx‎

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -96,40 +96,6 @@ describe('Ditto Lazy Provider Tests', () => {
9696
)
9797
})
9898

99-
it('should fail to load ditto from web assembly file that does not exist', async function () {
100-
const config = testConfig()
101-
const initOptions = {
102-
webAssemblyModule:
103-
'/base/node_modules/@dittolive/ditto/web/ditto-that-does-not-exist.wasm',
104-
}
105-
106-
root.render(
107-
<DittoLazyProvider
108-
initOptions={initOptions}
109-
setup={() => openOfflineDitto(config.databaseID, config.path)}
110-
>
111-
{({ loading, error }) => {
112-
return (
113-
<>
114-
<div data-testid="loading">{`${loading}`}</div>
115-
<div data-testid="error">{error?.message}</div>
116-
</>
117-
)
118-
}}
119-
</DittoLazyProvider>,
120-
)
121-
122-
await waitFor(
123-
() =>
124-
container.querySelector("div[data-testid='loading']").innerHTML ===
125-
'false',
126-
)
127-
await waitFor(
128-
() =>
129-
container.querySelector("div[data-testid='error']").innerHTML === '',
130-
)
131-
})
132-
13399
it('should mount the provider with an empty set of Ditto instances.', async () => {
134100
const config = testConfig()
135101

‎src/DittoProvider.spec.tsx‎

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -97,41 +97,6 @@ describe('Ditto Provider Tests', () => {
9797
)
9898
})
9999

100-
it('should fail to load ditto from web assembly file that does not exist', async function () {
101-
const config = testConfig()
102-
103-
const initOptions = {
104-
webAssemblyModule:
105-
'/base/node_modules/@dittolive/ditto/web/ditto-that-does-not-exist.wasm',
106-
}
107-
108-
root.render(
109-
<DittoProvider
110-
initOptions={initOptions}
111-
setup={() => openOfflineDitto(config.databaseID, config.path)}
112-
>
113-
{({ loading, error }) => {
114-
return (
115-
<>
116-
<div data-testid="loading">{`${loading}`}</div>
117-
<div data-testid="error">{error?.message}</div>
118-
</>
119-
)
120-
}}
121-
</DittoProvider>,
122-
)
123-
124-
await waitFor(
125-
() =>
126-
container.querySelector("div[data-testid='loading']").innerHTML ===
127-
'false',
128-
)
129-
await waitFor(
130-
() =>
131-
container.querySelector("div[data-testid='error']").innerHTML === '',
132-
)
133-
})
134-
135100
it('should mount the provider with the initialized Ditto instance.', async () => {
136101
const config = testConfig()
137102

‎src/queries/useQuery.spec.tsx‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,59 @@ describe('useQuery', function () {
189189
await waitFor(() => expect(result.current.error).to.exist)
190190
})
191191

192+
it('keeps a working observer when the subscription query is rejected under v5', async () => {
193+
const config = testConfig()
194+
195+
const { result } = renderHook(
196+
() =>
197+
// `ORDER BY`/`LIMIT` are valid to observe but rejected by
198+
// `registerSubscription` under v5's `DQL_RESTRICT_SUBSCRIPTION`.
199+
useQuery('select * from foo order by document limit 3', {
200+
persistenceDirectory: config.persistenceDirectory,
201+
// Swallow the expected subscription failure so it is not logged.
202+
onError: () => {},
203+
}),
204+
{
205+
wrapper: wrapper(config.databaseID, config.persistenceDirectory),
206+
},
207+
)
208+
209+
await waitFor(() => expect(result.current.items).not.to.be.empty, {
210+
timeout: 5000,
211+
})
212+
213+
// The observer succeeded and is serving data...
214+
expect(result.current.items).to.have.lengthOf(3)
215+
// ...so the rejected subscription must not have set the error state.
216+
expect(result.current.error).to.be.null
217+
// The subscription itself failed to register.
218+
expect(result.current.syncSubscription).to.be.undefined
219+
})
220+
221+
it('registers the subscription with subscriptionQuery when provided', async () => {
222+
const config = testConfig()
223+
224+
const { result } = renderHook(
225+
() =>
226+
useQuery('select * from foo order by document limit 3', {
227+
persistenceDirectory: config.persistenceDirectory,
228+
subscriptionQuery: 'select * from foo',
229+
}),
230+
{
231+
wrapper: wrapper(config.databaseID, config.persistenceDirectory),
232+
},
233+
)
234+
235+
await waitFor(() => expect(result.current.items).not.to.be.empty, {
236+
timeout: 5000,
237+
})
238+
239+
expect(result.current.items).to.have.lengthOf(3)
240+
// The unrestricted subscriptionQuery registers cleanly, no error.
241+
expect(result.current.error).to.be.null
242+
expect(result.current.syncSubscription).to.exist
243+
})
244+
192245
it('has the expected failure mode when used with a mutating query', async () => {
193246
const config = testConfig()
194247

‎src/queries/useQuery.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ export interface UseQueryParams<
3333
* property of the return value will be `undefined`.
3434
*/
3535
localOnly?: boolean
36+
/**
37+
* The query used for the {@link SyncSubscription} instead of the observed
38+
* `query`. Use this to pass a sync-appropriate query when the observed query
39+
* carries clauses that Ditto rejects in subscriptions.
40+
*
41+
* Ditto v5 enables `DQL_RESTRICT_SUBSCRIPTION` by default, so a subscription
42+
* query containing `ORDER BY`, `LIMIT`, or `OFFSET` throws
43+
* ("Unsupported feature: Limit or Order by"). When your `query` needs those
44+
* clauses for observation, pass an unrestricted `subscriptionQuery` here so
45+
* sync still registers. Ignored when {@link UseQueryParams.localOnly} is
46+
* `true`.
47+
*/
48+
subscriptionQuery?: string
3649
/**
3750
* A callback to run when an error occurs.
3851
*
@@ -170,11 +183,16 @@ export function useQuery<
170183
if (!params?.localOnly) {
171184
try {
172185
syncSubscriptionRef.current = ditto.sync.registerSubscription(
173-
query,
186+
params?.subscriptionQuery ?? query,
174187
params?.queryArguments,
175188
)
176189
} catch (e: unknown) {
177-
setError(e)
190+
// A failed subscription registration must not overwrite `error`: the
191+
// observer may have succeeded and be serving data, and under v5's
192+
// `DQL_RESTRICT_SUBSCRIPTION` an observed query with
193+
// `ORDER BY`/`LIMIT`/`OFFSET` throws here even though observation is
194+
// fine. Surface it via `onError` instead of the shared error state,
195+
// and let callers pass `subscriptionQuery` to avoid it.
178196
if (params?.onError) params.onError(e)
179197
else console.error(e)
180198
}
@@ -191,7 +209,13 @@ export function useQuery<
191209
// dependency but ensures that the hook is reset when deep changes occur in
192210
// `queryArguments`.
193211
// eslint-disable-next-line react-hooks/exhaustive-deps
194-
}, [ditto, queryArgumentsVersion, query, params?.localOnly])
212+
}, [
213+
ditto,
214+
queryArgumentsVersion,
215+
query,
216+
params?.localOnly,
217+
params?.subscriptionQuery,
218+
])
195219

196220
useEffect(() => {
197221
reset().then(() => setIsLoading(false))

0 commit comments

Comments
 (0)