Skip to content

Add native component lifecycle foundations - #342

Closed
shanerbaner82 wants to merge 4 commits into
mainfrom
agent/native-component-features
Closed

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

Conversation

@shanerbaner82

@shanerbaner82 shanerbaner82 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

This change gives native components one consistent path for method invocation, route binding, state mutation, render suppression, and component events.

It adds:

  • container injection plus implicit model and backed-enum binding for mount methods, actions, poll callbacks, and component-event listeners
  • typed public route-model properties, hydrated before mount runs
  • Illuminate route matching, constraints, optional parameters, explicit binders, custom route keys, scoped child bindings, URL decoding, prefixes, and query-string separation
  • a shared dotted-property mutation pipeline with locking and deterministic updating/updated hook order
  • one-shot render suppression through skipRender() and #[Renderless]
  • component events with bubbling, self-only delivery, class targeting, listener injection, and test assertions
  • dotted native:model compilation through view data, including nested Blade partials
  • test-harness support for the same runtime behavior

This change intentionally does not introduce a form or data-validation API.

Public API examples

Route-bound mount dependencies

Route::native('/counter/{click}/{section?}', CounterWithClick::class)
    ->whereNumber('click');

class CounterWithClick extends NativeComponent
{
    public Click $click;

    public function mount(
        Click $click,
        CounterService $service,
        string $section = 'overview',
    ): void {
        // $this->click is already hydrated from the route.
        $this->count = $click->count;
    }
}

NativeComponent deliberately does not declare a fixed mount() signature, so application components can define the parameters they need.

Action dependencies

public function inspect(
    Click $click,
    CounterService $service,
    DemoMode $mode,
): void {
    // Scalar interaction arguments are converted to declared scalar types.
}

Protected poll callbacks with dependencies

#[Poll(30_000)]
protected function refreshStatus(StatusService $service): void
{
    $this->status = $service->current();
}

Poll callbacks use the internal lifecycle invocation path, so they may remain protected while still receiving container dependencies.

Nested state hooks

<outlined-text-input native:model="profile.name" />
public function updatingProfileName(mixed $value, ?string $key): void
{
    // Runs before assignment.
}

public function updatedProfileName(mixed $value, ?string $key): void
{
    // Runs after assignment.
}

Render suppression and component events

#[Renderless]
public function incrementQuietly(): void
{
    $this->count++;
}

public function notify(): void
{
    $this->dispatch('count-changed', count: $this->count)->self();
}

#[On('count-changed')]
public function recordChange(int $count, AuditService $service): void
{
    // Listener dependencies are resolved by the container.
}

Safety and compatibility

  • only public application-defined actions are callable from interactions
  • lifecycle hooks and inherited framework internals remain protected
  • direct template calls to navigate(), replace(), back(), exitToWeb(), and emit() remain accepted for existing applications; new navigation bindings should use @navigate
  • component PHP may continue calling navigation and event methods normally
  • route-match predicates never execute parameter binders; actual navigation resolution still does
  • route-binding failures during navigate or replace render on the current screen and stay inside its lifecycle
  • literal routes retain priority over parameterized routes regardless of registration order
  • route matching caches compiled patterns while bound parameters remain isolated between navigations
  • request routing uses the normalized path, including subdirectory deployments and query strings
  • compatible scalar route values hydrate typed public properties; incompatible values retain PHP's TypeError contract
  • native event payloads retain explicit scalar conversion before dependency resolution
  • pure enums are never treated as implicitly bindable; backed enums retain value binding
  • soft-deletable and scoped child binding paths follow the route and model capabilities
  • missing implicit model bindings fail with the normal model-not-found exception
  • only public properties participate in external state synchronization, and locked public properties reject updates
  • queued component events flush before final navigation frames are published and before navigate, back, or replace intents are consumed, so listener mutations reach the outgoing frame
  • a nested renderless event listener cannot suppress the frame required by the interaction that dispatched it, while an explicit listener call to skipRender() remains authoritative
  • protected poll callbacks retain both container injection and #[Renderless] behavior
  • poll and system-back listener failures render through the active screen's error lifecycle instead of unwinding the run loop
  • invalid backed-enum action arguments fail with the same binding exception used by route parameters
  • a skipped render preserves the callback registry for the still-visible native tree
  • all lifecycle entry points route through the same mount dispatcher

Verification

  • full Pest suite completed successfully with 3,154 assertions
  • broader Edge, routing, navigation, and test-harness suite: 145 tests, 711 assertions
  • focused regression suite: 77 tests, 405 assertions
  • GitHub Actions at a54533e: PHP 8.4 tests, static analysis, and code style passed
  • targeted local static analysis: no errors
  • Pint: passed
  • scratch feature and navigation suite: 15 passed, 93 assertions
  • scratch registered-screen smoke suite: 35 passed, 105 assertions
  • all scratch Blade templates compiled successfully
  • patch, filenames, branch name, commit text, and PR copy scanned case-insensitively for prohibited comparison branding: no matches

Scope

The branch contains the feature foundation followed by three focused compatibility and regression-hardening commits. The scratch application and its Composer lockfile are intentionally not part of this repository diff.

@shanerbaner82
shanerbaner82 force-pushed the agent/native-component-features branch from 61122cd to ce63d6f Compare August 16, 2026 05:26
@simonhamp

Copy link
Copy Markdown
Member

@shanerbaner82 if this is ready for review, it should move out of Draft

@shanerbaner82

Copy link
Copy Markdown
Contributor Author

Closing in favor of #343 — same lifecycle foundations, without skipRender / #[Renderless].

That flag is still a screen-wide one-shot boolean: it can eat the first mount frame, skip every poll in a tick, and let a child skip the root. Not safe to ship on this branch. skipRender can come back as a small follow-up once it has per-invocation provenance.

Don't merge both. @simonhamp this was the one you pinged; #343 is the review target.

shanerbaner82 added a commit that referenced this pull request 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>
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