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 000000000..c187201d1 --- /dev/null +++ b/resources/views/docs/mobile/4/digging-deeper/async-tasks.md @@ -0,0 +1,256 @@ +--- +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. +If you run it inline and it takes too long, your user's screen freezes because the UI thread is busy, so +nothing re-renders until the work finishes and the user can't do anything about it. + +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 thread** 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` or a shorthand closure: + +```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()` + +When the background thread is done, the `finished` callback fires: + +```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 and exception, `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, `finished()` and `failed()` callbacks only fire if the screen that dispatched them 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, something in a +completely different component. + +For tasks whose results matter regardless of where the user is — e.g. 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 for, the extras queue up and run as slots free up. This helps to limit +overall memory consumption. + +## 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. + +## 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 +that NativePHP uses to wakes your screen's runloop, which then resolves the matching callback, binds it +to the live component, and runs it. And then your component 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. + +## Remember! + +- 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 from the screen that started the work; + use `shared()` when the result matters regardless of what screen the user moves to. +- 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 async tasks. Fetch and compute + in the task; drive UI from the callbacks.