Split out from #344 (which fixed the sibling routing gap for #[On] listeners, #341). Line references are against main @ bf4fd2a.
Symptom
A fluent native callback registered from a child NativeComponent fires with $this bound to the screen, not the child:
// inside a child component's @tap handler
Camera::getPhoto()->photoTaken(fn ($e) => $this->photo = $e->path);
$this->photo is written on the screen. NativeComponent has a __get but no __set, so if the screen has no $photo property the assignment silently creates a dynamic property on it — no error, no exception. The child's $photo stays empty and re-renders unchanged, so it looks like the camera did nothing.
This is worse than a no-op: state lands on the wrong object rather than nowhere.
Cause
NativeCallbacks (src/Support/NativeCallbacks.php) keys the registry by id + eventClass:
protected static array $memory = []; // id => [ eventClass => Closure ]
There is no record of which component registered the callback. So when the result event arrives, NativeComponent::fireNativeCallback() (src/Edge/NativeComponent.php:1897) has a closure and no owner, and runs on whatever component owns the runloop — the screen:
// src/Edge/NativeComponent.php:1932
$callback = \Closure::bind($callback, $this, static::class);
A closure written inside a child's method was already auto-bound to the child by PHP. This line overwrites that correct binding with the screen.
The behaviour predates child components — when a screen was the only component, "the live component instance" was unambiguous and the rebind was correct. It became wrong once components could nest.
Related: ambiguous fallback when the id misses
resolveByEvent() (src/Support/NativeCallbacks.php:112) takes the last registration for an event class:
foreach (static::$memory as $id => $byEvent) {
if (isset($byEvent[$eventClass])) {
$matchId = $id; // keep scanning — last match is the most recent
}
}
"The single in-flight callback for this event class" is an assumption, not a guarantee. With two components holding an in-flight getPhoto(), the most-recently-registered one wins regardless of which produced the result. This fallback is load-bearing — per the comment at the call site, some native paths drop the id across a lifecycle bounce (the gallery picker) — so it is not a hypothetical path.
What a fix needs
This is a missing-data problem, not a traversal one (which is why it was out of scope for #344's ~10-line fix):
- Thread an owner reference from the facade / pending builder into
NativeCallbacks::register(), which today receives only id, eventClass, callback.
- Resolve that owner back to a live instance at fire time — the component may have unmounted, or the process may have been killed and rehydrated (the reason the durable tier 2 exists at all).
- Give the owner a stable identity for the durable tier.
register() already skips the cache copy for instance-bound closures (getClosureThis() !== null), and an object reference has the same serialization problem. The CallbackRegistry scope string (parent>child) is the plausible candidate, since that is already how child callbacks are namespaced.
Fallback resolution should then prefer the owning component before the last-match scan.
Docs impact
docs/native-callback-api-design.md "Finding 2" currently documents the rebind as reliable for any component:
Because fireNativeCallback() runs on the live component instance, it rebinds the callback to $this … So then()/catch() closures can mutate the live component directly — $this->images[] = …
That is accurate for a screen and misleading for a child, as is the "You write / Gets $this" table under it. Worth a note once this is resolved, or sooner.
Split out from #344 (which fixed the sibling routing gap for
#[On]listeners, #341). Line references are againstmain@ bf4fd2a.Symptom
A fluent native callback registered from a child
NativeComponentfires with$thisbound to the screen, not the child:$this->photois written on the screen.NativeComponenthas a__getbut no__set, so if the screen has no$photoproperty the assignment silently creates a dynamic property on it — no error, no exception. The child's$photostays empty and re-renders unchanged, so it looks like the camera did nothing.This is worse than a no-op: state lands on the wrong object rather than nowhere.
Cause
NativeCallbacks(src/Support/NativeCallbacks.php) keys the registry byid+eventClass:There is no record of which component registered the callback. So when the result event arrives,
NativeComponent::fireNativeCallback()(src/Edge/NativeComponent.php:1897) has a closure and no owner, and runs on whatever component owns the runloop — the screen:A closure written inside a child's method was already auto-bound to the child by PHP. This line overwrites that correct binding with the screen.
The behaviour predates child components — when a screen was the only component, "the live component instance" was unambiguous and the rebind was correct. It became wrong once components could nest.
Related: ambiguous fallback when the id misses
resolveByEvent()(src/Support/NativeCallbacks.php:112) takes the last registration for an event class:"The single in-flight callback for this event class" is an assumption, not a guarantee. With two components holding an in-flight
getPhoto(), the most-recently-registered one wins regardless of which produced the result. This fallback is load-bearing — per the comment at the call site, some native paths drop the id across a lifecycle bounce (the gallery picker) — so it is not a hypothetical path.What a fix needs
This is a missing-data problem, not a traversal one (which is why it was out of scope for #344's ~10-line fix):
NativeCallbacks::register(), which today receives onlyid,eventClass,callback.register()already skips the cache copy for instance-bound closures (getClosureThis() !== null), and an object reference has the same serialization problem. TheCallbackRegistryscope string (parent>child) is the plausible candidate, since that is already how child callbacks are namespaced.Fallback resolution should then prefer the owning component before the last-match scan.
Docs impact
docs/native-callback-api-design.md"Finding 2" currently documents the rebind as reliable for any component:That is accurate for a screen and misleading for a child, as is the "You write / Gets
$this" table under it. Worth a note once this is resolved, or sooner.