diff --git a/resources/views/docs/mobile/4/digging-deeper/async-tasks.md b/resources/views/docs/mobile/4/digging-deeper/async-tasks.md index a968cf82f..8b1b23718 100644 --- a/resources/views/docs/mobile/4/digging-deeper/async-tasks.md +++ b/resources/views/docs/mobile/4/digging-deeper/async-tasks.md @@ -160,6 +160,48 @@ required parameter that isn't in the payload can't be resolved, so the call fail +## Sending events while a task runs + + + +`finished()` hands back one result, at the end. To tell the screen something while the work still runs, such as +progress, fire an event that implements `BroadcastsGlobally` inside the task. It is carried to the UI thread, where +the live screen hears it in its `#[On]` methods: + +```php +use App\Events\ReportProgressed; +use Native\Mobile\AsyncTask; + +AsyncTask::dispatch(static function () { + foreach (ExpensiveReport::sections() as $index => $section) { + $section->build(); + + event(new ReportProgressed(done: $index + 1)); + } +}); +``` + +```php +use App\Events\ReportProgressed; +use Native\Mobile\Attributes\On; + +#[On(ReportProgressed::class)] +public function onProgress(int $done): void +{ + $this->sectionsDone = $done; +} +``` + +Unlike a scoped callback, the event goes to whichever screen is live, and only while a native screen is showing. +Its `Event::listen` listeners then run on the UI thread, and not in the task. + +Each event from a task costs its own render on the screen. Fire one per step, as above, and not one per row of a +large loop: a large burst does not all reach the screen, see +[A burst of events](../the-basics/events#a-burst-of-events). + +See [Events from queued jobs and async tasks](../the-basics/events#events-from-queued-jobs-and-async-tasks) for how +to write the event, and for the limits. + ## Running several at once Tasks run on a small pool of background PHP contexts, so several can be in flight together: @@ -262,3 +304,6 @@ the callbacks run on your UI thread, in your component. - 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. +- On Android the background lane for async tasks starts 2.5 seconds after the app is ready. A task that is + dispatched before that is refused: its work does not run and `failed()` is called at once, saying that the + background lane is not available. Without a `failed()` callback, the refusal only shows in the log. diff --git a/resources/views/docs/mobile/4/digging-deeper/queues.md b/resources/views/docs/mobile/4/digging-deeper/queues.md index f890c06d0..01a9b71bd 100644 --- a/resources/views/docs/mobile/4/digging-deeper/queues.md +++ b/resources/views/docs/mobile/4/digging-deeper/queues.md @@ -66,6 +66,33 @@ class SyncData implements ShouldQueue } ``` +## Sending Events to a Screen + + + +A job runs in its own PHP runtime, so it cannot change a screen directly. Fire an event that implements +`BroadcastsGlobally` instead, and the live screen hears it in its `#[On]` methods: + +```php +use App\Events\SyncFinished; + +public function handle() +{ + Http::post('https://api.example.com/sync', $this->payload); + + event(new SyncFinished(count: count($this->payload))); +} +``` + +The event only reaches a screen while a native screen is showing. Its `Event::listen` listeners then run in the +runtime of the screens, and not in the job. Otherwise the event stays in the job, as any Laravel event does. + +This does not work under [Jump](../the-basics/jump): a job that `php artisan queue:work` runs on your machine does +not reach the screen. + +See [Events from queued jobs and async tasks](../the-basics/events#events-from-queued-jobs-and-async-tasks) for how +to write the event and the screen's method, and for the limits. + ## How It Works When your app boots, NativePHP automatically starts a dedicated PHP runtime on a separate thread. This worker diff --git a/resources/views/docs/mobile/4/digging-deeper/theming.md b/resources/views/docs/mobile/4/digging-deeper/theming.md index 7a8418770..61e331fb7 100644 --- a/resources/views/docs/mobile/4/digging-deeper/theming.md +++ b/resources/views/docs/mobile/4/digging-deeper/theming.md @@ -156,8 +156,8 @@ public function appearanceChanged(string $mode): void } ``` -`AppearanceChanged` also dispatches globally, so code anywhere in the app can listen — not just the active -screen: +`AppearanceChanged` also [broadcasts globally](../the-basics/events#broadcasting-globally), so code anywhere in the +app can listen, not just the active screen: ```php use Illuminate\Support\Facades\Event; diff --git a/resources/views/docs/mobile/4/testing/advanced.md b/resources/views/docs/mobile/4/testing/advanced.md index 61ed3615f..9c5bde419 100644 --- a/resources/views/docs/mobile/4/testing/advanced.md +++ b/resources/views/docs/mobile/4/testing/advanced.md @@ -34,7 +34,9 @@ To audit a tree for screen-reader accessibility rather than a specific element, ## Render-count guards -Every interaction re-renders. When performance matters, assert on exactly how many frames a screen produced. +Every interaction re-renders, except an `emitNative()` that misses every `when` filter of the screen while nothing +else is there for the event (see [Testing a when filter](native-events#testing-a-when-filter)). When performance +matters, assert on exactly how many frames a screen produced. - `renderCount()` — frames rendered so far (the initial mount counts as 1). - `assertRenderCount($count)` — an exact frame count. diff --git a/resources/views/docs/mobile/4/testing/introduction.md b/resources/views/docs/mobile/4/testing/introduction.md index 510da4d68..5a64febba 100644 --- a/resources/views/docs/mobile/4/testing/introduction.md +++ b/resources/views/docs/mobile/4/testing/introduction.md @@ -54,7 +54,9 @@ it('increments the count', function () { `tap()` presses the nearest pressable element whose subtree shows the given text (or one carrying a matching `ref` — more on that in [Interactions](interactions)). `assertSet()` reads a public or `#[Computed]` property and compares it, while `assertNotSet()` asserts a property holds anything but a given value. Every interaction re-renders the component, -so the assertions that follow see the fresh frame. +so the assertions that follow see the fresh frame. The exception is an `emitNative()` that misses every `when` filter +of the screen while nothing else is there for the event, see +[Testing a when filter](native-events#testing-a-when-filter). ## Passing params and data diff --git a/resources/views/docs/mobile/4/testing/native-events.md b/resources/views/docs/mobile/4/testing/native-events.md index 22e79eaab..5a2e64d94 100644 --- a/resources/views/docs/mobile/4/testing/native-events.md +++ b/resources/views/docs/mobile/4/testing/native-events.md @@ -18,6 +18,9 @@ responses. You reach its assertions through the harness, or the bridge itself th plugin pushes an event. It fires `#[On]` listeners, fluent `->on()` closures, and any pending `then()` / `catch()` callbacks the component chained onto its bridge call, then re-renders. +No re-render follows an event that the screen hears only through `when` filters, when none of them matches and +nothing else is there for the event. See [Testing a when filter](#testing-a-when-filter). + Pass the event class and its payload: ```php @@ -32,6 +35,107 @@ it('counts device shakes delivered as native events', function () { }); ``` +## Testing a when filter + + + +`emitNative()` hands the payload to the screen as you wrote it, so a +[`when` filter](../the-basics/events#filtering-with-when) sees the values and types that you pass. The comparison is +strict: write `42` and not `'42'` when the device sends a number. Only two numbers match across types, so `5` passes a +filter on `5.0`. + +Deliver one event that the filter lets through and one that it does not: + +```php +use Native\Mobile\Events\Alert\ButtonPressed; + +// ItemScreen: #[On(ButtonPressed::class, when: ['id' => 'delete-confirm', 'label' => 'Delete'])] +it('deletes the item only when Delete is pressed', function () { + Native::test(ItemScreen::class) + ->emitNative(ButtonPressed::class, ['index' => 0, 'label' => 'Cancel', 'id' => 'delete-confirm']) + ->assertSet('deleted', false) + ->assertNotRerendered() + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => 'delete-confirm']) + ->assertSet('deleted', true) + ->assertRerendered(); +}); +``` + +A screen that listens for an event only through filters does not re-render when none of them matches, and the +harness does the same: `emitNative()` then renders no new frame. Pin that down with `assertNotRerendered()` or +`assertRenderCount()`, see [Render-count guards](advanced#render-count-guards). + +The exception is an event that something else is there for: a closure registered with `->on()`, a fluent callback +that waits for it, or a Laravel listener that is registered for the event. The screen then re-renders, also on a +miss. + +The package itself listens for `AppearanceChanged`, `OrientationChanged` and `ThermalStateChanged`, so do not expect +`assertNotRerendered()` to pass after those three. An `Event::listen` in your app for an event that broadcasts +globally has the same effect, and `Event::fake()` does not change that. + +## Events that broadcast globally + +An event that implements [`BroadcastsGlobally`](../the-basics/events#broadcasting-globally) is heard by the live +screen and by Laravel's listeners. Test each direction from the side where the event starts. + +### From the device + +`emitNative()` delivers the event to the screen, and a marked event is also sent through `event()`. Fake Laravel's +events to assert on that half. The screen still hears the event: + +```php +use Illuminate\Support\Facades\Event; +use Native\Mobile\Events\System\AppearanceChanged; + +it('hears a theme change on the screen and in Laravel', function () { + Event::fake(); + + Native::test(SettingsScreen::class) + ->emitNative(AppearanceChanged::class, ['mode' => 'dark']) + ->assertSet('mode', 'dark'); + + Event::assertDispatched(AppearanceChanged::class); +}); +``` + +### Fired from PHP + + + +The screen under test is the live screen. Fire the event with `event()` in the body of the test, and the harness +delivers it to the screen's `#[On]` methods before the next assertion, with one render: + +```php +use App\Events\OrderUpdated; + +it('shows an order that shipped', function () { + $screen = Native::test(OrdersScreen::class); + + event(new OrderUpdated(orderId: 42, status: 'shipped')); + + $screen->assertSet('statuses', [42 => 'shipped']) + ->assertRerendered(); +}); +``` + +When a method of the screen fires the event, its `#[On]` method runs after that method has returned, and the +interaction still renders once: + +```php +it('renders once when a handler fires the event', function () { + Native::test(OrdersScreen::class) + ->call('ship', 42) // ship() fires event(new OrderUpdated(42, 'shipped')) + ->assertSet('statuses', [42 => 'shipped']) + ->assertRenderCount(2); +}); +``` + +Two things to keep in mind: + +- `Event::fake()` holds a marked event back, so it reaches no screen and no listener. To test the screen's side, + leave the event out of the fake, for example with `Event::fake([OtherEvent::class])`. +- After `follow()`, the destination is the live screen. The screen underneath hears nothing, as on a device. + ## Asserting on native calls After an interaction, assert on what the component sent to the bridge: diff --git a/resources/views/docs/mobile/4/the-basics/device.md b/resources/views/docs/mobile/4/the-basics/device.md index 53f436cc7..1ed497d24 100644 --- a/resources/views/docs/mobile/4/the-basics/device.md +++ b/resources/views/docs/mobile/4/the-basics/device.md @@ -97,6 +97,40 @@ $battery['isCharging']; // bool | `batteryLevel` | Battery level from `0.0` to `1.0` | | `isCharging` | Whether the device is charging | +## Events + +The device sends two events that a screen hears with `#[On]`: + +| Event | Fires when | Arguments | +| --- | --- | --- | +| `Native\Mobile\Events\Device\ThermalStateChanged` | The thermal state of the device changes | `state`, `previous` | +| `Native\Mobile\Events\Motion\ShakeDetected` | The user shakes the device | None | + +`state` and `previous` are cases of the `Native\Mobile\ThermalState` enum: `Normal`, `Warm`, `Hot` or `Critical`. +`Device::thermalState()` returns the current case at any time. + +```php +use Native\Mobile\Attributes\On; +use Native\Mobile\Events\Device\ThermalStateChanged; +use Native\Mobile\Events\Motion\ShakeDetected; +use Native\Mobile\ThermalState; + +#[On(ThermalStateChanged::class)] +public function onThermal(ThermalState $state, ThermalState $previous): void +{ + $this->throttled = $state->isHot(); +} + +#[On(ShakeDetected::class)] +public function onShake(): void +{ + $this->shakes++; +} +``` + +Both events [broadcast globally](events#broadcasting-globally). Next to the `#[On]` methods of the live screen, +`Event::listen` hears them anywhere in your app. + ## Permissions | Platform | Permissions | diff --git a/resources/views/docs/mobile/4/the-basics/events.md b/resources/views/docs/mobile/4/the-basics/events.md index cc220bb74..621a03450 100644 --- a/resources/views/docs/mobile/4/the-basics/events.md +++ b/resources/views/docs/mobile/4/the-basics/events.md @@ -9,7 +9,8 @@ Screens react to things that happen outside a user tap — a push notification a a bridge call finishes. A component **listens** for these native events and updates its state in response; the screen re-renders like any other state change. -This page covers listening for these native events from a component. +This page covers listening for these native events from a component. It also covers where an event is heard, and +how an event that your own PHP code fires reaches a screen. ## Listening with #[On] @@ -35,6 +36,141 @@ class ChatScreen extends NativeComponent `#[On]` is repeatable — stack several on one method to handle multiple events, or put several methods on the same event. Listeners are torn down automatically when the screen unmounts, so they never leak onto the next screen. +When several methods listen for one event, all of them run, in the order they are declared in the class. The methods +of a child class run before those of its parent. A method runs once per event, also when two of its attributes +match. + +An `#[On]` method has to be public. A child class that overrides a listener of its parent has to repeat the attribute +on its own method, or that listener is lost. + +### How a payload is bound + +The payload of an event is bound to the parameters of your method by name: + +- A scalar is cast to the type of the parameter, so `'3'` arrives as `3` for an `int`. +- A `null` stays `null` for a nullable parameter . For a parameter that does + not allow `null`, it is cast like any other value, to `0`, `''` or `false`. +- A parameter typed with a backed enum gets the enum, made from its backing value. +- A key that your method has no parameter for is left out. + +## Filtering with when + + + +Pass `when` as the second argument of `#[On]` to hear an event only for certain values. The method runs when every +key of the filter is in the event's values, with the same value: + +```php +use Native\Mobile\Attributes\On; +use Native\Mobile\Events\Alert\ButtonPressed; + +#[On(ButtonPressed::class, when: ['id' => 'delete-confirm', 'label' => 'Delete'])] +public function onDeleteConfirmed(): void +{ + $this->deleteItem(); +} +``` + +The filter follows these rules: + +- Every key of the filter has to match. A key that the event does not carry is no match, also when the filter + value is `null`. +- The comparison is strict. It uses the values as they arrive, before the types of your parameters convert them. + So `1` does not match `'1'`, and `true` does not match `1`. +- Numbers are the one exception: two numbers match when they are equal as numbers. `when: ['ratio' => 1.0]` matches + a device payload that carries `1`. A device writes a whole number without a fraction, and the same event class + can come from PHP with a float. +- An empty filter (`when: []`) is no filter, and the method hears every event. +- The filter is part of an attribute, so its values are fixed when you write the class: literals, constants and + enum cases. To compare with a value the screen holds, such as a route parameter, do that inside the method. + +### Nested values + +A dotted key reads a nested value: + +```php +use App\Events\MessageReceived; +use Native\Mobile\Attributes\On; + +// Payload: ['sender' => ['id' => 7, 'role' => 'support'], 'body' => 'Hi!'] +#[On(MessageReceived::class, when: ['sender.role' => 'support'])] +public function fromSupport(string $body): void +{ + $this->supportMessages[] = $body; +} +``` + +The key reads into arrays. An event that PHP fires can also carry objects, and the key reads into those too: public +properties, the attributes of an Eloquent model and the items of a collection (`lines.0.sku` is the `sku` of the +first line). When the payload has a key that is literally `sender.role`, that key wins. + +### Enums + +A backed enum is compared by its backing value. This filter matches whether the event carries the enum or the +string `'critical'`: + +```php +use Native\Mobile\Attributes\On; +use Native\Mobile\Events\Device\ThermalStateChanged; +use Native\Mobile\ThermalState; + +#[On(ThermalStateChanged::class, when: ['state' => ThermalState::Critical])] +public function tooHot(): void +{ + $this->pauseSync(); +} +``` + +An enum without a backing value only matches the same case. + +### Several filters on one method + +Repeat the attribute to give one method several filters. The method runs once when any of them matches: + +```php +use App\Events\StockChanged; +use Native\Mobile\Attributes\On; + +#[On(StockChanged::class, when: ['sku' => 'A'])] +#[On(StockChanged::class, when: ['low' => true])] +public function restock(string $sku): void +{ + $this->toRestock[] = $sku; +} +``` + +### When no filter matches + +When a screen listens for an event only through filters and none of them matches, no method runs. The screen also +does not re-render, unless something else is there for the event: + +- a closure registered with `->on()`; +- a fluent callback that waits for it, such as [`->buttonPressed()`](dialogs#handling-button-presses) on an alert; +- a Laravel listener that is registered for the event class, or for an interface that the class implements. + +Any of these may have changed something that the screen shows, so the screen is drawn again. + +A registered Laravel listener counts whether it runs at once or is queued. A wildcard listener, as logging tools +register for every event, does not count. Laravel only hears a device event that +[broadcasts globally](#broadcasting-globally), so for any other device event a Laravel listener plays no part. + +The package itself listens for `AppearanceChanged`, `OrientationChanged` and `ThermalStateChanged`, to keep `System` +and `Device` up to date. So a screen always re-renders after those three, also when its own filter misses. + +For an event that nothing else listens for, a miss costs nothing. That is the point of `when` for an event that fires +often: the screen is left alone until a value it cares about comes in. + +All of this holds for an event from the device, and for one that reaches the screen from a +[queued job, an async task or a web view](#events-from-queued-jobs-and-async-tasks). + +### Component events + +For a [component event](nested-components#events-up), the filter is matched against the named arguments of `emit()` +or `dispatch()`. `$this->emit('item-picked', id: 5)` matches `when: ['id' => 5]`. A positional argument has no name, +so `$this->emit('item-picked', 5)` does not match. + +A component event is part of an interaction, so the screen renders whether or not a filter matched. + ## Listening with ->on() For a listener you register at runtime — for example inside `mount()`, or conditionally — use the fluent `->on()` @@ -57,7 +193,7 @@ dynamically. Native events originate on the device side and are delivered to whichever screen is alive: plugin events (a [Vibe](../digging-deeper/websockets) websocket message, a push notification tap), bridge-call completions, and any custom events an async native call resolves with. Because delivery targets the live screen, a listener only fires while -its screen is on the stack. +its screen is the one on top. That last part is also the limit of this page. If something outside your screens needs to follow what the user is doing — analytics, telemetry, crash breadcrumbs — listen for the app-wide @@ -69,3 +205,305 @@ You can drive events in tests without a device — `emitNative(Event::class, [.. component. See [Native Events & the Bridge](../testing/native-events). + +### How a device event arrives + +On a native screen, a device event comes in through the native event queue. That queue is kept in memory, between the +native side and the screen that is showing. It is not Laravel's queue and nothing of it is in the database. It lasts +as long as the native screen session: when that ends, what still waited in it is gone. + +A web view app does not use that queue. There the device posts each event to `/_native/api/events`, where a +controller of the package builds the event from its payload and calls `event()`. So in a web view app Laravel's +listeners hear every device event. + +### The order on a native screen + +One device event is handed out in this order: + +1. the fluent callback that waits for it, which runs once; +2. the closures registered with `->on()`; +3. Laravel's listeners, when the event [broadcasts globally](#broadcasting-globally); +4. the `#[On]` methods of the screen. + +## Where an event is heard + +An event starts in one of two places: on the device, or in PHP with Laravel's `event()`. It stays on the side where +it started, unless its class implements [`BroadcastsGlobally`](#broadcasting-globally). Then both sides hear it. + +| Direction | What happens | Needs `BroadcastsGlobally` | +| --- | --- | --- | +| Device to screen | The device sends an event, and the `#[On]` methods of the live screen run | No | +| Device to Laravel | That event is also sent through `event()`, so `Event::listen` hears it | Yes | +| Screen to Laravel | A method on your screen calls `event()`. Laravel's listeners hear it, as in any Laravel app | No | +| Laravel to screen | An event fired with `event()` also runs the `#[On]` methods of the live screen | Yes | + +The table is about native screens. In a web view app, Laravel hears every device event without the marker, see +[How a device event arrives](#how-a-device-event-arrives). + +### The live screen + +Events go to the live screen, the one on top of the stack. A screen that is covered by another screen hears nothing, +and nothing is kept for it: when the user comes back, the events it missed do not arrive. Read the state again in +[`onResume()`](../digging-deeper/lifecycle-hooks#onresume): + +```php +use App\Models\Order; + +public function onResume(): void +{ + // Events that fired while this screen was covered never reached it. + $this->orders = Order::latest()->get()->toArray(); +} +``` + +A device event reaches the screen only, never a nested component. The same goes for a marked event from `event()`. +The screen hears them and hands what a child needs down as a [prop](nested-components#props). + +## Broadcasting globally + +`BroadcastsGlobally` is a marker interface without methods. An event class that implements it is heard in both +places at once: by the `#[On]` methods of the live screen and by Laravel's listeners. + +"Globally" means those two places. It does not mean every PHP runtime of your app, see +[One way, towards the main runtime](#one-way-towards-the-main-runtime). + +```php +namespace App\Events; + +use Native\Mobile\Events\Concerns\BroadcastsGlobally; + +class OrderUpdated implements BroadcastsGlobally +{ + public function __construct( + public int $orderId, + public string $status, + ) {} +} +``` + +An event without the interface stays on the side where it started. + +### From the device to Laravel + +A marked event that arrives from the device is also sent through `event()`. `Event::listen` hears it next to the +screen's `#[On]` methods, so code anywhere in your app can react: + +```php +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Event; +use Native\Mobile\Events\System\AppearanceChanged; + +Event::listen(AppearanceChanged::class, function (AppearanceChanged $event) { + Cache::forget('theme'); +}); +``` + +Laravel's listeners hear the event whether or not the live screen has an `#[On]` method for it. + +### From Laravel to the screen + + + +A marked event that PHP fires with `event()` also reaches the live screen. Its public properties are the payload. +They are bound to the method's parameters by name and matched against `when`, as the payload of a device event is: + +```php +use App\Events\OrderUpdated; + +event(new OrderUpdated(orderId: 42, status: 'shipped')); +``` + +```php +use App\Events\OrderUpdated; +use Native\Mobile\Attributes\On; +use Native\Mobile\Edge\NativeComponent; + +class OrdersScreen extends NativeComponent +{ + public array $statuses = []; + + #[On(OrderUpdated::class)] + public function onOrderUpdated(int $orderId, string $status): void + { + $this->statuses[$orderId] = $status; + } +} +``` + +The method does not run inside `event()`. It runs after the current handler returns, on the screen's next turn. The +screen then renders once for everything that arrived, so a burst of events costs one render. + +An event that a handler fires right before it navigates away reaches no screen. The screen it leaves is not live +any more, and the next screen is not live yet. Laravel's listeners still hear it. + + + +### Package events that broadcast globally + +These events of the package carry the marker: + +| Event | Fires when | Arguments | More | +| --- | --- | --- | --- | +| `Native\Mobile\Events\System\AppearanceChanged` | The system switches between light and dark | `mode` | [Theming](../digging-deeper/theming#reacting-to-changes) | +| `Native\Mobile\Events\System\OrientationChanged` | The app window turns to portrait or landscape | `orientation` | [System](system#orientation) | +| `Native\Mobile\Events\Device\ThermalStateChanged` | The thermal state of the device changes | `state`, `previous` | [Device](device#events) | +| `Native\Mobile\Events\Motion\ShakeDetected` | The user shakes the device | None | [Device](device#events) | + +Any other event class can implement the interface too: one of your own, or one in a plugin that you write. + +## Events from queued jobs and async tasks + + + +A [queued job](../digging-deeper/queues), an [async task](../digging-deeper/async-tasks) and a +[web view inside a native screen](../edge-components/web-view#php-mode) each run in a PHP runtime of their own inside +the app. A marked event that is fired there is carried to the main runtime, the one that runs your screens, and the +live screen hears it: + +```php +namespace App\Jobs; + +use App\Events\OrderUpdated; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; + +class ShipOrder implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function __construct(public int $orderId) {} + + public function handle(): void + { + // The slow work happens here. + + event(new OrderUpdated(orderId: $this->orderId, status: 'shipped')); + } +} +``` + +The `OrdersScreen` above hears this event without a change. + +The rules below say "the job". They hold in the same way for an async task and for a web view. + +### One way, towards the main runtime + +"Globally" does not mean everywhere. A marked event is heard in the main runtime: by the `Event::listen` listeners +there and by the live screen. It is never sent out to a queued job, an async task or the runtime of a web view that +happens to be running, and not from one job to another. + +### Where the listeners run + +When an event is carried over, its `Event::listen` listeners run in the main runtime, and not in the job. In the job, +`event()` then returns an empty array, as it does when nothing listens. + +For a web view this means that the listeners run outside the request that fired the event. Do not count on that +request, its session or its authenticated user in such a listener. + +### Only while a native screen is showing + +An event is carried over only when a native screen is showing at that moment. Otherwise it stays in the job and its +listeners run there, as for any Laravel event. No warning is logged for that, and nothing is kept for a screen that +opens later. + +### What the event can carry + +The event is carried over as itself, the way Laravel carries the data of a queued job: it is serialized where it was +fired and unserialized in the main runtime. So: + +- models, dates and collections arrive, and a method on the screen can type-hint them; +- properties that are not constructor parameters arrive too. + +On a device the event travels in memory: nothing of it is written to disk or to the database. It is signed with +your app key, and the main runtime checks that signature before it reads the event. + +### Events that hold a model + +Put the `SerializesModels` trait on a marked event that holds a model. Then only the class and the key of the model +travel, and the main runtime fetches the model again, as a queued job does. That gives you two things: the model is +fresh when the screen gets it, and the event stays small. + +```php +namespace App\Events; + +use App\Models\Order; +use Illuminate\Queue\SerializesModels; +use Native\Mobile\Events\Concerns\BroadcastsGlobally; + +class OrderRefunded implements BroadcastsGlobally +{ + use SerializesModels; + + public function __construct(public Order $order) {} +} +``` + +```php +use App\Events\OrderRefunded; +use App\Models\Order; +use Native\Mobile\Attributes\On; + +#[On(OrderRefunded::class)] +public function onRefunded(Order $order): void +{ + $this->statuses[$order->id] = $order->status; +} +``` + +Measured for an event that holds one model: + +| The model | Without the trait | With `SerializesModels` | +| --- | --- | --- | +| 10 short columns | 2.3 KB | about 220 bytes | +| 60 columns of 200 characters | 28 KB | about 220 bytes | +| 200 columns of 500 characters | 211 KB | about 220 bytes | + +Without the trait, a model takes about twice the size of its data, because Eloquent keeps the attributes and a copy +of their original values. + +If the row is gone when the event arrives, the event is dropped: nothing hears it, and a warning is logged. + +### When an event cannot be carried over + +An event that cannot be carried over never throws. It stays in the job, and its listeners run there. That happens, +with a warning in the log, when: + +- the event cannot be serialized, in practice because it holds a closure; +- the event is too large (`SerializesModels` keeps an event that holds a model small); +- the app has no usable app key to sign the event with; +- the native event queue is full (see [A burst of events](#a-burst-of-events)). + +Such a warning is logged once, not for every event. The event also stays in the job, without a warning, when no +native screen is showing. + +### A burst of events + +Each event from a job arrives on its own and, when something hears it, costs its own render. So a burst of fifty +events from a job costs fifty renders, where the same burst fired in the main runtime costs one. + +A large burst also runs into the size of the native event queue. The queue holds 1,024 events, or 8 MB of them, and +taps and other device events share it with the events from jobs, async tasks and web views. A job in a tight loop +fires events much faster than the main runtime takes them, so a loop of more than about a thousand events fills the +queue by itself. An event that finds the queue full stays in the job: its listeners run there, and the screen does +not hear it. + +So do not fire one event per item in a tight loop. Fire the end result, or a progress event every so often. + +A full queue can still lose an event: a tap that arrives while it is full pushes out the oldest event that waits. + +### Limits + +- Livewire components in a web view do not hear a marked event that PHP fires. Only native screens do. +- Under [Jump](jump), PHP runs on your machine. A marked event that an async task fires reaches the screen there. One + from a job that `php artisan queue:work` runs on your machine does not, and neither does one from a web view: both + stay where they were fired. +- Under Jump, an async task cannot tell whether a native screen is showing. Its event leaves the task either way: its + listeners do not run in the task, and the event waits until a screen takes it, however late that is. diff --git a/resources/views/docs/mobile/4/the-basics/nested-components.md b/resources/views/docs/mobile/4/the-basics/nested-components.md index 153ee73ce..be8c26bbe 100644 --- a/resources/views/docs/mobile/4/the-basics/nested-components.md +++ b/resources/views/docs/mobile/4/the-basics/nested-components.md @@ -152,6 +152,24 @@ public function onAnyTaskToggled(string $title, bool $done): void } ``` +Several methods can listen for one event. They all run, in the order they are declared in the class. + +`#[On]` also takes a `when` filter , which is matched against the **named** +arguments of the event. A positional argument has no name, so it never matches a filter: + +```php +use Native\Mobile\Attributes\On; + +// Emitted with names: $this->emit('task-toggled', title: $this->title, done: $this->done); +#[On('task-toggled', when: ['done' => true])] +public function onTaskDone(string $title): void +{ + // ... +} +``` + +See [Filtering with when](events#filtering-with-when) for the rules. + +## Dispatching events + +`emit()` calls its listeners right away, and the event only travels up. `$this->dispatch('event-name', ...$params)` +queues the event instead. It is delivered after the current action returns, and before the screen renders. + +```php +namespace App\NativeComponents; + +use Native\Mobile\Edge\NativeComponent; + +class TaskCard extends NativeComponent +{ + public int $taskId = 0; + + public function archive(): void + { + $this->dispatch('task-archived', id: $this->taskId); + } +} +``` + +A dispatched event goes to, in this order: + +1. the `#[On('task-archived')]` methods of the component that dispatched it, +2. the `@task-archived` binding on that component's mounting tag, +3. the `#[On('task-archived')]` methods of every ancestor, up to the screen. + +So unlike `emit()`, `dispatch()` also reaches the component itself. A screen has no ancestors, and it can still +dispatch an event to its own `#[On]` methods. + +Listen for a dispatched event as for an emitted one, with a string-form `#[On]`. Pass the parameters by name, as in +`id: $this->taskId`, and they are bound to the method's parameters by name. + +Because the event waits until the action returns, you can narrow where it goes. `dispatch()` returns the event, and +`->self()` and `->to()` set its destination. + +### Only the component itself + +`->self()` keeps the event inside the component that dispatched it. Only its own `#[On]` methods run. No tag binding +and no ancestor hears it: + +```php +$this->dispatch('draft-saved')->self(); +``` + +### Components of one class + +`->to()` takes a component class name. The event goes to every component on the screen that is an instance of that +class, wherever it sits: the screen itself, an ancestor, a sibling or a child. Nothing else hears it: + +```php +use App\NativeComponents\TaskCounter; + +$this->dispatch('task-archived', id: $this->taskId)->to(TaskCounter::class); +``` + +Pass a component instance instead of a class name to reach only that one component. + + + ## Lifecycle - `mount()` runs when a child's key first appears in the parent's tree. @@ -205,6 +289,18 @@ Native::test(TaskBoard::class) ->assertSee('1 done'); // tag binding updated the screen ``` +The harness records every event that was sent with `dispatch()`, so you can assert on those. An `emit()` is not +recorded: + +```php +Native::test(TaskBoard::class) + ->tap('archive-7') + ->assertDispatched('task-archived', id: 7) + ->assertNotDispatched('task-restored'); +``` + +`assertDispatchedTo(TaskCounter::class, 'task-archived')` also checks the class that was given to `->to()`. + ## Children vs. everything else - **Repeated UI with behavior** (a card with its own tap handlers and state) → a nested component. diff --git a/resources/views/docs/mobile/4/the-basics/system.md b/resources/views/docs/mobile/4/the-basics/system.md index 550244bdf..5572eeca3 100644 --- a/resources/views/docs/mobile/4/the-basics/system.md +++ b/resources/views/docs/mobile/4/the-basics/system.md @@ -71,6 +71,32 @@ Reading the current appearance and reacting to theme changes lives with the rest [Theming → Appearance in PHP](../digging-deeper/theming#appearance-in-php) for `System::appearance()`, `isDarkMode()`, `isLightMode()`, the `isDark()` / `theme()` helpers, and the `AppearanceChanged` event. +## Orientation + +Read the orientation of the app window: + +```php +System::orientation(); // 'portrait' | 'landscape' +System::isPortrait(); // bool +System::isLandscape(); // bool +``` + +When it changes, the `OrientationChanged` event fires. `$orientation` is `'portrait'` or `'landscape'`: + +```php +use Native\Mobile\Attributes\On; +use Native\Mobile\Events\System\OrientationChanged; + +#[On(OrientationChanged::class)] +public function rotated(string $orientation): void +{ + $this->columns = $orientation === 'landscape' ? 4 : 2; +} +``` + +`OrientationChanged` [broadcasts globally](events#broadcasting-globally). Next to the `#[On]` methods of the live +screen, `Event::listen` hears it anywhere in your app. +