Skip to content

State and lifecycle

PAM keeps the PHP runtime and component instances alive. State mutations schedule a new render, Rust compares the resulting element tree, and each platform renderer receives only the necessary mutation batch.

Single-file components can mark mutable properties with #[State]:

final class Counter extends Component
{
#[State]
public int $count = 0;
public function increment(): void
{
$this->count++;
}
}

State belongs to a stable component identity. Reordering repeated children without keys may transfer identity to the wrong item. Use a stable key whenever children can be inserted, removed, sorted, or moved.

Public constructor parameters are component props. PAM updates mutable public props on a stable child instance.

A private or readonly constructor value cannot be updated safely. When it changes, PAM remounts that child intentionally.

Components may react to mount, updated props/state, and unmount through the component lifecycle contract. Keep lifecycle work bounded:

  • start subscriptions or resources at mount;
  • update only when relevant values changed;
  • release resources at unmount;
  • cancel late asynchronous work; and
  • never manipulate Android Views from the PHP worker.
PHP component state
│ render
Element tree
│ PNT1 first frame / PNP1 updates
Rust reconciliation and layout
│ PNB1 mutation batch
Kotlin or Swift frame queue
│ one UI commit per VSYNC
Android Views or UIKit controls

The first render is a complete tree. Later renders use numeric create, remove, property-update, move, and set-root operations.

Node IDs are stable 64-bit hashes of keyed PHP identities. Reused immutable subtrees are cached through WeakMap; returning the same tree can produce no native commit.

Paint-only changes can skip layout. Dimensions, flex rules, gaps, margins, min/max constraints, and alignment invalidate the relevant layout path.

The renderer applies only layout IDs emitted by Rust instead of rescanning every Android View.

High-frequency interaction remains native on both renderers:

  • input text, cursor, and selection;
  • press feedback and long-press recognition;
  • scroll and fling progress;
  • list recycling and prefetch on Android;
  • modal and navigation animations;
  • image fetch, decode, and caching;
  • safe-area and IME calculations; and
  • transform and opacity animations.

PHP receives semantic events according to the chosen synchronization policy. On iOS, scroll, dimensions, image progress, input selection, input content size, and pointer-move events are coalesced before the next CADisplayLink commit.

Text remains in the native input while the user types. Change delivery defaults to a 48 ms debounce and can be configured as:

Mode PHP synchronization
Native No per-keystroke state synchronization
Debounced Coalesced after a short quiet period
Immediate Every accepted change
Blur When the input loses focus
Submit When the input submits

Selection events are coalesced to VSYNC. Key, content-size, end-editing, and other detailed events are opt-in.

The Swift renderer installs and removes event bridges with the node lifecycle:

  • onPressIn, onPressOut, and onPressMove use one zero-delay native recognizer;
  • onInputEndEditing, onInputSelectionChange, onInputContentSizeChange, and onInputKeyPress stay attached to PamInputField;
  • modal requestClose, show, dismiss, and orientationChange callbacks are detached when the node is removed;
  • image loading emits start, progress, load or error, and load-end in lifecycle order;
  • refresh and drawer callbacks are owned by their native containers; and
  • scroll listeners support onScroll and onEndReached independently.

onEndReached uses property ID 84 as a non-negative viewport-relative threshold, defaults to 0.5, supports both axes, emits once for a content/viewport state, and rearms when either size changes.

Rust-owned PNB1 buffers cross JNI through direct, read-only ByteBuffer instances. Kotlin accepts ownership, applies the batch, and releases the buffer. Shutdown drains queued and accepted buffers before destroying the renderer.

Debug output exposes the number of retained buffers; it must return to zero after a completed frame.