Skip to content

Add native component lifecycle foundations - #343

Open
shanerbaner82 wants to merge 4 commits into
mainfrom
agent/native-component-core
Open

Add native component lifecycle foundations#343
shanerbaner82 wants to merge 4 commits into
mainfrom
agent/native-component-core

Conversation

@shanerbaner82

@shanerbaner82 shanerbaner82 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Why this exists

Four things a native component does — invoking a method from a template, binding a route model, mutating state, and firing an event — are four unrelated code paths on main. Nothing shares a contract, so every capability got bolted on where it happened to be needed.

The concrete result is that main invokes template-bound callbacks as $this->$method(...$args) with no callability check at all. @tap="deleteAccount" will happily call a protected method, a lifecycle hook, or anything else that exists on the class. There is no seam to hang DI, binding, or validation off, because there is no single place where invocation happens.

That is the actual problem. Everything below follows from fixing it.

What you can't do today

main after
Type-hint a model or service in mount() not injected container-resolved
Type-hint a routable model on an action you fetch it by hand implicitly bound (incl. soft-deletes and child bindings)
Protect a property from client mutation nothing stops it #[Locked]
React to a property changing no hook updating* / updated*
Hit a route whose binding fails isNativeRoute() can throw stays inside the screen error lifecycle
Bind native:model inside a nested Blade partial doesn't resolve resolves against view data

This does not add a form or validation API, and it does not include skipRender() / #[Renderless].

Breaking changes

Four patterns that work on main fail after this lands. All four fail loudly at runtime, on screens that previously worked, so they are worth a CHANGELOG entry.

ComponentMethodInvoker::ensureCallable() is the new check, and that tightening is the source of (1) and (2).

# Pattern on main After this PR Fix
1 protected / private method bound to @tap ComponentMethodNotFoundException make the method public
2 public function updatedFilters() bound to a callback DirectlyCallingLifecycleHooksNotAllowedException rename off the updating* / updated* / hydrate* / dehydrate* prefix
3 parent::mount() inside a component's mount() fatal — NativeComponent::mount() no longer exists delete the parent::mount() call
4 overriding or calling protected function dispatch(array $event) fatal — now public dispatch(string $event, mixed ...$params): ComponentEvent the old UI-event method is dispatchUiEvent(); dispatch() is the component-event API

Note that (3) breaks in both directions: a component written against this branch — mount() with arguments — is a fatal declaration error on a pre-343 package:

Declaration of App\NativeComponents\CounterWithClick::mount(Click $click, ParityDemoService $service, string $section = 'overview'): void
must be compatible with Native\Mobile\Edge\NativeComponent::mount(): void

Overriding mount() with any signature is otherwise fine and strictly more permissive than main, since there is no longer a base declaration to match.

Measured blast radius

Scanned every NativePHP app on hand — native, demo, xclone, tesla, flighty — matching method names referenced from @tap / onPress / @change against protected and lifecycle-prefixed declarations:

App Components (1) (2) (3)
native 129 0 0 0
demo 33 0 0 0
xclone 6 0 0 0
tesla 3 0 0 0
flighty 6 0 0 0

177 components, zero hits. Ordinary public actions — what nearly everyone writes — are unaffected.

One open question for review

(1) is the one most likely to exist in the wild, and it is the least deliberate part of the tightening: rejecting a non-public method is a different intent from rejecting a lifecycle name. Restricting the guard to lifecycle names only would keep the intent and remove most of the upgrade risk. Flagging it as a design call rather than changing it unilaterally.

What's here

Area Change
ComponentMethodInvoker Single DI-backed invocation path. Implicit binding for routable models and backed enums; lifecycle hooks protected from direct invocation.
ComponentRouteBinder Implicit route-model binding, including soft-deletable and child bindings.
ComponentEvent dispatch() with #[On] listeners, self-targeting and class-targeted delivery.
ComponentState Shared property-sync pipeline with #[Locked] and updating* / updated* hooks.
NativeRouter Match/resolve split so isNativeRoute() cannot throw; binding failures stay in the screen error lifecycle.
Mount DI Container injection into mount().
native:model Resolves against view data so nested Blade partials bind.

