Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions app/NativeComponents/AsyncTaskDemo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php

namespace App\NativeComponents;

use Illuminate\View\View;
use Native\Mobile\AsyncTask;
use Native\Mobile\Attributes\On;
use Native\Mobile\Edge\NativeComponent;

/**
* Async tasks — background PHP work with UI completion callbacks.
*
* Each card exercises one part of the API:
* 1. finished() — a slow task whose result lands back on this component
* 2. failed() — a task that throws, surfaced as an AsyncTaskException
* 3. concurrency — three tasks in flight at once, each landing independently
* 4. shared() — result delivered as a named event via #[On] instead of a
* screen-scoped callback
* 5. the static-closure guard — a $this-bound closure is rejected at dispatch
*
* The tap counter at the top is the important one: it proves the UI thread is
* never blocked. Hammer it while tasks are running — it keeps counting.
*/
class AsyncTaskDemo extends NativeComponent
{
/** Proof the runloop stays responsive — tap while tasks are in flight. */
public int $taps = 0;

/**
* How long each simulated task "works" for, in milliseconds. Long enough
* on device to watch the spinners and prove the UI never blocks; tests
* override these to 0 so the inline fake (which runs work synchronously
* on the test thread) doesn't spend 20s sleeping.
*/
public int $reportDelayMs = 2000;

public int $failDelayMs = 800;

public int $sharedDelayMs = 2500;

/** label => 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');
}
}
1 change: 1 addition & 0 deletions app/NativeComponents/DemoLauncher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
142 changes: 142 additions & 0 deletions resources/views/native/async-task-demo.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<scroll-view class="w-full h-full bg-theme-background">
<column class="w-full p-5 gap-5">

{{-- Proof the UI thread never blocks: hammer this while tasks run. --}}
<column class="w-full p-5 gap-3 bg-theme-surface-variant rounded-2xl">
<text class="text-2xl font-bold uppercase text-theme-on-surface-variant">UI stays live</text>
<text class="text-base text-theme-on-surface-variant">
Tap while tasks are running — the counter keeps moving, so PHP is not blocked.
</text>

<row class="items-center justify-between mt-2">
<text class="text-xl text-theme-on-surface">Taps</text>
<text class="text-2xl font-bold text-theme-accent">{{ $taps }}</text>
</row>

<row class="gap-3 mt-2">
<button @press="tap">Tap me</button>
<spacer />
<button @press="reset" variant="ghost">Reset</button>
</row>
</column>

{{-- 1. finished() --}}
<column class="w-full p-5 gap-3 bg-theme-surface-variant rounded-2xl">
<text class="text-2xl font-bold uppercase text-theme-on-surface-variant">1 · finished()</text>
<text class="text-base text-theme-on-surface-variant">
A 2s task on a background PHP thread. The result lands back on this component.
</text>

@if ($reportRunning)
<row class="items-center gap-3 mt-2">
<activity-indicator />
<text class="text-lg text-theme-on-surface-variant">Building report…</text>
</row>
@elseif ($reportResult)
<row class="items-center justify-between mt-2">
<text class="text-lg text-theme-on-surface">{{ $reportResult }}</text>
<text class="text-base text-theme-on-surface-variant">{{ (int) $reportTookMs }}ms</text>
</row>
@else
<text class="text-lg text-theme-on-surface-variant mt-2">Not run yet</text>
@endif

<button @press="runReport" class="mt-2" :disabled="$reportRunning">Run report</button>
</column>

{{-- 2. failed() --}}
<column class="w-full p-5 gap-3 bg-theme-surface-variant rounded-2xl">
<text class="text-2xl font-bold uppercase text-theme-on-surface-variant">2 · failed()</text>
<text class="text-base text-theme-on-surface-variant">
The task throws. The exception crosses back as an AsyncTaskException.
</text>

@if ($failRunning)
<row class="items-center gap-3 mt-2">
<activity-indicator />
<text class="text-lg text-theme-on-surface-variant">Calling upstream…</text>
</row>
@elseif ($failMessage)
<column class="gap-1 mt-2">
<text class="text-lg font-semibold text-theme-destructive">{{ $failMessage }}</text>
<text class="text-base text-theme-on-surface-variant">thrown as {{ $failClass }}</text>
</column>
@else
<text class="text-lg text-theme-on-surface-variant mt-2">Not run yet</text>
@endif

<button @press="runFailing" class="mt-2" variant="destructive" :disabled="$failRunning">Run failing task</button>
</column>

{{-- 3. Concurrency --}}
<column class="w-full p-5 gap-3 bg-theme-surface-variant rounded-2xl">
<text class="text-2xl font-bold uppercase text-theme-on-surface-variant">3 · Concurrency</text>
<text class="text-base text-theme-on-surface-variant">
Three tasks dispatched together, sleeping 3s / 1s / 2s. They land fastest-first —
so they ran side by side, not in a queue.
</text>

@if (count($parallel))
<column class="gap-2 mt-2">
@foreach ($parallel as $entry)
<row class="items-center justify-between">
<text class="text-lg text-theme-on-surface">{{ $entry['result'] }}</text>
<text class="text-base text-theme-accent">+{{ $entry['at'] }}ms</text>
</row>
@endforeach
</column>
@endif

@if ($parallelRunning)
<row class="items-center gap-3 mt-2">
<activity-indicator />
<text class="text-lg text-theme-on-surface-variant">{{ count($parallel) }}/3 done…</text>
</row>
@elseif (! count($parallel))
<text class="text-lg text-theme-on-surface-variant mt-2">Not run yet</text>
@endif

<button @press="runParallel" class="mt-2" :disabled="$parallelRunning">Run 3 at once</button>
</column>

{{-- 4. shared() --}}
<column class="w-full p-5 gap-3 bg-theme-surface-variant rounded-2xl">
<text class="text-2xl font-bold uppercase text-theme-on-surface-variant">4 · shared()</text>
<text class="text-base text-theme-on-surface-variant">
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.
</text>

@if ($sharedRunning)
<row class="items-center gap-3 mt-2">
<activity-indicator />
<text class="text-lg text-theme-on-surface-variant">Syncing (2.5s)…</text>
</row>
@elseif ($sharedResult)
<text class="text-lg font-semibold text-theme-primary mt-2">{{ $sharedResult }}</text>
@else
<text class="text-lg text-theme-on-surface-variant mt-2">Not run yet</text>
@endif

<button @press="runShared" class="mt-2" variant="secondary" :disabled="$sharedRunning">Run shared task</button>
</column>

{{-- 5. Static-closure guard --}}
<column class="w-full p-5 gap-3 bg-theme-surface-variant rounded-2xl">
<text class="text-2xl font-bold uppercase text-theme-on-surface-variant">5 · Static guard</text>
<text class="text-base text-theme-on-surface-variant">
Dispatching a closure that captures $this is rejected immediately — in the handler,
not silently in a background log.
</text>

@if ($guardMessage)
<text class="text-base text-theme-destructive mt-2">{{ $guardMessage }}</text>
@else
<text class="text-lg text-theme-on-surface-variant mt-2">Not run yet</text>
@endif

<button @press="tryBoundClosure" class="mt-2" variant="outlined">Dispatch a bound closure</button>
</column>

</column>
</scroll-view>
Loading
Loading