Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0eda2db
Test that mutate() waits for a slow native enqueue
dmurphy5 Nov 10, 2026
afc5337
Make mutate() wait for native's answer instead of timing out
dmurphy5 Nov 10, 2026
eeb602b
Add onWarning and holdNext, and fix docs after review
dmurphy5 Nov 10, 2026
d24d8b7
Add useRequests(): the queue rows as React hooks, cached per client
dmurphy5 Nov 10, 2026
07e43b5
Document the row hooks, their limits, and how the fake drives them
dmurphy5 Nov 10, 2026
130726f
Fix the row cache after review: always check at subscribe, index by id
dmurphy5 Nov 10, 2026
106c251
Guard the row cache's native callbacks and drop it while idle
dmurphy5 Nov 10, 2026
0b56d78
Make held enqueues block later ones in the fake, and validate configu…
dmurphy5 Nov 10, 2026
ce47f98
Merge remote-tracking branch 'origin/dylan/mutate-waits' into dylan/r…
dmurphy5 Nov 10, 2026
600bfe7
Say that onWarning gets the JavaScript warnings only
dmurphy5 Nov 10, 2026
9bf1101
Merge remote-tracking branch 'origin/dylan/mutate-waits' into dylan/r…
dmurphy5 Nov 10, 2026
e92855c
Merge origin/master into dylan/mutate-waits
dmurphy5 Nov 12, 2026
4d7c3e0
Merge dylan/mutate-waits into dylan/request-rows-hook
dmurphy5 Nov 12, 2026
51958c3
Row cache: do not add back a row on a late state event
dmurphy5 Nov 13, 2026
c4986e8
Row cache: keep cancelled rows native holds, and check late settled e…
dmurphy5 Nov 13, 2026
47ab12b
Merge origin/master into dylan/mutate-waits
dmurphy5 Nov 13, 2026
85305e6
Merge dylan/mutate-waits into dylan/request-rows-hook
dmurphy5 Nov 13, 2026
84c131f
Merge origin/master into dylan/request-rows-hook
dmurphy5 Nov 13, 2026
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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,28 @@ pending until the returned function is called, so a test can model a native
that is slow to answer. Later enqueues wait behind it, as they do on a
device.

Added `useRequests(filter?)` and `useRequestsWithProgress(filter?)`, React
hooks that return the queue rows for display, and
`definition.useRequests()` and `definition.useRequestsWithProgress()`, which
return one definition's rows with typed `vars`. The client keeps one copy of
the rows in memory, read from native when the first hook mounts and updated
by each `state` event. It also removes a row when native forgets it with no
event: after the ack of a `completed` or `cancelled` outcome, and after
`cancel()` on a settled entry. A `state` event that arrives after the copy
removed its row is checked against native, so it does not add a finished row
back. The filter takes `includeCancelled`; cancelled rows are left out by
default. `addRequestsListener()` and `getCachedRequests()` give the same rows
outside React. Completed rows are included until their outcome is
acknowledged. An app that keeps its own copy of `getRequests()` for display,
and wraps `cancel()` to update it, can remove that code. Delivery, acks and
cancel are unchanged. `define()` now returns the two hooks with `key` and
`mutate`. The `Defined` and `UploadClient` types gained the new functions as
required members, so a test double built by hand with one of those types no
longer compiles until it adds them.

`createFakeNative().settle()` now resolves one promise turn later, after the
client has handled the ack, so the row hooks are up to date when it resolves.

## 11.1.0

Added `takeDiagnostics()`. On Android it returns the module's recent
Expand Down
93 changes: 92 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ carries the last one.
`createUploadClient()` returns a client. The default export is one client;
an app needs one.

### `define(definition): { key, mutate }`
### `define(definition): { key, mutate, useRequests, useRequestsWithProgress }`

```ts
type Definition<V extends object | null, T> =
Expand Down Expand Up @@ -484,6 +484,89 @@ uploads
.forEach((row) => uploads.cancel(row.id));
```

### `useRequests(filter?)` and `useRequestsWithProgress(filter?)`
React hooks that return the queue rows, for display. The client keeps one
copy of the rows in memory. It reads native when the first hook mounts, and
each `state` event updates the row it carries. Every component shares that
copy, so the app does not keep its own.

```tsx
// Take the hooks out of the client and the definition at module level. The
// hooks lint rule checks `X.useY()` only when `X` starts with a capital
// letter, so `uploads.useRequests()` inside a condition is not flagged.
// `useRequests()` called as a plain name is checked.
const { useRequests } = uploads;
const { useRequests: useCaptureRows } = captureUpload;

const CaptureBadge = ({ captureId }: { captureId: string }) => {
// Typed vars: the rows of this definition only.
const rows = useCaptureRows();
const pending = rows.filter(
(row) => row.vars.captureId === captureId && row.state !== 'completed',
);
return pending.length > 0 ? <Badge count={pending.length} /> : null;
};
```

- `filter` is `{ key?, id?, includeCancelled? }`. `key` and `id` match as in
`getRequests()`. Cancelled rows are left out unless `includeCancelled` is
`true`. A cancelled row has no work left, and native forgets it at its ack.
- Completed rows are included. A completed row stays until its outcome is
acknowledged. When its handler rejects or its key has no definition, the
outcome is not acknowledged, so the row stays for the whole session. Do not
treat a completed row as work still to do.
- `useRequests()` re-renders when a row appears, changes state, or goes. A
row's bytes are those of its last `state` event.
- `useRequestsWithProgress()` also re-renders on each `progress` event of a
row it returns, with that event's bytes. Use it only where bytes are shown.
- The result is the same array while the rows it holds are the same, even
when the filter is a new object each render. A component filtered by `id`
re-renders only when that row changes. The array and its rows are shared,
so do not change them.
- `definition.useRequests(filter?)` and `definition.useRequestsWithProgress(filter?)`
return the rows of that definition's key, with `vars` typed as its vars.
The type is not checked at run time: `vars` is what native stored, so a
`Date` reads back as a string, and a row enqueued under an older vars type
keeps its old shape.

Native emits no event when it forgets a row, so the library removes the row
itself when its own call made native forget it: when the ack of a
`completed` or `cancelled` outcome resolves, and when `cancel(id)` resolves
on a settled entry. An `error` row stays until `cancel()` or a same-id
`mutate()`, as in native. A same-id `mutate()` emits a `state` event, so it
needs nothing extra. iOS can settle a live row during an ack with no event;
after any ack whose row the copy does not show in the outcome's state, the
library reads that row from native. It does the same after `cancel(id)` on a
row the copy shows live or `cancelled`. Events and call results can reach JS
in either order, so a `state` event can arrive after the library removed its
row (Android emits a run's outcome before its last `state` event). The
library keeps the last 256 ids it removed this way, and checks a `state`
event for one of them against native instead of adding the row back. Past
256 ids the result is the same: a settled `state` event for a row the copy
does not hold is also checked, at the cost of one native read. A same-id
`mutate()` of a kept id costs one native read.

Limits. While no hook or listener is mounted, the copy is not updated. Each
time the first hook or listener subscribes, the library checks the copy
against native, so a mount can show old rows for one render. When the
native read fails, the library keeps the rows it has and sends a warning to
`onWarning`. Any other change that native makes with no event is missed
until the next check.

### `addRequestsListener(listener)` and `getCachedRequests(filter?)`
The same rows outside React, for code such as a store or a logger.
`addRequestsListener` returns `{ remove }` and calls `listener` with
`getCachedRequests()` each time those rows change. There is no call when it
registers, so read `getCachedRequests()` after it returns. It keeps the copy
up to date as a mounted hook does.

`getCachedRequests(filter?)` returns the rows the hooks see. While nothing
listens, it reads native on each call and returns a new array. While a hook
or listener is mounted and the filter has no `key` or `id`, it returns the
same array until a row changes. A result filtered by `key` or `id` is a new
array on each call, so do not use it as a `useSyncExternalStore` snapshot;
use the hooks.

### `takeDiagnostics(): Promise<Diagnostic[]>`
The library's persisted diagnostic records, oldest first. Each call returns
the records since the last call and clears them, so send them somewhere
Expand Down Expand Up @@ -707,6 +790,14 @@ The fake does not model the same-id rules (resume, replace, `E_RUNNING`,
re-emit) or the header merge of `updateHeaders()`; script those with
`failNext()` and `seedUnacknowledged()`.

The fake drives the row hooks as native does. `settle()` resolves after the
client has handled the ack, so a completed row is already gone from
`useRequests()`. `seedRows()` and `reset()` change rows with no event, as
leftovers from a dead session would. Call them before the first hook mounts
or listener subscribes, or unmount and mount again: the next mount checks the
rows against the fake. The hooks need a React renderer, for example
`react-test-renderer`, and its updates wrapped in `act()`.

`native.entries` holds every enqueue as `mutate()` sent it, with `vars` and
`data` parsed back from JSON. `failNext()` makes the next native call reject
with a code such as `E_STORAGE`. `holdNext('enqueue')` keeps the next enqueue
Expand Down
247 changes: 247 additions & 0 deletions example/RNBGUExample/__tests__/useRequests.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* @format
*/

import 'react-native';
import React from 'react';

// Note: import explicitly to use the types shipped with jest.
import {beforeEach, expect, it, jest} from '@jest/globals';

// Note: test renderer must be required after react-native.
import renderer, {act, type ReactTestRenderer} from 'react-test-renderer';

import {
createUploadClient,
type RequestRow,
type RequestsFilter,
type UploadClient,
} from 'react-native-background-upload';
import {createFakeNative, type FakeNative} from '../../../src/testing';

// The package root builds a default client over the TurboModule at load.
jest.mock('../../../src/NativeRNFileUploader', () => ({
__esModule: true,
default: jest
.requireActual<typeof import('../../../src/testing')>(
'../../../src/testing',
)
.createFakeNative(),
}));

const row = (overrides: Partial<RequestRow> = {}): RequestRow => ({
id: 'a',
key: 'k',
vars: null,
state: 'queued',
bytesSent: 0,
totalBytes: 100,
attempts: 0,
updatedAt: 1,
...overrides,
});

let native: FakeNative;
let client: UploadClient;

beforeEach(() => {
native = createFakeNative();
client = createUploadClient({native});
client.configure({});
});

type Seen = {renders: number; results: Array<readonly RequestRow[]>};

const Rows = ({
seen,
filter,
progress = false,
}: {
seen: Seen;
filter?: RequestsFilter;
progress?: boolean;
}) => {
const rows = progress
? client.useRequestsWithProgress(filter)
: client.useRequests(filter);
seen.renders += 1;
seen.results.push(rows);
return null;
};

const newSeen = (): Seen => ({renders: 0, results: []});
const last = (seen: Seen) => seen.results[seen.results.length - 1]!;

const mount = (element: React.ReactElement): ReactTestRenderer => {
let tree!: ReactTestRenderer;
act(() => {
tree = renderer.create(element);
});
return tree;
};

it('reads native at the first render and at the first subscribe, not per hook', () => {
native.seedRows([row()]);
const getRequests = jest.spyOn(native, 'getRequests');
const one = newSeen();
const two = newSeen();
mount(
<>
<Rows seen={one} />
<Rows seen={two} progress />
</>,
);
// One read for the render, one check when the first hook subscribes.
expect(getRequests).toHaveBeenCalledTimes(2);
expect(last(one)).toEqual([row()]);
expect(last(two)).toEqual([row()]);
// The check found nothing new, so no hook rendered again.
expect(one.renders).toBe(1);
});

it('works under StrictMode, which subscribes twice', () => {
native.seedRows([row()]);
const seen = newSeen();
const tree = mount(
<React.StrictMode>
<Rows seen={seen} progress />
</React.StrictMode>,
);
expect(native.listenerCount('state')).toBe(1);
expect(native.listenerCount('progress')).toBe(1);
act(() => native.emit.progress({id: 'a', bytesSent: 7, totalBytes: 100}));
expect(last(seen)[0]!.bytesSent).toBe(7);
act(() => native.emit.state(row({state: 'running', updatedAt: 2})));
expect(last(seen)[0]!.state).toBe('running');
act(() => tree.unmount());
expect(native.listenerCount('state')).toBe(0);
expect(native.listenerCount('progress')).toBe(0);
});

it('follows a filter whose value changes', () => {
native.seedRows([row({id: 'a'}), row({id: 'b', key: 'other'})]);
const seen = newSeen();
const tree = mount(<Rows seen={seen} filter={{id: 'a'}} />);
expect(last(seen).map(r => r.id)).toEqual(['a']);
act(() => tree.update(<Rows seen={seen} filter={{id: 'b'}} />));
expect(last(seen).map(r => r.id)).toEqual(['b']);
act(() => tree.update(<Rows seen={seen} filter={{key: 'k'}} />));
expect(last(seen).map(r => r.id)).toEqual(['a']);
act(() => native.emit.state(row({id: 'b', key: 'other', state: 'running'})));
expect(last(seen).map(r => r.id)).toEqual(['a']);
});

it('re-renders on a state event, and only the progress hook on progress', () => {
native.seedRows([row()]);
const plain = newSeen();
const withProgress = newSeen();
mount(
<>
<Rows seen={plain} />
<Rows seen={withProgress} progress />
</>,
);
const plainRenders = plain.renders;
const progressRenders = withProgress.renders;

act(() => native.emit.progress({id: 'a', bytesSent: 40, totalBytes: 100}));
expect(plain.renders).toBe(plainRenders);
expect(withProgress.renders).toBe(progressRenders + 1);
expect(last(withProgress)[0]!.bytesSent).toBe(40);
expect(last(plain)[0]!.bytesSent).toBe(0);

act(() => native.emit.state(row({state: 'running', updatedAt: 2})));
expect(plain.renders).toBe(plainRenders + 1);
expect(last(plain)[0]!.state).toBe('running');
expect(last(withProgress)[0]!.state).toBe('running');
});

it('returns the same filtered array across renders while its rows hold', () => {
native.seedRows([row({id: 'a'}), row({id: 'b'})]);
const seen = newSeen();
// A new filter object each render, as an inline literal is.
const tree = mount(<Rows seen={seen} filter={{id: 'a'}} />);
act(() => tree.update(<Rows seen={seen} filter={{id: 'a'}} />));
expect(seen.renders).toBe(2);
expect(seen.results[1]).toBe(seen.results[0]);

// A change to another row does not re-render this one.
act(() => native.emit.state(row({id: 'b', state: 'running'})));
expect(seen.renders).toBe(2);

act(() => native.emit.state(row({id: 'a', state: 'running'})));
expect(seen.renders).toBe(3);
expect(last(seen)).toEqual([row({id: 'a', state: 'running'})]);
});

it('drops a completed row once its outcome is acknowledged', async () => {
const ping = client.define({
key: 'ping',
request: () => ({url: 'https://api.test/ping'}),
});
const seen = newSeen();
mount(<Rows seen={seen} />);
let id = '';
await act(async () => {
({id} = await ping.mutate());
});
expect(last(seen)).toEqual([expect.objectContaining({id, state: 'queued'})]);
await act(async () => {
await native.settle(id, {kind: 'completed'});
});
expect(last(seen)).toEqual([]);
});

it('drops a settled row when cancel() resolves, and hides cancelled rows', async () => {
native.seedRows([
row({id: 'e', state: 'error'}),
row({id: 'c', state: 'cancelled'}),
]);
const visible = newSeen();
const all = newSeen();
mount(
<>
<Rows seen={visible} />
<Rows seen={all} filter={{includeCancelled: true}} />
</>,
);
expect(last(visible).map(r => r.id)).toEqual(['e']);
expect(last(all).map(r => r.id)).toEqual(['e', 'c']);
await act(async () => {
await client.cancel('e');
});
expect(last(visible)).toEqual([]);
expect(last(all).map(r => r.id)).toEqual(['c']);
});

it('re-checks native when a hook mounts again', () => {
native.seedRows([row({id: 'a'})]);
const first = newSeen();
const tree = mount(<Rows seen={first} />);
act(() => tree.unmount());
// A change with no event while nothing is mounted.
native.seedRows([row({id: 'b'})]);
const second = newSeen();
mount(<Rows seen={second} />);
expect(last(second).map(r => r.id)).toEqual(['a', 'b']);
});

it('types the vars of a definition', () => {
type Vars = {captureId: string};
const upload = client.define({
key: 'capture',
request: (_vars: Vars) => ({url: 'https://api.test/c'}),
});
native.seedRows([
row({id: 'x', key: 'capture', vars: {captureId: 'c1'}}),
row({id: 'y', key: 'other'}),
]);
const ids: string[][] = [];
const Captures = () => {
const rows = upload.useRequests();
ids.push(rows.map(r => r.vars.captureId));
return null;
};
mount(<Captures />);
expect(ids[ids.length - 1]).toEqual(['c1']);
});
Loading
Loading