Verified against Semitexa Ultimate 2026.09.19.1020
UI Events and Transport
Events (#[UiOn])
#[UiOn] declares which component method handles a given (part, event) pair. The browser captures the event, UiInteractionDispatcher verifies it and invokes the method on the server.
use Semitexa\PlatformUi\Attribute\UiOn;
use Semitexa\PlatformUi\Attribute\UiPart;
use Semitexa\PlatformUi\Domain\Model\Event\UiInteractionEvent;
#[UiPart(name: 'input', uses: InputPrimitive::class, bind: 'value')]
final class FieldComponent
{
#[UiOn(part: 'input', event: 'change')]
public function onInputChanged(UiInteractionEvent $event): void
{
$value = $event->value();
}
}
Attribute shape
#[Attribute(Attribute::TARGET_METHOD)]
final class UiOn {
public function __construct(
public string $part, // must reference a #[UiPart] on the same class
public string $event, // /^[a-z][a-z0-9:_-]*$/
public ?string $updates = null, // optional UiValuePath; inherits from part.bind when omitted
) {}
}
Valid event-name examples: change, input, click, blur, focus, submit, value:change.
Rejected: empty string, uppercase, spaces, onclick(), {{ value }}, brackets, quotes, digit-first.
updates vs bind (strict-compatibility mode):
| part.bind | updates argument | resolved updatesPath |
|---|---|---|
value |
omitted | value (inherited) |
value |
value |
value |
value |
other.path |
error — strict mismatch |
user.email |
omitted | user.email (inherited) |
| (none) | omitted | null |
| (none) | value |
value |
When the part declares bind: '<path>' and the handler declares updates: '<other-path>', the factory rejects the registration. This keeps the model deterministic until a future slice introduces multi-path updates.
Validation rules (enforced at metadata extraction):
partmust match an existing#[UiPart]on the same component.eventmust match/^[a-z][a-z0-9:_-]*$/.updatesmust parse viaUiValuePathwhen provided.updatesmust equalpart.bindwhen both are present (strict mode).- Each
(part, event)pair is unique within one component. - The handler method must be
public, non-static, non-abstract. - One
#[UiOn]per method.
Metadata model
UiOnMetadata records componentName, class, partName, eventName, updatesPath (a UiValuePath or null), methodName. It carries NO transport URL and NO handler-id, and neither may leak into the DOM. Access via UiComponentMetadata::event($partName, $eventName), ::eventsForPart($partName), or the events map.
Twig helper for debug surfaces: ui_component_events('<component-name>') returns a list of plain arrays ({part, event, updates, method, runtime}) for documentation panels. The helper is read-only and does NOT emit any DOM event-runtime attributes.
Signed event manifest (render-time, inert)
Every Platform UI component render emits a per-instance signed event manifest as inert JSON. The manifest is built from the component's #[UiOn] metadata; each entry is signed with SSR's existing SignedContext substrate (sc1.<base64url-claims>.<base64url-hmac> over canonicalized JSON, HMAC-SHA256, TTL-bound). The signing secret is shared with the rest of the framework's signed-context substrate (APP_SECRET, with dev-mode fallback to a derivative of APP_NAME|APP_HOST|APP_PORT).
What lands in the DOM:
<div data-ui-component="platform.field"
data-ui-component-instance-id="uci_4f8a…"
ui-component="field" sx-layout="stack" sx-gap="1">
…input + label + help/error…
<script type="application/json"
data-ui-event-manifest="uci_4f8a…"
data-ui-component="platform.field">
{
"v": 1,
"c": "platform.field",
"i": "uci_4f8a…",
"events": [
{ "p": "input", "e": "change", "u": "value", "ctx": "sc1.<b64>.<hmac>" }
]
}
</script>
</div>
What stays server-side: the method name (onInputChanged), the class FQCN (Semitexa\PlatformUi\Application\Component\Builtin\FieldComponent), and any handler resolution id. UiInteractionDispatcher resolves the method via UiComponentRegistry::get($c)->event($p, $e)->methodName — clients can never coerce a different method.
Signed-claim payload (decoded ctx):
{
c: string, // component canonical name
i: string, // per-render instance id
p: string, // part name
e: string, // event name
u: string, // updates path — present only when the part declares bind
iat: int, // issued-at (unix seconds, added by SignedContext)
exp: int // expires-at (iat + ttl, default ttl = 300s)
}
Helpers
ui_component_instance(): string— returns a freshuci_<16hex>per render. Stamp it once at the top of a component template and pass it intoui_event_manifest()and the rootdata-ui-component-instance-idattribute.ui_event_manifest(instanceId, ttlSeconds = null): Markup— emits the<script type="application/json" data-ui-event-manifest="…">block for the component currently rendering. Reads the component identity from Twig's_componentcontext; throws if called outside a Platform UI component. Returns an emptyevents: []array when no#[UiOn]declarations exist.
Render-time service
UiEventManifestBuilder::build(UiComponentMetadata $metadata, string $instanceId, ?int $ttlSeconds = null): UiEventManifest — pure, stateless. Calls SignedContext::sign() once per #[UiOn]. Returns a UiEventManifest value object with toJsonShape() for serialization.
Inert by construction
- No
<script>ever contains executable JavaScript — the type isapplication/json, browsers parse it as data. - No
onclick/onchange/oninput/data-ui-handler/data-ui-event-urlattributes are emitted anywhere. - No method name or class FQCN appears in the rendered HTML.
Frontend event runtime (capture-only)
Shipped in this slice. The runtime is a tiny IIFE (packages/semitexa-platform-ui/src/Application/Static/js/event-runtime.js) loaded globally via the asset manifest with defer. It scans the DOM for <script type="application/json" data-ui-event-manifest> blocks emitted by the server, attaches one document-level capture-phase delegated listener per distinct native event name across all manifests, and captures matches locally. Capturing on its own sends nothing; the transport bridge is what posts a captured event to HUG.
What the runtime does on every captured event:
- Walks up from
event.targetto the nearest[data-ui-component-instance-id]root. - Looks up the manifest for that instance id.
- Iterates manifest entries; for each entry whose
ematches the native event name, finds the part element. Lookup order:[data-ui-part="<part-name>"](canonical, injected byui_part()) →[ui="<part-name>"](back-compat for templates that still callprimitive()directly withui_part_props()). - Builds a
capturedpayload (see shape below). - Dispatches a
semitexa:ui-event:capturedCustomEvent ondocumentwithdetail = captured. - Invokes every
window.SemitexaUi.onCapture(fn)listener.
What it does NOT do:
- No
fetch/XMLHttpRequest/navigator.sendBeacon/WebSocket/EventSource. - No signature verification —
ctxis treated as opaque and passed through verbatim. - No
preventDefault/stopPropagation— native browser behavior is preserved. - No DOM mutation, no state changes, no UI patching.
Public API:
window.SemitexaUi.version // '1.0'
window.SemitexaUi.scan(root?) // manual rescan; also auto-runs on DOMContentLoaded + MutationObserver
window.SemitexaUi.manifests() // snapshot list of parsed manifests on the page
window.SemitexaUi.onCapture(fn) // register listener; returns unsubscribe()
Captured payload shape:
{
component: 'platform.field',
instanceId: 'uci_<16hex>',
part: 'input',
event: 'change',
updates: 'value', // or null when the part is unbound
ctx: 'sc1.<b64>.<b64>',// opaque signed blob — exactly what the server receives
value: <part.value>, // extracted from partEl.value or the value attribute
originalEvent: <DOM Event>,
manifestVersion: 1
}
Part-element lookup: the runtime first looks for [data-ui-part="<part-name>"] inside the component root (canonical — emitted by the server-side ui_part() Twig helper), then falls back to [ui="<part-name>"] for legacy templates that render the primitive directly via primitive() + ui_part_props(). The canonical path decouples the UiPart name from the primitive's ui alias, so a part can be named freely (e.g. UiPart(name: 'main', uses: InputPrimitive::class)) without breaking the runtime.
Rows that come and go: a re-render morphs the DOM and pairs elements by id, then data-key, then data-ui-part, and otherwise by position. A part repeated across rows is paired by position, so removing a row moves a half-typed draft and the caret onto the next one, or loses them. Give each such row a stable key, unique among its siblings. An id on the row must come from the same stable value, not from its position, because id is paired first:
{% for line in lines %}
<li data-key="{{ line.id }}">...</li>
{% endfor %}
A row that remains keeps its element and takes the server's markup. Only the control with focus keeps what was typed: a draft in any other field is replaced by the value the server sends, so a value worth keeping has to reach the server first. A row that moves keeps its focus without firing a #[UiOn] handler.
KISS and HUG: the whole transport
Two doors carry every byte between a page and the server, by design:
| Door | Route | Direction | Carries |
|---|---|---|---|
| KISS | GET /__semitexa_kiss |
server → browser | the one SSE stream per page: deferred slots, UI patches, component state, live feeds |
| HUG | POST /__semitexa_hug |
browser → server | every UI event and action (the envelope below), feed control as {"stream": {…}}, and file uploads as multipart {upload, file} (see UI Forms → Uploads) |
| HUG | GET /__semitexa_hug |
browser → server | the pull fallback for deferred slots when a page has no stream |
A feature that needs a verb HUG lacks extends HUG — it does not add a route. (/__ui/event, /__ui/dispatch, /__semitexa_component_event and /__ui/form-doc were removed in 2026-10 for exactly that reason, and the {"componentEvent"} body with them: a component's events are its #[UiOn] methods.)
Live feeds
A feed (a grid's collection, a calendar month, a collaborative document) is a
route with transport: TransportType::Sse and an explicit name — discovery
refuses an SSE route without one, and its OPTIONS contract carries it. A page
never opens a stream for a feed. The browser asks HUG to attach the feed to the
page's KISS stream, by name:
POST /__semitexa_hug
{"stream": {"op": "subscribe", "feed": "ui-playground.leads.feed",
"params": {"sort": "-createdAt"},
"session": "sse_<32 hex>", "subscriptionId": "sse_<32 hex>"}}
op is subscribe, view (the complete new view, for example after a sort
or a filter) or unsubscribe. HUG admits a subscribe or view exactly as a
direct GET of the feed with params as its query: the same auth gate,
hydration, validation and AuthCheck, with the caller's own cookies. The answer
is an acknowledgement, 202 {"ok": true, "accepted": true, "subscription_id": …};
rows only ever arrive on KISS, as the feed's typed frames
(ui.collection.data, ui.document.data, …) tagged with the subscription id.
An unknown name and a route that is not a feed both answer
404 {"reason": "unknown_feed"}, and the worker that owns the KISS stream
refuses a subscription from a different tenant (subscribe_tenant_mismatch).
A page without a KISS session, or whose KISS stream fails before it ever
connects, falls back to the feed's own plain GET. A feed that must not be
readable that way declares exposure: RouteExposure::Hug and no path: it is
reachable only by name, through HUG. The collaborative-form document feed
(platform-ui.form-doc) is one.
In the browser all of this is SemitexaUi.core.openFeedChannel({feed, params, dataEvent, errorEvent, onData, onError, onPull}), which returns
{mode(), view(params), close()}.
Input timing
#[UiOn] decides when the browser sends, never what the server accepts:
#[UiOn(part: 'query', event: 'input', debounce: 300)] // once the user pauses 300 ms, with the last value
#[UiOn(part: 'canvas', event: 'pointermove', throttle: 100)] // at most every 100 ms (first and last always sent)
Both take 1–10000 ms and cannot be combined. A form submit first sends every debounced event still waiting inside it, so the server validates what was actually typed. A morph never overwrites the value of the field that has focus.
Loading states
While an action is in flight, the runtime marks it — no markup needed:
- the component root gets
aria-busy="true"; - the part that fired it (and a submit's own button) gets
data-loading— aplatform.buttonshows its spinner after a 150 ms grace period, so a fast answer never flickers; - a submitting form turns its inputs readonly and its buttons disabled, and a second submit (double click, Enter) is dropped.
Everything else is declared with ui-loading (after Symfony UX):
ui-loading="…" |
While loading |
|---|---|
show |
the element is hidden until then |
hide |
hidden |
addClass(is-dim muted) / removeClass(x) |
classes added / removed |
addAttribute(disabled) / removeAttribute(x) |
attribute added / removed (never on*) |
action(save)|… / action(form.submit)|… |
only for that part (and event) |
delay|… / delay(500)|… |
only if it takes longer than 200 ms / 500 ms |
Several directives sit in one attribute, separated by spaces; action() and delay() bind the directive they lead. The action's own answer undoes all of it.
Optimistic updates
The answer an action expects can be drawn the moment it fires, with the same grammar:
ui-optimistic="…" |
When the action fires |
|---|---|
hide |
hidden |
text(Saved) |
its text set |
increment / increment(-1) |
its number moved by one (or by the step) |
addClass(is-on) / removeClass(…) |
classes added or removed |
addAttribute(a) / removeAttribute(a) / toggleAttribute(aria-pressed) |
attribute changes |
action(part) / action(part.event) |
scope the effect that follows to one part's action |
<output data-ui-patch-target="count" ui-optimistic="action(increment)|increment action(reset)|text(0)">{{ count }}</output>
- Accepted. The prediction stands, and the server's answer (a morph, a patch) puts the truth in place.
- Refused or unreachable. Every effect is undone, a toast says nothing was changed, and
semitexa:ui-optimistic:rolled-backis dispatched ondocument. - Grids. A delete is optimistic by default (
UiGridAction::$removesRows, set byCrudAction::delete()): the rows go the moment it is confirmed. They come back if the server refuses, or if the next frame still has them.
Nothing optimistic decides anything: it only draws early what the server is expected to say.
Sending a UI event (HUG)
Bridges captured frontend events to declared #[UiOn] / #[HandlesUiEvent] handlers. The handler can return a plain ack or a small list of safe DOM-patch instructions; patches addressed to a live component are published on KISS instead of returned inline.
Endpoint: POST /__semitexa_hug (HugEventPayload → HugEventHandler → UiResponseDispatcherInterface)
Request shape — the canonical envelope:
{
"schemaVersion": 1,
"eventId": "ui_evt_<32 hex>",
"correlationId": "ui_cor_<32 hex>",
"semanticEvent": "platform.field.change",
"signedContext": "sc1.<base64url-claims>.<base64url-hmac>",
"timestamp": "2026-10-04T10:00:00.000Z",
"payload": { "value": "taras@example.com" }
}
signedContextis required and is verified before anything else; an unverifiable one is refused with422and never reaches a dispatcher.eventIdis required. Must match[A-Za-z0-9][A-Za-z0-9_-]{4,127}. The frontend transport mints one fresh value per captured event withcrypto.getRandomValues(format:ui_evt_<32 hex>); it is the replay key's per-attempt half.payloadis optional and defaults to{}.payloadmust not carry any routing-flavored field. TheUiPayloadFieldGuardwalks the whole payload tree and rejects (400) on any key (normalized across camelCase/snake_case/kebab-case) matching:handler,handlerId,handlerClass,handlerMethod,method,methodName,class,className,component,componentName,instance,instanceId,part,partName,event,eventName,updates,updatesPath,endpoint,url,route,action,controller,callback,dispatcher,payloadClass,authzScope,backendHandler, plusdispatchId/requestId/eventId(those identifiers belong at the top level, not insidepayload).
Replay guard. The dispatcher keys an entry by sha256(signedContext) + ':' + eventId. The TTL is bounded by both the signed ctx's remaining lifetime and a server-side ceiling (currently 600s). A second request with the same (signedContext, eventId) pair returns 409 duplicate_dispatch. Crucially, the same context with a different eventId still works — the signed ctx is intentionally reusable inside its TTL so legitimate repeated user actions (e.g. successive change events on the same field) are not blocked. The replay guard claim is taken after ctx verification (so an invalid ctx never poisons the store) and before authorization (so a denied attempt still consumes its eventId — clients must mint a fresh id to retry).
Authorization hook. A pluggable UiInteractionAuthorizerInterface runs after the replay claim and before the #[UiOn] handler. The default AllowAllUiInteractionAuthorizer is wired by the package and allows every verified dispatch; apps swap it via withServices(authorizer: …) or a future container binding. A false return maps to 403 interaction_forbidden; the handler is never invoked and no patches are returned.
Success response (200): the canonical HUG envelope plus the dispatcher's result.
{
"status": "accepted",
"phase": "dispatch",
"reason": null,
"message": null,
"eventId": "ui_evt_<32 hex>",
"correlationId": "ui_cor_<32 hex>",
"semanticEvent": "platform.field.change",
"schemaVersion": 1,
"signedContext": { "present": true, "verified": true },
"kind": "ack",
"patches": [],
"debug": { "value": "taras@example.com", "instance": "uci_<hex>" },
"dispatchId": "ui_evt_<32 hex>"
}
Patches addressed to a component on a page with a live KISS stream are published there instead of returned inline (streamedPatchCount).
Refused before any dispatcher runs (422, {error, message, context: {errors}}): a body that is not a JSON object, a malformed envelope, a forbidden routing field anywhere in it, or a signedContext that does not verify (tampered or expired) — context.errors.signedContext names the last.
Refused by the dispatcher (safe JSON with a reason token; never class/method names or stack traces):
| Status | reason token |
Trigger |
|---|---|---|
| 400 | forbidden_payload_field |
Payload smuggled a routing-flavored key the envelope check did not cover (path included in message) |
| 403 | updates_path_mismatch |
Signed u claim doesn't equal the registered #[UiOn] updates path |
| 403 | interaction_forbidden |
UiInteractionAuthorizerInterface::authorize() returned false |
| 503 | ui_replay_store_not_shared |
Production-like env + the bound replay store reports isShared() === false. Operator must set CACHE_DRIVER to a shared driver (e.g. redis). |
| 404 | unknown_component |
Signed component doesn't exist in UiComponentRegistry |
| 404 | unknown_part |
Signed part doesn't exist on the component |
| 404 | unknown_event |
Signed (part, event) pair has no #[UiOn] |
| 409 | duplicate_dispatch |
(signedContext, eventId) already processed (replay guard) |
| 422 | missing_claim_<key> |
Signed context missing required claim |
| 422 | cannot_instantiate_component |
Component constructor requires DI args |
| 422 | handler_error |
Handler threw a non-UiInteractionException |
| 422 | invalid_handler_return |
Handler returned something other than void / array / UiInteractionResult |
| 422 | invalid_patch / invalid_patch_op / patch_instance_mismatch / invalid_patch_value / invalid_patch_attribute / invalid_patch_target_part / invalid_patch_target_name |
A handler returned a UiInteractionResult::patch(...) that fails server-side patch validation. Errors carry safe reason tokens — no class/method leaks. |
| 500 | internal_error |
Truly unexpected failure |
Additional forbidden payload keys for the response-patch slice (rejected with 400 forbidden_payload_field): patch, patches, target, selector, html, script.
UiInteractionDispatcher API:
final class UiInteractionDispatcher
{
public function __construct(
UiPayloadFieldGuard $payloadGuard = new UiPayloadFieldGuard(),
UiPatchValidator $patchValidator = new UiPatchValidator(),
UiReplayStoreInterface $replayStore = new InMemoryUiReplayStore(),
UiInteractionAuthorizerInterface $authorizer = new AllowAllUiInteractionAuthorizer(),
);
public function dispatch(string $ctx, string $dispatchId, array $payload): UiInteractionResult;
}
Trust boundary:
- The signed ctx is the only source of (component, instance, part, event, updates) identity. Mismatches between signed claims and registry metadata fail closed.
- The request body's
payloadis treated as arbitrary user data afterUiPayloadFieldGuardhas scrubbed any routing-flavored keys (the guard runs before signature verification so a malformed/tampered ctx still emits a 400 if the payload tries to smuggle a handler). dispatchIdis client-supplied and used only for replay-key construction — handlers see it onUiInteractionEvent::$dispatchIdfor correlation but MUST NOT base authorization or routing decisions on it.- When the handler returns
UiInteractionResult::patch([...]), every patch is validated against the signed claims'instance— handlers can only patch their own component instance.
Service bindings (default). Platform UI ships three #[SatisfiesServiceContract]-bound defaults:
| Interface | Default implementation | Module |
|---|---|---|
UiReplayStoreInterface |
CacheBackedUiReplayStore |
semitexa-platform-ui |
UiInteractionAuthorizerInterface |
AllowAllUiInteractionAuthorizer |
semitexa-platform-ui |
UiFieldRuleRegistryInterface |
DefaultUiFieldRuleRegistry |
semitexa-platform-ui |
The Semitexa container resolves both contracts at boot via ServiceContractRegistry. PlatformUiResponseDispatcher (behind HUG) declares them as #[InjectAsReadonly] protected properties — the container fills them; nothing news them up in production.
Override seam. An application registers its own implementation by declaring a class with #[SatisfiesServiceContract(of: UiInteractionAuthorizerInterface::class)] (or UiReplayStoreInterface::class) inside a module that "extends" semitexa-platform-ui. The contract registry picks the descendant-module winner, so the app's class replaces the default automatically — no per-handler wiring required.
Replay store implementations.
CacheBackedUiReplayStore— production default. Backed bySemitexa\Cache\Domain\Contract\CacheManagerInterfaceunder theui-dispatch-replaynamespace; inherits the cache's process-shared semantics.isShared()reportstruewhen the bound cache driver isredis,valkey, ormemcached. WithCACHE_DRIVER=array(the framework default), each Swoole worker has its own in-memory cache →isShared()reportsfalse→ the dispatcher refuses to invoke handlers in production-like environments.InMemoryUiReplayStore— test/dev fallback. Always reportsisShared() === false. Used only by tests that dispatch without a container (tests/Support/HugDispatch). Apps and modules MUST NOT wire this with#[SatisfiesServiceContract]; it carries no such attribute on purpose.
Runtime guard. Before claiming a replay key, UiInteractionDispatcher calls $replayStore->isShared(). In production-like environments (APP_ENV is prod or production), a false return aborts the dispatch with 503 ui_replay_store_not_shared. The handler is never invoked. In other environments (dev, staging, test, …) the guard is a no-op so local development with the in-memory store continues to work. The check runs after ctx verification (a tampered context never gets this far: HUG refuses it with 422) and before the replay claim (so an unsafe store never accumulates orphan keys).
Why signed ctx is reusable but dispatchId is single-use: the signed ctx carries identity (c, i, p, e, u, iat, exp) so the dispatcher can resolve handlers — re-issuing it on every keystroke would force a server round-trip per character. The dispatchId is the attempt identifier and exists exclusively for replay deduplication: each captured event mints a fresh crypto.getRandomValues-derived id, so a network race or double-click produces two distinct ids and both succeed, but an exact replay (ctx, dispatchId pair) is rejected at the replay claim.
Why shared replay cache is required: with CACHE_DRIVER=array, two requests carrying the same (ctx, dispatchId) that land on different Swoole workers both see "no claim yet" in their per-worker arrays and both succeed. With CACHE_DRIVER=redis (or valkey/memcached), the claim is observable across every worker on the same node. The shipped configuration on semitexa-pl sets CACHE_DRIVER=redis in .env for this reason.
UiInteractionEvent DTO passed to handlers:
final readonly class UiInteractionEvent
{
public string $componentName;
public string $instanceId;
public string $partName;
public string $eventName;
public ?UiValuePath $updatesPath;
public array $payload; // already guard-scrubbed
public int $issuedAt;
public int $expiresAt;
public array $claims; // raw signed claims (server-side only)
public string $dispatchId; // per-attempt id; correlation only — do NOT route on it
}
UiInteractionResult DTO:
final readonly class UiInteractionResult
{
public const KIND_ACK = 'ack';
public const KIND_PATCH = 'patch';
public string $kind;
public array $debug;
/** @var list<UiResponsePatch> */
public array $patches;
public static function ack(array $debug = []): self;
public static function patch(array $patches, array $debug = []): self; // empty list ↦ kind=ack
}
Handlers may also return void (mapped to ack()), a bare array (mapped to ack($array)), or an explicit UiInteractionResult.
UiResponsePatch DTO:
final readonly class UiResponsePatch
{
public const OP_SET_TEXT = 'setText';
public const OP_SET_VALUE = 'setValue';
public const OP_SET_ATTRIBUTE = 'setAttribute';
public const ALLOWED_OPS; // [setText, setValue, setAttribute]
public const ALLOWED_ATTRIBUTES; // [aria-invalid, aria-describedby, data-state, ui-state]
public string $op;
public string $targetInstance; // must match signed claims' instance
public ?string $targetPart; // resolved as [data-ui-part="<name>"] inside the root
public ?string $targetName; // resolved as [data-ui-patch-target="<name>"] inside the root
public mixed $value; // scalar / null
public ?string $attribute; // setAttribute only; must be in ALLOWED_ATTRIBUTES
}
Targeting addresses (ALL scoped to a single component instance — no global/document selectors):
| target shape | resolves to |
|---|---|
{ instance } |
the component root |
{ instance, part } |
[data-ui-part="<part>"] inside the root |
{ instance, name } |
[data-ui-patch-target="<name>"] inside the root |
UiPatchValidator (server-side) enforces every rule above and ALSO that target.instance === claims.i. The frontend re-checks the same invariants before mutating the DOM.
FieldComponent handler (returns a server-ack patch):
#[UiOn(part: 'input', event: 'change')]
public function onInputChanged(UiInteractionEvent $event): UiInteractionResult
{
return UiInteractionResult::patch(
patches: [
new UiResponsePatch(
op: UiResponsePatch::OP_SET_TEXT,
targetInstance: $event->instanceId,
targetPart: null,
targetName: 'server-ack',
value: 'Server received: ' . (string) $event->value(),
),
],
debug: ['value' => $event->value(), 'instance' => $event->instanceId],
);
}
The server-ack <span data-ui-patch-target="server-ack"> is opt-in per render — only emitted when the caller passes showServerAckTarget: true to component('platform.field', ...), which also signs cfg.ack into the field's context. The handler echoes the value only for such a field: a field without the target never gets the patch, so the typed value does not travel back for nothing.
Response JSON (success with patches):
{
"ok": true,
"handled": true,
"kind": "patch",
"component": "platform.field",
"instance": "uci_<hex>",
"part": "input",
"event": "change",
"updates": "value",
"debug": { "value": "taras@example.com", "instance": "uci_<hex>" },
"patches": [
{
"op": "setText",
"target": { "instance": "uci_<hex>", "name": "server-ack" },
"value": "Server received: taras@example.com"
}
]
}
For ack-only responses the patches field is [] and kind stays "ack".
Frontend transport bridge
window.SemitexaUi.transport.attach() subscribes the capture pipeline to HUG. The runtime calls it for you as soon as it has parsed at least one event manifest — at page load, or later when a manifest arrives with a deferred component, a navigation swap or a morph — so a captured event on such a page is posted to /__semitexa_hug. A page with no manifest never attaches and makes zero network requests: fetch( lives only inside transport.attach's closure body. Calling attach() again for the same endpoint is a no-op that returns the existing detach, so a page that also attaches by hand does not send each event twice. To keep a page from attaching automatically, set window.SEMITEXA_UI_DISABLE_AUTOATTACH = true before the runtime script loads.
const detach = window.SemitexaUi.transport.attach(); // posts to /__semitexa_hug
// later: detach();
Wire body sent on every capture: exactly the envelope above, with payload: { value } (plus payload.form.values inside a form). The eventId is freshly minted per captured event with crypto.getRandomValues (format: ui_evt_<32 hex>), so a network race or double-click produces two distinct ids and both go through; only an exact (signedContext, eventId) replay is rejected with 409. Never component, instance, part, event, handler, method, class, endpoint, url, action, dispatcher fields.
Lifecycle CustomEvents on document (every detail carries dispatchId for correlation):
semitexa:ui-event:dispatching(before fetch;detail = {captured, dispatchId, endpoint})semitexa:ui-event:dispatched(on 2xx;detail = {captured, dispatchId, status, response})semitexa:ui-event:failed(on non-2xx or thrown;detail = {captured, dispatchId, status?, response?, error?, phase})semitexa:ui-patch:applied(one per successfully applied response patch;detail = {patch, captured, index})semitexa:ui-patch:failed(one per response patch that could not apply;detail = {patch, captured, index, reason})
The bridge does not call preventDefault / stopPropagation.
Frontend response-patch applier
When the server response includes a non-empty patches array, the bridge runs each patch through a tight safe applier with these invariants:
- Allowed ops are exactly
setText,setValue,setAttribute. Anything else →semitexa:ui-patch:failedwithreason: "invalid_op". setAttributeis gated by thearia-invalid/aria-describedby/data-state/ui-stateallow-list.- The component root is located by
document.querySelector('[data-ui-component-instance-id="<safe-id>"]'). The patch target is then resolved byrootEl.querySelector(...)for[data-ui-part="<safe-name>"]or[data-ui-patch-target="<safe-name>"]. The applier never accepts a caller-provided CSS selector and never traverses outside the component root. - The applier uses
textContent,element.value,setAttribute/removeAttributeonly. It never touchesinnerHTML,outerHTML,insertAdjacentHTML,document.write,eval, or theFunctionconstructor. - Patch identifiers (instance, part, name) must match
/^[A-Za-z_][A-Za-z0-9_-]*$/. Anything else fails before any DOM lookup. - One failed patch never breaks the rest of the batch — each patch fires its own
:appliedor:failedevent. - The bridge also double-checks
target.instance === captured.instanceIdbefore applying — defense in depth against a tampered response.
SSE server-push channel
Retired. The standalone platform-ui patch-stream subsystem (its own route, channel-token auth, per-channel Redis queue, connection limiter and subscription authorizer) has been removed. All UI streaming now rides the single canonical KISS stream
GET /__semitexa_kiss(served by SSR'sAsyncResourceSseServer::handleSse). The page opens that one stream viaui_page_sse_session_meta(...); components never open their own.
What survives is the part that was always shared: there is one patch shape
(UiResponsePatch) and one safe applier on the frontend. Server-pushed
patches arrive as canonical typed ui.patch frames on the KISS EventSource
and flow through the same applyOnePatch engine the request/response transport
uses. The frontend bridge (window.SemitexaUi.sse.attach({url})) is only ever
attached to a /__semitexa_kiss URL; it still emits the semitexa:ui-sse:*
lifecycle CustomEvents on document. The retired {v, patches[]} envelope is
tolerated defensively but is no longer produced.