Skip to content

Fluent native callbacks (then()/photoTaken()) bind to the screen, not the child component that registered them #345

Description

@shanerbaner82

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):

  1. Thread an owner reference from the facade / pending builder into NativeCallbacks::register(), which today receives only id, eventClass, callback.
  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions