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