Hardening on this branch

  • Empty string binds as null for nullable parameters (cleared select/radio vs backed enums).
  • #[On] delivery no longer runs the lifecycle-name guard, so a listener named updated* / mount can actually run.
  • Native::test()->call('navigate') (and emit / back / replace) matches the device path.
  • A throwing poll fails once instead of re-painting the error overlay every tick.

Not in this PR

#[Renderless] and skipRender(). They are new relative to main, so omitting them drops no existing capability. The flag is still a screen-wide one-shot boolean (mount can eat the first frame, one renderless poll can skip a sibling, a child can skip the root). That needs per-invocation provenance; it should come back as a small follow-up, not ride this branch.

Attribution

Portions of the invoker, state hooks, event targeting, and the lifecycle-hook exception are derived from Livewire (MIT, Copyright © Caleb Porzio). See THIRD-PARTY.md and the file headers.

Verification

CI at 99a777b: Tests PHP 8.4, Static Analysis, and Code Style all passed.

Follow-ups after this lands

shanerbaner82 and others added 3 commits August 25, 2026 17:55
Component features split out of #342, excluding render suppression.

Included:

- ComponentMethodInvoker: a single DI-backed invocation path for
  component interactions, with implicit binding for routable models and
  backed enums, and lifecycle hooks protected from direct invocation.
- ComponentRouteBinder: implicit route-model binding for components,
  matching Laravel's ImplicitRouteBinding including soft-deletable and
  child bindings.
- ComponentEvent: dispatch()/emit() with #[On] listeners, self-targeting
  and class-targeted delivery.
- ComponentState: shared property-sync pipeline with #[Locked] support
  and updated*/updating* hooks.
- NativeRouter: match/resolve split so isNativeRoute() cannot throw,
  route-binding failures contained in the screen error lifecycle, and
  compiled route patterns retained across calls.
- Mount dependency injection.
- NativeTagPrecompiler: native:model resolves against view data, fixing
  bindings inside nested partials.
- NativeServiceProvider: the HTTP fallback resolves the normalized
  request path.

Excluded, and left on #342:

- The #[Renderless] attribute and skipRender().

Both are new in #342 and have no counterpart on main, so nothing here
restores or preserves prior behavior. They are held back because the
one-shot, screen-wide render-suppression flag has no owner or scope,
which produced repeated frame-suppression defects across four review
rounds. That design is being resolved separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… poll errors

Four should-fixes found reviewing the lifecycle foundations, plus dead state
left behind when render suppression was split out.

- A cleared native control sends an empty string, not null. For a nullable
  binding that now resolves to null instead of throwing
  BackedEnumCaseNotFoundException. Invalid cases still throw.
- An #[On] listener names itself through the attribute, so it is never remote
  input. It no longer runs the guard that protects template- and
  device-supplied method names, which rejected a listener named updatedFoo().
- The runloop and the test harness now share one interaction entry point, so
  Native::test()->call('navigate') is accepted exactly where a template may
  call navigate(). Unknown methods are still rejected.
- Polls are parked while the error screen is up. A throwing poll callback
  previously fired on every idle tick and repainted the overlay each time.
- Removed nativeComponentEventListenerDepth, written but never read since
  render suppression moved out of this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4sQXRvhRt1LzUDFWWyS6P
ComponentMethodInvoker, ComponentState, ComponentEvent and
DirectlyCallingLifecycleHooksNotAllowedException are derived from Livewire,
which is MIT licensed. MIT requires the copyright notice to travel with the
code, so add a header to each file and a root THIRD-PARTY.md reproducing the
notice in full and mapping each file to its upstream class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4sQXRvhRt1LzUDFWWyS6P
@shanerbaner82
shanerbaner82 force-pushed the agent/native-component-core branch from ca97e36 to 99a777b Compare August 25, 2026 21:59
@shanerbaner82 shanerbaner82 changed the title Add native component lifecycle foundations (split from #342, without render suppression) Add native component lifecycle foundations Aug 25, 2026
@shanerbaner82
shanerbaner82 marked this pull request as ready for review August 25, 2026 22:01

@simonhamp simonhamp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit too big, separate these kinds of things into distinct PRs in future. I agree that the breaking changes shouldn't affect anyone

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants