Skip to content

Commit fe8a35f

Browse files
simonhampclaude
andcommitted
docs(mobile/4): document async tasks
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f957b4b commit fe8a35f

1 file changed

Lines changed: 254 additions & 0 deletions

File tree

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
---
2+
title: Async Tasks
3+
order: 240
4+
---
5+
6+
## Overview
7+
8+
Some work is too slow to do while the user waits — building a report, resizing an image, hitting a slow API.
9+
Run it inline in an event handler and the screen freezes: your component's runloop is busy, so nothing
10+
re-renders until the work finishes.
11+
12+
Async tasks move that work onto a **separate PHP thread** and hand you the result back on the UI thread, where
13+
you can update state and let the screen re-render:
14+
15+
```php
16+
use Native\Mobile\AsyncTask;
17+
18+
public function generateReport(): void
19+
{
20+
$this->generating = true; // paints immediately — spinner shows
21+
22+
AsyncTask::dispatch(static function () {
23+
return ExpensiveReport::build()->toArray();
24+
})->finished(function (array $report) {
25+
$this->report = $report;
26+
$this->generating = false;
27+
});
28+
}
29+
```
30+
31+
`dispatch()` returns straight away. Your handler finishes, the screen re-renders with the spinner, and the
32+
task runs on its own PHP interpreter. When it completes, `finished()` fires **on the UI thread**, bound to your
33+
live component — so `$this->report = ...` works exactly like it does in any other handler, and the screen
34+
updates.
35+
36+
<aside>
37+
38+
Async tasks are **not** queued jobs. They start immediately, run concurrently, and never touch your database or
39+
the queue. If you want durable, retryable background work that survives an app restart, use [Queues](queues)
40+
instead.
41+
42+
</aside>
43+
44+
## The work closure must be static
45+
46+
The work runs in a **different PHP interpreter** with its own memory. It cannot see `$this`, your component's
47+
properties, or anything else from the dispatching thread — so the closure must be declared `static`:
48+
49+
```php
50+
// ✅ Static — nothing captured from the component
51+
AsyncTask::dispatch(static fn () => Report::build());
52+
53+
// ❌ Throws InvalidArgumentException at dispatch time
54+
AsyncTask::dispatch(fn () => Report::build($this->month));
55+
```
56+
57+
A non-static closure throws immediately, in your handler, where you can see it — not silently in a background
58+
log.
59+
60+
Pass data in by capturing plain serializable values with `use`:
61+
62+
```php
63+
$month = $this->month;
64+
65+
AsyncTask::dispatch(static fn () => Report::build($month));
66+
```
67+
68+
Everything the closure captures must be serializable. Resources, PDO handles, and open file pointers can't
69+
cross the thread boundary and will throw.
70+
71+
## Handling results
72+
73+
### finished()
74+
75+
Fires with whatever the work returned:
76+
77+
```php
78+
AsyncTask::dispatch(static fn () => Http::get('https://api.example.com/stats')->json())
79+
->finished(function (array $stats) {
80+
$this->stats = $stats;
81+
});
82+
```
83+
84+
The result travels back as JSON, so return **scalars and arrays**. For anything large — an image, a PDF, a big
85+
export — write it to disk in the task and return the path:
86+
87+
```php
88+
AsyncTask::dispatch(static function () {
89+
$path = storage_path('app/report-'.now()->timestamp.'.pdf');
90+
Report::build()->save($path);
91+
92+
return $path; // ✅ return a path, not the file contents
93+
})->finished(fn (string $path) => $this->reportPath = $path);
94+
```
95+
96+
### failed()
97+
98+
If the task throws, `failed()` receives an `AsyncTaskException` carrying the original message and class:
99+
100+
```php
101+
AsyncTask::dispatch(static fn () => Http::get($url)->json())
102+
->finished(function (array $data) {
103+
$this->data = $data;
104+
$this->loading = false;
105+
})
106+
->failed(function (\Throwable $e) {
107+
$this->error = $e->getMessage();
108+
$this->loading = false;
109+
});
110+
```
111+
112+
The original exception object can't cross threads, so you get a stand-in. `getMessage()` returns the original
113+
message; `originalClass()` gives you the class name it was thrown as.
114+
115+
<aside>
116+
117+
Async tasks run **once**. There's no automatic retry — silently re-running a button press is rarely what you
118+
want. If a task should retry, handle it in `failed()` or reach for a queued job.
119+
120+
</aside>
121+
122+
## Callbacks are scoped to the screen
123+
124+
By default, a `finished()` / `failed()` callback only fires if the screen that dispatched it is **still the one
125+
on top**. If the user navigated away before the task completed, the callback is dropped.
126+
127+
That's deliberate: the callback is bound to a live component instance so it can mutate state, and firing it
128+
against a screen the user has left would update something nobody is looking at — or worse, the wrong screen.
129+
130+
For a fire-and-forget task whose result matters regardless of where the user is — a background upload feeding a
131+
status bar, a sync that refreshes a badge — use `shared()`:
132+
133+
```php
134+
AsyncTask::dispatch(static fn () => Sync::run())
135+
->shared('sync-complete');
136+
```
137+
138+
`shared()` delivers the result as a **named event** instead of a scoped callback. Any active screen can pick it
139+
up with `#[On]`:
140+
141+
```php
142+
use Native\Mobile\Attributes\On;
143+
144+
#[On('sync-complete')]
145+
public function syncComplete($event): void
146+
{
147+
$this->lastSync = $event->result;
148+
}
149+
```
150+
151+
The event payload carries `id`, a `status` of `finished` or `failed`, and either `result` or the failure
152+
details.
153+
154+
## Running several at once
155+
156+
Tasks run on a small pool of background PHP contexts, so several can be in flight together:
157+
158+
```php
159+
public function loadDashboard(): void
160+
{
161+
AsyncTask::dispatch(static fn () => Stats::revenue())
162+
->finished(fn ($r) => $this->revenue = $r);
163+
164+
AsyncTask::dispatch(static fn () => Stats::orders())
165+
->finished(fn ($o) => $this->orders = $o);
166+
}
167+
```
168+
169+
Each callback fires as its own task completes — there's no ordering guarantee between them. When more tasks are
170+
dispatched than the pool has slots, the extras queue up and run as slots free.
171+
172+
## Task classes
173+
174+
For anything you'd rather not write inline, extend `AsyncTask` and put the work in `handle()`:
175+
176+
```php
177+
namespace App\Async;
178+
179+
use Native\Mobile\AsyncTask;
180+
181+
class BuildReport extends AsyncTask
182+
{
183+
public function handle(int $month): array
184+
{
185+
return Report::forMonth($month)->toArray();
186+
}
187+
}
188+
```
189+
190+
Dispatch it with its arguments — they're passed to `handle()` in the background context:
191+
192+
```php
193+
use App\Async\BuildReport;
194+
195+
BuildReport::dispatch($this->month)
196+
->finished(fn (array $report) => $this->report = $report);
197+
```
198+
199+
The arguments must be serializable, same as a closure's captures. This form reads like a Laravel job, but it's
200+
still an async task — no queue, no database, no retries.
201+
202+
## Testing
203+
204+
`AsyncTask::fake()` runs tasks **inline and synchronously**, so your `finished()` and `failed()` callbacks fire
205+
during the test with no threads involved:
206+
207+
```php
208+
use Native\Mobile\AsyncTask;
209+
210+
it('loads the report', function () {
211+
AsyncTask::fake();
212+
213+
Native::test(ReportScreen::class)
214+
->tap('generateReport')
215+
->assertSee('Revenue');
216+
});
217+
```
218+
219+
The fake also records every dispatch:
220+
221+
```php
222+
$fake = AsyncTask::fake();
223+
224+
Native::test(ReportScreen::class)->tap('generateReport');
225+
226+
$fake->assertDispatched()
227+
->assertDispatchedTimes(1);
228+
```
229+
230+
Available assertions: `assertDispatched()` (optionally with a filter closure), `assertNotDispatched()`,
231+
`assertDispatchedTimes()`, and `assertShared('alias')`.
232+
233+
## How It Works
234+
235+
When you dispatch a task, the work closure is serialized and handed to a background PHP context — a full PHP
236+
interpreter on its own thread, with its own memory, booted once and reused. Your handler returns immediately and
237+
the screen re-renders.
238+
239+
When the task finishes, the background context sends the result through the same native event channel that
240+
delivers camera results and push notifications. That wakes your screen's runloop, which resolves the matching
241+
callback, binds it to the live component, and runs it — then re-renders.
242+
243+
This is why the work closure is isolated but the callbacks aren't: the work runs on the background thread, and
244+
the callbacks run on your UI thread, in your component.
245+
246+
## Things to Note
247+
248+
- The work closure must be `static` and everything it captures must be serializable.
249+
- Results round-trip as JSON — return scalars and arrays; pass large data by file path or cache key.
250+
- Tasks run once, with no automatic retry.
251+
- Scoped callbacks are dropped if the user navigates away; use `shared()` when the result matters regardless.
252+
- Async tasks don't survive the app being killed. For durable background work, use [Queues](queues).
253+
- Device APIs that need the UI (camera, dialogs, biometrics) don't belong inside a task — the work runs off the
254+
UI thread with no screen attached. Fetch and compute in the task; drive UI from the callback.

0 commit comments

Comments
 (0)