diff --git a/app/NativeComponents/AsyncTaskDemo.php b/app/NativeComponents/AsyncTaskDemo.php new file mode 100644 index 0000000..ca90c15 --- /dev/null +++ b/app/NativeComponents/AsyncTaskDemo.php @@ -0,0 +1,240 @@ + delay(ms). Staggered so results land out of dispatch order. */ + public array $parallelDelaysMs = ['orders' => 3000, 'revenue' => 1000, 'signups' => 2000]; + + // 1. finished() + public bool $reportRunning = false; + + public ?string $reportResult = null; + + public ?float $reportStartedAt = null; + + public ?float $reportTookMs = null; + + // 2. failed() + public bool $failRunning = false; + + public ?string $failMessage = null; + + public ?string $failClass = null; + + // 3. concurrency — three independent tasks + public array $parallel = []; + + public bool $parallelRunning = false; + + public ?float $parallelStartedAt = null; + + // 4. shared() + public ?string $sharedResult = null; + + public bool $sharedRunning = false; + + // 5. static-closure guard + public ?string $guardMessage = null; + + public function navTitle(): string + { + return 'Async Tasks'; + } + + // ── 1. finished() ─────────────────────────────── + + public function runReport(): void + { + $this->reportRunning = true; + $this->reportResult = null; + $this->reportTookMs = null; + $this->reportStartedAt = microtime(true); + + $delayMs = $this->reportDelayMs; + + AsyncTask::dispatch(static function () use ($delayMs) { + // Pretend this is an expensive build — a real report, an image + // resize, a slow upstream API. Runs on its own PHP interpreter. + usleep($delayMs * 1000); + + return 'Revenue £'.number_format(random_int(10_000, 99_999)); + })->finished(function (string $result) { + $this->reportResult = $result; + $this->reportTookMs = round((microtime(true) - $this->reportStartedAt) * 1000); + $this->reportRunning = false; + })->failed(function (\Throwable $e) { + $this->reportResult = 'Failed: '.$e->getMessage(); + $this->reportRunning = false; + }); + } + + // ── 2. failed() ───────────────────────────────── + + public function runFailing(): void + { + $this->failRunning = true; + $this->failMessage = null; + $this->failClass = null; + + $delayMs = $this->failDelayMs; + + AsyncTask::dispatch(static function () use ($delayMs) { + usleep($delayMs * 1000); + + throw new \RuntimeException('Upstream API returned 503'); + })->finished(function () { + // Never reached — kept to show both callbacks can coexist. + $this->failMessage = 'unexpectedly succeeded'; + $this->failRunning = false; + })->failed(function (\Throwable $e) { + $this->failMessage = $e->getMessage(); + // AsyncTaskException carries the class it was originally thrown as. + $this->failClass = method_exists($e, 'originalClass') + ? $e->originalClass() + : $e::class; + $this->failRunning = false; + }); + } + + // ── 3. Concurrency ────────────────────────────── + + public function runParallel(): void + { + $this->parallelRunning = true; + $this->parallelStartedAt = microtime(true); + $this->parallel = []; + + // Deliberately staggered sleeps: results should land in duration order + // (fast → slow), NOT dispatch order — that's the proof they ran + // concurrently rather than queueing behind each other. + foreach ($this->parallelDelaysMs as $label => $delayMs) { + AsyncTask::dispatch(static function () use ($label, $delayMs) { + usleep($delayMs * 1000); + + return ucfirst($label).': '.random_int(100, 999); + })->finished(function (string $result) use ($label) { + $this->parallel[] = [ + 'label' => $label, + 'result' => $result, + 'at' => round((microtime(true) - $this->parallelStartedAt) * 1000), + ]; + + if (count($this->parallel) === 3) { + $this->parallelRunning = false; + } + }); + } + } + + // ── 4. shared() ───────────────────────────────── + + /** + * `shared()` delivers the result as a NAMED EVENT rather than a + * screen-scoped callback, so it fires no matter which screen is showing. + * Navigate away mid-flight and back — with a plain finished() the result + * would be dropped; this one still arrives. + */ + public function runShared(): void + { + $this->sharedRunning = true; + $this->sharedResult = null; + + $delayMs = $this->sharedDelayMs; + + AsyncTask::dispatch(static function () use ($delayMs) { + usleep($delayMs * 1000); + + return 'synced at '.now()->format('H:i:s'); + })->shared('demo-sync-complete'); + } + + #[On('demo-sync-complete')] + public function syncComplete($event): void + { + $this->sharedResult = ($event->status ?? 'finished') === 'failed' + ? 'failed: '.($event->message ?? 'unknown') + : (string) ($event->result ?? '—'); + $this->sharedRunning = false; + } + + // ── 5. Static-closure guard ───────────────────── + + /** + * The work closure runs in another interpreter, so it can't capture $this. + * A non-static closure is rejected at dispatch time — in the handler, where + * you can see it — rather than failing silently in a background log. + */ + public function tryBoundClosure(): void + { + try { + // NOT static — captures $this. This is the mistake the guard catches. + AsyncTask::dispatch(function () { + return $this->taps; + }); + + $this->guardMessage = 'No exception — the guard did not fire!'; + } catch (\InvalidArgumentException $e) { + $this->guardMessage = $e->getMessage(); + } + } + + // ── UI ────────────────────────────────────────── + + public function tap(): void + { + $this->taps++; + } + + public function reset(): void + { + $this->taps = 0; + $this->reportResult = null; + $this->reportTookMs = null; + $this->failMessage = null; + $this->failClass = null; + $this->parallel = []; + $this->sharedResult = null; + $this->guardMessage = null; + } + + public function render(): View + { + return view('native.async-task-demo'); + } +} diff --git a/app/NativeComponents/DemoLauncher.php b/app/NativeComponents/DemoLauncher.php index 5784fc4..1024055 100644 --- a/app/NativeComponents/DemoLauncher.php +++ b/app/NativeComponents/DemoLauncher.php @@ -44,6 +44,7 @@ class DemoLauncher extends NativeComponent ['id' => 'mail', 'title' => 'Mail Inbox', 'subtitle' => 'Pull-to-refresh + leading/trailing swipe actions', 'icon' => 'envelope.fill', 'color' => '#0EA5E9', 'url' => '/mail-demo'], ['id' => 'refresh', 'title' => 'Pull to refresh', 'subtitle' => 'Native pull-to-refresh on custom card content', 'icon' => 'arrow.clockwise', 'color' => '#10B981', 'url' => '/refreshable-demo'], ['id' => 'reactivity', 'title' => 'Reactivity', 'subtitle' => '#[Computed], #[Poll] and #[Lazy] placeholder in one screen', 'icon' => 'bolt.fill', 'color' => '#8B5CF6', 'url' => '/reactivity'], + ['id' => 'asynctasks', 'title' => 'Async Tasks', 'subtitle' => 'Background PHP thread + finished/failed/shared callbacks', 'icon' => 'timer', 'color' => '#F97316', 'url' => '/async-tasks'], ['id' => 'webview', 'title' => 'Webview', 'subtitle' => 'Embedded web content — remote URL + inline HTML, @navigated events', 'icon' => 'globe', 'color' => '#3B82F6', 'url' => '/webview-demo'], // ['id' => 'eventchannel', 'title' => 'Event Channel Test', 'subtitle' => 'Native → PHP payload > 4KB (growable event buffer)', 'icon' => 'arrow.up.arrow.down', 'color' => '#EF4444', 'url' => '/event-channel-test'], // ['id' => 'vibe', 'title' => 'Vibe — Live Events', 'subtitle' => 'Websocket broadcast events (Vask/Reverb) into a component', 'icon' => 'antenna.radiowaves.left.and.right', 'color' => '#22C55E', 'url' => '/vibe'], diff --git a/resources/views/native/async-task-demo.blade.php b/resources/views/native/async-task-demo.blade.php new file mode 100644 index 0000000..0e4cca9 --- /dev/null +++ b/resources/views/native/async-task-demo.blade.php @@ -0,0 +1,142 @@ + + + + {{-- Proof the UI thread never blocks: hammer this while tasks run. --}} + + UI stays live + + Tap while tasks are running — the counter keeps moving, so PHP is not blocked. + + + + Taps + {{ $taps }} + + + + + + + + + + {{-- 1. finished() --}} + + 1 · finished() + + A 2s task on a background PHP thread. The result lands back on this component. + + + @if ($reportRunning) + + + Building report… + + @elseif ($reportResult) + + {{ $reportResult }} + {{ (int) $reportTookMs }}ms + + @else + Not run yet + @endif + + + + + {{-- 2. failed() --}} + + 2 · failed() + + The task throws. The exception crosses back as an AsyncTaskException. + + + @if ($failRunning) + + + Calling upstream… + + @elseif ($failMessage) + + {{ $failMessage }} + thrown as {{ $failClass }} + + @else + Not run yet + @endif + + + + + {{-- 3. Concurrency --}} + + 3 · Concurrency + + Three tasks dispatched together, sleeping 3s / 1s / 2s. They land fastest-first — + so they ran side by side, not in a queue. + + + @if (count($parallel)) + + @foreach ($parallel as $entry) + + {{ $entry['result'] }} + +{{ $entry['at'] }}ms + + @endforeach + + @endif + + @if ($parallelRunning) + + + {{ count($parallel) }}/3 done… + + @elseif (! count($parallel)) + Not run yet + @endif + + + + + {{-- 4. shared() --}} + + 4 · shared() + + Delivered as a named event, not a screen-scoped callback. Start it, navigate back, + then return — the result still arrives. A plain finished() would have been dropped. + + + @if ($sharedRunning) + + + Syncing (2.5s)… + + @elseif ($sharedResult) + {{ $sharedResult }} + @else + Not run yet + @endif + + + + + {{-- 5. Static-closure guard --}} + + 5 · Static guard + + Dispatching a closure that captures $this is rejected immediately — in the handler, + not silently in a background log. + + + @if ($guardMessage) + {{ $guardMessage }} + @else + Not run yet + @endif + + + + + + diff --git a/routes/web.php b/routes/web.php index 2c40f1c..507a21d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('counter'); Route::native('/reactivity', ReactivityDemo::class)->name('reactivity.demo'); + Route::native('/async-tasks', AsyncTaskDemo::class)->name('async.tasks.demo'); Route::native('/webview-demo', WebviewDemo::class)->name('webview.demo'); Route::native('/animate', Animate::class)->name('animate'); Route::native('/number-switcher', NumberSwitcherDemo::class)->name('number.switcher'); diff --git a/tests/Feature/Native/AsyncTaskDemoTest.php b/tests/Feature/Native/AsyncTaskDemoTest.php new file mode 100644 index 0000000..b963b99 --- /dev/null +++ b/tests/Feature/Native/AsyncTaskDemoTest.php @@ -0,0 +1,99 @@ +set('reportDelayMs', 0) + ->set('failDelayMs', 0) + ->set('sharedDelayMs', 0) + ->set('parallelDelaysMs', ['orders' => 0, 'revenue' => 0, 'signups' => 0]); +} + +it('renders without running anything', function () { + Native::test(AsyncTaskDemo::class) + ->assertSet('taps', 0) + ->assertSee('UI stays live') + ->assertSee('Not run yet'); +}); + +it('lands a finished() result back on the component', function () { + $screen = instant() + ->call('runReport') + ->assertSet('reportRunning', false); + + expect($screen->get('reportResult'))->toStartWith('Revenue £') + ->and($screen->get('reportTookMs'))->toBeNumeric(); +}); + +it('routes a thrown task to failed() with the original message and class', function () { + instant() + ->call('runFailing') + ->assertSet('failRunning', false) + ->assertSet('failMessage', 'Upstream API returned 503') + ->assertSet('failClass', RuntimeException::class); +}); + +it('collects every result when several tasks are dispatched together', function () { + $screen = instant() + ->call('runParallel') + ->assertSet('parallelRunning', false); + + $labels = array_column($screen->get('parallel'), 'label'); + + expect($labels)->toHaveCount(3) + ->and($labels)->toContain('orders', 'revenue', 'signups'); +}); + +it('rejects a work closure that captures $this', function () { + $screen = instant() + ->call('tryBoundClosure'); + + expect($screen->get('guardMessage'))->toContain('must be static'); +}); + +it('records each dispatch on the fake', function () { + $fake = AsyncTask::fake(); + + instant() + ->call('runReport') + ->call('runParallel'); + + // One report + three parallel tasks. + $fake->assertDispatched()->assertDispatchedTimes(4); +}); + +it('dispatches the shared task under its event alias', function () { + $fake = AsyncTask::fake(); + + instant()->call('runShared'); + + $fake->assertShared('demo-sync-complete'); +}); + +it('keeps the tap counter independent of task state', function () { + instant() + ->call('tap') + ->call('tap') + ->assertSet('taps', 2) + ->call('reset') + ->assertSet('taps', 0); +});