diff --git a/resources/views/docs/mobile/4/digging-deeper/async-tasks.md b/resources/views/docs/mobile/4/digging-deeper/async-tasks.md new file mode 100644 index 00000000..1b7ed55b --- /dev/null +++ b/resources/views/docs/mobile/4/digging-deeper/async-tasks.md @@ -0,0 +1,254 @@ +--- +title: Async Tasks +order: 240 +--- + +## Overview + +Some work is too slow to do while the user waits — building a report, resizing an image, hitting a slow API. +Run it inline in an event handler and the screen freezes: your component's runloop is busy, so nothing +re-renders until the work finishes. + +Async tasks move that work onto a **separate PHP thread** and hand you the result back on the UI thread, where +you can update state and let the screen re-render: + +```php +use Native\Mobile\AsyncTask; + +public function generateReport(): void +{ + $this->generating = true; // paints immediately — spinner shows + + AsyncTask::dispatch(static function () { + return ExpensiveReport::build()->toArray(); + })->finished(function (array $report) { + $this->report = $report; + $this->generating = false; + }); +} +``` + +`dispatch()` returns straight away. Your handler finishes, the screen re-renders with the spinner, and the +task runs on its own PHP interpreter. When it completes, `finished()` fires **on the UI thread**, bound to your +live component — so `$this->report = ...` works exactly like it does in any other handler, and the screen +updates. + + + +## The work closure must be static + +The work runs in a **different PHP interpreter** with its own memory. It cannot see `$this`, your component's +properties, or anything else from the dispatching thread — so the closure must be declared `static`: + +```php +// ✅ Static — nothing captured from the component +AsyncTask::dispatch(static fn () => Report::build()); + +// ❌ Throws InvalidArgumentException at dispatch time +AsyncTask::dispatch(fn () => Report::build($this->month)); +``` + +A non-static closure throws immediately, in your handler, where you can see it — not silently in a background +log. + +Pass data in by capturing plain serializable values with `use`: + +```php +$month = $this->month; + +AsyncTask::dispatch(static fn () => Report::build($month)); +``` + +Everything the closure captures must be serializable. Resources, PDO handles, and open file pointers can't +cross the thread boundary and will throw. + +## Handling results + +### finished() + +Fires with whatever the work returned: + +```php +AsyncTask::dispatch(static fn () => Http::get('https://api.example.com/stats')->json()) + ->finished(function (array $stats) { + $this->stats = $stats; + }); +``` + +The result travels back as JSON, so return **scalars and arrays**. For anything large — an image, a PDF, a big +export — write it to disk in the task and return the path: + +```php +AsyncTask::dispatch(static function () { + $path = storage_path('app/report-'.now()->timestamp.'.pdf'); + Report::build()->save($path); + + return $path; // ✅ return a path, not the file contents +})->finished(fn (string $path) => $this->reportPath = $path); +``` + +### failed() + +If the task throws, `failed()` receives an `AsyncTaskException` carrying the original message and class: + +```php +AsyncTask::dispatch(static fn () => Http::get($url)->json()) + ->finished(function (array $data) { + $this->data = $data; + $this->loading = false; + }) + ->failed(function (\Throwable $e) { + $this->error = $e->getMessage(); + $this->loading = false; + }); +``` + +The original exception object can't cross threads, so you get a stand-in. `getMessage()` returns the original +message; `originalClass()` gives you the class name it was thrown as. + + + +## Callbacks are scoped to the screen + +By default, a `finished()` / `failed()` callback only fires if the screen that dispatched it is **still the one +on top**. If the user navigated away before the task completed, the callback is dropped. + +That's deliberate: the callback is bound to a live component instance so it can mutate state, and firing it +against a screen the user has left would update something nobody is looking at — or worse, the wrong screen. + +For a fire-and-forget task whose result matters regardless of where the user is — a background upload feeding a +status bar, a sync that refreshes a badge — use `shared()`: + +```php +AsyncTask::dispatch(static fn () => Sync::run()) + ->shared('sync-complete'); +``` + +`shared()` delivers the result as a **named event** instead of a scoped callback. Any active screen can pick it +up with `#[On]`: + +```php +use Native\Mobile\Attributes\On; + +#[On('sync-complete')] +public function syncComplete($event): void +{ + $this->lastSync = $event->result; +} +``` + +The event payload carries `id`, a `status` of `finished` or `failed`, and either `result` or the failure +details. + +## Running several at once + +Tasks run on a small pool of background PHP contexts, so several can be in flight together: + +```php +public function loadDashboard(): void +{ + AsyncTask::dispatch(static fn () => Stats::revenue()) + ->finished(fn ($r) => $this->revenue = $r); + + AsyncTask::dispatch(static fn () => Stats::orders()) + ->finished(fn ($o) => $this->orders = $o); +} +``` + +Each callback fires as its own task completes — there's no ordering guarantee between them. When more tasks are +dispatched than the pool has slots, the extras queue up and run as slots free. + +## Task classes + +For anything you'd rather not write inline, extend `AsyncTask` and put the work in `handle()`: + +```php +namespace App\Async; + +use Native\Mobile\AsyncTask; + +class BuildReport extends AsyncTask +{ + public function handle(int $month): array + { + return Report::forMonth($month)->toArray(); + } +} +``` + +Dispatch it with its arguments — they're passed to `handle()` in the background context: + +```php +use App\Async\BuildReport; + +BuildReport::dispatch($this->month) + ->finished(fn (array $report) => $this->report = $report); +``` + +The arguments must be serializable, same as a closure's captures. This form reads like a Laravel job, but it's +still an async task — no queue, no database, no retries. + +## Testing + +`AsyncTask::fake()` runs tasks **inline and synchronously**, so your `finished()` and `failed()` callbacks fire +during the test with no threads involved: + +```php +use Native\Mobile\AsyncTask; + +it('loads the report', function () { + AsyncTask::fake(); + + Native::test(ReportScreen::class) + ->tap('generateReport') + ->assertSee('Revenue'); +}); +``` + +The fake also records every dispatch: + +```php +$fake = AsyncTask::fake(); + +Native::test(ReportScreen::class)->tap('generateReport'); + +$fake->assertDispatched() + ->assertDispatchedTimes(1); +``` + +Available assertions: `assertDispatched()` (optionally with a filter closure), `assertNotDispatched()`, +`assertDispatchedTimes()`, and `assertShared('alias')`. + +## How It Works + +When you dispatch a task, the work closure is serialized and handed to a background PHP context — a full PHP +interpreter on its own thread, with its own memory, booted once and reused. Your handler returns immediately and +the screen re-renders. + +When the task finishes, the background context sends the result through the same native event channel that +delivers camera results and push notifications. That wakes your screen's runloop, which resolves the matching +callback, binds it to the live component, and runs it — then re-renders. + +This is why the work closure is isolated but the callbacks aren't: the work runs on the background thread, and +the callbacks run on your UI thread, in your component. + +## Things to Note + +- The work closure must be `static` and everything it captures must be serializable. +- Results round-trip as JSON — return scalars and arrays; pass large data by file path or cache key. +- Tasks run once, with no automatic retry. +- Scoped callbacks are dropped if the user navigates away; use `shared()` when the result matters regardless. +- Async tasks don't survive the app being killed. For durable background work, use [Queues](queues). +- Device APIs that need the UI (camera, dialogs, biometrics) don't belong inside a task — the work runs off the + UI thread with no screen attached. Fetch and compute in the task; drive UI from the callback.