Add native component lifecycle foundations - #343
Open
shanerbaner82 wants to merge 4 commits into
Open
Conversation
This was referenced Aug 25, 2026
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
force-pushed
the
agent/native-component-core
branch
from
August 25, 2026 21:59
ca97e36 to
99a777b
Compare
shanerbaner82
marked this pull request as ready for review
August 25, 2026 22:01
simonhamp
approved these changes
Aug 26, 2026
simonhamp
left a comment
Member
There was a problem hiding this comment.
A bit too big, separate these kinds of things into distinct PRs in future. I agree that the breaking changes shouldn't affect anyone
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
maininvokes template-bound callbacks as$this->$method(...$args)with no callability check at all.@tap="deleteAccount"will happily call aprotectedmethod, 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
mainmount()#[Locked]updating*/updated*isNativeRoute()can thrownative:modelinside a nested Blade partialThis does not add a form or validation API, and it does not include
skipRender()/#[Renderless].Breaking changes
Four patterns that work on
mainfail 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).mainprotected/privatemethod bound to@tapComponentMethodNotFoundExceptionpublicpublic function updatedFilters()bound to a callbackDirectlyCallingLifecycleHooksNotAllowedExceptionupdating*/updated*/hydrate*/dehydrate*prefixparent::mount()inside a component'smount()NativeComponent::mount()no longer existsparent::mount()callprotected function dispatch(array $event)public dispatch(string $event, mixed ...$params): ComponentEventdispatchUiEvent();dispatch()is the component-event APINote 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:Overriding
mount()with any signature is otherwise fine and strictly more permissive thanmain, 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/@changeagainst protected and lifecycle-prefixed declarations: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
ComponentMethodInvokerComponentRouteBinderComponentEventdispatch()with#[On]listeners, self-targeting and class-targeted delivery.ComponentState#[Locked]andupdating*/updated*hooks.NativeRouterisNativeRoute()cannot throw; binding failures stay in the screen error lifecycle.mount().native:modelHardening on this branch
#[On]delivery no longer runs the lifecycle-name guard, so a listener namedupdated*/mountcan actually run.Native::test()->call('navigate')(andemit/back/replace) matches the device path.Not in this PR
#[Renderless]andskipRender(). 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.mdand the file headers.Verification
CI at
99a777b: Tests PHP 8.4, Static Analysis, and Code Style all passed.Follow-ups after this lands
native:modelcompiles every path throughdata_get(get_defined_vars(), …)(full symbol-table copy per bound input; typos become silent null). Prefer$propfor single-segment paths.class_uses_recursive()instead of reflecting on every tap.{slug?}) becomes redundant once this is on main.