Repository navigation
Keep screens responsive while their requests are in flight: background work pumped by the runloop, rendered only when a request settles - #455
Conversation
NativeComponent::background($promise, $onSettled) registers a promise (a Laravel LazyPromise is built so it is on the wire at once) for the runloop to pump between events: while any is pending the event wait is capped at 20 ms, an idle tick advances the shared CurlMultiHandler (Edge\BackgroundHttp) and runs the callbacks that became due, and the loop re-renders only when a promise settled or a poll fired — a tick that merely moved bytes publishes nothing. hasBackgroundWork() reports whether anything is still out. Components that never call background() are unaffected.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@sandermj you should be able to achieve what you're attempting to do here with Async Tasks (#228, released in The benefit of async tasks is that they're system level multi-threading async, tied to the runloop. So the integration is way deeper and more flexible than just for HTTP tasks through cURL. It also avoids the frame-hopping logic that this PR is having to do to achieve its result |
Resolves #456 — the proposal, with the reasoning and the measurements, is in that issue.
Proposal
What users experience today
A
NativeComponentruns on a single-threaded runloop: a method runs to completion before the next event is read. For everything local that is fine — SQLite through Eloquent, the filesystem, the app's own PHP all answer in a few milliseconds, and a screen built on them feels instant. The problem starts the moment a screen needs data that lives on a server: a REST or GraphQL backend, a third-party API, anyHttp::get()that leaves the phone. That call takes a hundred milliseconds on a good network, seconds on a bad one, and a full timeout when the server is down — and the runloop waits for all of it. Any screen that fetches remote data the obvious way,Http::get()inmount()or in a press handler, therefore stops the app for the whole round trip:#[Lazy]shows its placeholder, but the placeholder does not respond either;This is the single biggest difference a user feels between a native app and a NativePHP screen, and it affects every app whose content comes from an API rather than from the on-device database — which is most apps beyond a demo. An app that only reads its own SQLite never notices; an app whose home screen is a feed from its backend hits it on every launch. It is not a bug in any one component: the runloop has no notion of work that is in progress but not finished, so there is no way for a screen to say "start this request, keep taking input, tell me when the answer lands".
Why it cannot be solved properly from user land
Two pieces are available today and can be combined into a workaround:
CurlMultiHandlerso it goes on the wire at once, return frommount()with a skeleton, and callcurl_multi_execfromrender()to see whether it has landed.native:poll="200ms"element while the request is out, so the runloop wakes up, re-renders, and the render ticks the handle again.That makes the screen responsive, but the poll timer is now doing two jobs at once and does both badly, and neither can be fixed from outside the runloop:
Only the runloop can end this, because only the runloop knows two things: when it is idle, and whether the frame it is about to publish is different from the last one.
Proposal
Give
NativeComponenta small background-work API, backed by one sharedCurlMultiHandler, and make the runloop itself the pump:background(PromiseInterface $promise, ?callable $onSettled = null)registers a promise. A LaravelLazyPromise(whatHttp::async()returns — it only sends onwait()) is built at once so the transfer starts immediately, andmount()returns right away.nativephp_element_wait_event) is capped at 20 ms instead of blocking indefinitely.Utils::queue()->run()) — on the runloop thread, so$onSettledwrites straight into component state with no locking.#[Poll]method fired). A tick that just moved bytes costs no render and no publish.$onSettledwithnull; the screen decides what to show.hasBackgroundWork()answers whether anything is still out — for skeleton gates and for tests.Screens that never call
background()see no change at all: the timeout stays -1, the idle branch is never entered, the render gate is never set.What it solves
Measured
On an app whose every screen is fed by a remote API through its own backend (a tabs root with three chained requests, 20+ detail screens, iOS and Android), with every screen migrated onto this:
Implementation
New:
Native\Mobile\Edge\BackgroundHttp— the sharedCurlMultiHandler(select_timeout0, so a tick is a few milliseconds ofcurl_multi_exec, never a blocking select) plustick(), which advances the transfers and runs Guzzle's task queue.NativeComponent:background(PromiseInterface $promise, ?callable $onSettled = null)— builds a LaravelLazyPromiseif that is what it got (so the request is on the wire immediately), counts it as pending, and chains a settle callback that decrements the count, marks the component dirty and calls$onSettledwith the value (ornullon rejection).hasBackgroundWork()— true while anything is pending.pumpBackgroundWork()— ticks the handle when work is pending; returns whether a promise settled during the tick.nextEventTimeout()adds a 20 ms deadline (BACKGROUND_TICK_MS) while work is pending, next to the existing#[Poll]/native:polldeadlines.runDuePolls()now returns whether a poll fired (it used to return void).run()andrunLoop()) share the same idle branch: pump the background work, run the due polls, and setnativeSkipRenderwhen neither changed anything. At the top of the next iteration that flag skips the render + publish block once (the published tree and its callbacks stay valid — nothing was re-rendered, so nothing was reset) and goes straight back to waiting.Testing. A screen that calls
background()with a promise it resolves later:hasBackgroundWork()is true and no poll element is in the tree; after the promise resolves, the next idle tick lands the data andhasBackgroundWork()is false. A rejected promise settles the callback withnulland the count drops to zero. Existing#[Poll]andnative:pollbehaviour is unchanged (their deadlines still drivenextEventTimeout(), and a fired poll still re-renders). Verified on the iOS simulator and an Android emulator with an app whose every screen loads this way: reads land without a poll timer, and a waiting screen publishes nothing until its read settles.Compatibility. Purely additive. Components that never call
background()are unaffected: the pending count is zero, the timeout is unchanged, the idle branch only runs when it already ran today (a poll deadline elapsed), and the render gate is only set from that branch. The public-facingBackgroundHttp::handler()is the handle a request has to be built on (Http::setHandler(BackgroundHttp::handler())->async()->…); a plainHttp::async()promise passed tobackground()still works, it is just pumped by Guzzle's default handler on the same ticks. A promise that never settles keeps the 20 ms wake-up alive (cheap: no render, no publish — acurl_multi_execcall and an empty task queue), so give requests a timeout, asHttpdoes by default.