feat: Server-side trigger API for client-side actions#24353
Closed
Artur- wants to merge 13 commits into
Closed
Conversation
Introduce com.vaadin.flow.component.trigger: a server-side API for wiring client-side actions to client-side triggers without a server round-trip, so each action runs inside the user-gesture DOM event handler that the browser API (clipboard, fullscreen, share, ...) requires. Slice 1 ships the public Trigger / Action / Output<T> contracts with extensible AbstractTrigger / AbstractAction / AbstractOutput<T> bases keyed by namespaced type ids, the per-element TriggerSupport server-side feature that holds bindings and emits client snapshots via Element.executeJs, and the first built-ins ClickTrigger, PropertyOutput and ClipboardCopyAction. A new flow-client Triggers.ts module installs window.Vaadin.Flow.triggers with a type-id registry that add-ons extend via @jsmodule. Subsequent slices will add shortcut triggers, server-side mirroring, the JS escape hatch and ServerCallbackAction wiring.
The public com.vaadin.flow.component.trigger package already uses a package-info.java with @NullMarked; mirror that on the internal package and drop the now-redundant class-level annotations from TriggerSupport, ConfigContext and ServerCallbackAction. Matches the convention in DESIGN_GUIDELINES.md ("Apply @NullMarked at the package level").
Record the trigger API plan — confirmed design decisions, architecture (server, client, wire format), public API sketch, the four-slice v0 plan with slice 1 (clipboard copy) marked done and slices 2-4 (disable-on-shortcut, JS escape hatch, server callback) pending, the extension model for apps and add-ons, deferred work (signal outputs, output composition, fluent shorthands) and the planned file map.
…Test The PR CI for slice 1 caught two regressions: - com.vaadin.flow.testutil.ClassesSerializableTest (run from FlowClassesSerializableTest, PolymerClassesSerializableTest, DataSerializableTest) requires every server-package interface and class to extend Serializable. ConfigContext was the only missing one. - NodeFeatureTest.testGetIdValues and NodeFeatureTest.priorityOrder enumerate every registered NodeFeature; add TriggerSupport to both expected lists. No production behaviour change beyond adding Serializable to the ConfigContext marker.
The class-level Javadoc still pointed at the no-arg overload that was renamed to buildClientConfig(ConfigContext) before slice 1 landed. Caught by attach-javadocs in the PR build for slice 1 (unit-tests shard 3); local mvn javadoc:jar now runs clean.
Add ShortcutTrigger (flow:shortcut), ClickAction (flow:click) and SetEnabledAction (flow:set-enabled). The set-enabled action toggles the target's disabled attribute on the client and mirrors the change server-side via Element.setEnabled. Server-to-client mirror plumbing lands in TriggerSupport: a per-host ReturnChannelRegistration (with DisabledUpdateMode.ALWAYS) is registered lazily and sent as the last executeJs parameter, where it arrives as a callable JS function. Each action factory receives a notifyServer callback closing over its own id; SetEnabledAction calls it after the local DOM change to deliver [actionId] back through the channel, where dispatchMirror resolves the action and runs applyServerSideEffect(). When the user wires (disable, click) on the shortcut, the client queues the mirror before Flow's click event for the same target, so the server applies setEnabled before the user-attached ClickListener runs and that listener observes the post-action state.
…sOutput) JsTrigger(host, expression): the expression runs once at bind time with this=host and a single named parameter "trigger" — a callback the expression must invoke to fire. An optional function returned by the expression is treated as the uninstall hook. JsAction(expression, Output<?>...): the expression runs each time the trigger fires with a single named parameter "output" — output(i) resolves the i-th declared output's current value through the shared output pool, so JsAction outputs dedupe with built-in actions'. JsOutput<T>(type, expression): the expression evaluates at fire time; its return value becomes the output. Client side, all three use new Function(...) rather than eval (lint clean, slightly safer scoping) and swallow compile/runtime errors to console.debug so a broken expression does not take down sibling factories in the same bind.
Wire ServerCallbackAction (stubbed in slice 1) to actually invoke its wrapped SerializableRunnable on the server. ApplyServerSideEffect() now calls handler.run(); the client factory for flow:server-callback is a one-liner that just calls notifyServer(). The dispatch path reuses the per-host ReturnChannelRegistration and the dispatchMirror infrastructure introduced in slice 2 — no new round-trip plumbing, no new wire shape. Trigger.triggers(SerializableRunnable) sugar already constructed the action. The Runnable overload is intentionally no-arg in v0; if a callback needs values, use a @ClientCallable directly on a component, build a custom Action subclass, or carry context through server-side state. Also captures a concrete plan for slice 5 (SignalOutput<T>) in triggers.md: snapshot reads signal.peek() at build time and ships the value in the output config; signal subscriptions re-emit the snapshot via the existing beforeClientResponse path; cleanup on detach. Stays in snapshot semantics — does not introduce reactive output composition.
SignalOutput<T>(Class<T>, Signal<T>), namespace flow:signal-value.
buildClientConfig ships {"value": <signal.peek() as JSON>}. On first
build against an attached host, installs ElementEffect.effect(host, …)
that reads the signal to register the dependency and calls
context.scheduleSync() on subsequent changes; an initial-run flag
suppresses the spurious schedule that would otherwise fire during the
effect's dependency-discovery pass. Cleanup is automatic via the
ElementEffect's host detach hook.
ConfigContext grows getHost() and scheduleSync() to give outputs the
minimum information needed to install host-scoped subscriptions and
trigger a re-emit. TriggerSupport.getHost/scheduleSync flip from
private to public @OverRide.
Client factory for flow:signal-value is a one-liner that returns the
pre-decoded config.value at fire time.
Snapshot semantics, not reactive composition. SignalOutput is a value
reader; composing computed values stays the signal layer's job (use
Signal#cached). Use case: feed a server-side Signal<T> into a trigger
action without first mirroring it through a DOM property.
The shortcut-disable IT failed in CI with a 10s timeout waiting for the result text. The view wired actions as (disable, click) on the assumption that the mirror notification would be queued first and the server's ClickListener would see isEnabled()==false. That assumption held only on paper — browsers block element.click() on a disabled element, so the ClickAction became a no-op and Flow's click listener never fired. Swap to (click, disable): target.click() dispatches while the button is still enabled (listener queues the click event), then SetEnabledAction disables locally and queues the mirror. The server processes the click first (listener observes enabled=true) and applies the mirror immediately after, leaving server state consistent. The user-gesture protection comes from the local disable: a browser- initiated second click is blocked by the disabled attribute. Update the IT assertion to "clicked, enabled=true" and the slice 2 section of triggers.md so the ordering narrative matches what actually works in a browser. The wrong-direction ordering claim in the slice 2 commit message stands as historical record.
Artur-
added a commit
to vaadin/use-cases
that referenced
this pull request
May 15, 2026
Adds five views (UC1–UC5) covering slice 1 of the trigger API introduced in vaadin/flow#24353: ClickTrigger + ClipboardCopyAction + PropertyOutput, plus a custom AbstractAction (FlashAction) wired through window.Vaadin.Flow.triggers to exercise the extension SPI. Each view has a browserless test asserting the TriggerSupport snapshot (type ids, output config, bindings). API-GAPS.md captures the use cases the feature is for but slice 1 cannot yet express (ServerCallbackAction client handler, ShortcutTrigger, FullscreenAction, WebShareAction, SignalOutput, test simulator, feature detection).
From the action's perspective the value coming in is a parameter, not
an output. The Vaadin 8 Output naming was producer-side and read
backwards every time it appeared as a constructor parameter type
(`ClipboardCopyAction(Output<String>)` implied "the action's output").
Argument matches the consumer's mental model and reads honestly in the
constructor signature.
Type renames:
- Output<T> → Argument<T>
- AbstractOutput<T> → AbstractArgument<T>
- PropertyOutput → PropertyArgument
- JsOutput → JsArgument
- SignalOutput → SignalArgument
API renames:
- ConfigContext.registerOutput(...) → registerArgument(...)
- ClipboardCopyAction.getTextOutput() → getTextArgument()
- JsAction("…", Output<?>...) → JsAction("…", Argument<?>...)
- The JsAction expression helper changes from output(i) to argument(i)
Client TS mirrors the same:
- argumentFactories / ArgumentResolver / ArgumentInstance / ArgumentFactory
- window.Vaadin.Flow.triggers.registerArgument(typeId, factory)
Wire format keys:
- Snapshot: "outputs" → "arguments"
- flow:clipboard-copy config: "textOutput" → "text"
- flow:js action config: "outputs" id list → "arguments"
- Type ids unchanged (flow:property, flow:signal-value, flow:js, …)
Tests, ITs, view classes and triggers.md updated to match.
Mixing Element and Component overloads on every public constructor
conflated two layers. Component is the high-level abstraction Vaadin
users work with daily; Element is the DOM primitive that mostly
matters to component authors. The trigger API's public surface should
pick one.
ClickTrigger, ShortcutTrigger, JsTrigger, ClickAction, SetEnabledAction
and PropertyArgument now only accept a Component. Internal storage is
still Element (consistent with AbstractTrigger.getHost()), but it does
not appear in the constructor signature.
The SPI layer is unchanged: AbstractTrigger's protected constructor
still takes both Element and Component for add-on infrastructure that
legitimately works at the DOM level, and the internal package keeps
TriggerSupport.on(Element) / ConfigContext.referenceElement(Element)
for use by custom Trigger / Action / Argument subclasses.
Unit tests previously built hosts via `new Element("tag")`; switch
them to a tiny TagComponent helper in the test sources rather than
pull flow-html-components into the server test classpath.
triggers.md updated: the decision table now says "Component only on
the public surface; Element-level access via internal package."
|
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.



Summary
com.vaadin.flow.component.trigger: a server-side API for wiring client-side actions (clipboard copy, set property, run JS, server callback, …) to client-side triggers (DOMclick, keyboard shortcut, custom JS) reading values from arguments (DOM property,
Signal<T>, inline JS), so each action runs inside the original user-gesture DOM event handler thatbrowser APIs like clipboard, fullscreen and share require.
Trigger/Action/Argument<T>contracts with extensibleAbstractTrigger/AbstractAction/AbstractArgument<T>bases, namespaced type ids (flow:click,flow:shortcut,flow:clipboard-copy,flow:property,flow:signal-value,flow:js, …) and built-ins for click + shortcut triggers, click + set-enabled + clipboard-copy +server-callback + inline-JS actions, and property + signal + inline-JS arguments.
flow-client/src/main/frontend/Triggers.tsmodule imported byFlow.ts, exposingwindow.Vaadin.Flow.triggerswith a type-id registry that applications and add-ons extendvia
@JsModule-loaded TypeScript files registering against the same global.Details
Wire format. A new server-only
TriggerSupportServerSideFeatureregistered on everyBasicElementStateProviderelement holds per-host id-keyed pools of triggers, actions andarguments plus a bindings list, and on every change emits a single
Element.executeJscall carrying a snapshot, any secondary element references the snapshot indexes, and a per-hostReturnChannelRegistration(withDisabledUpdateMode.ALWAYS) that arrives at the client as a callable JS function. Mutations are coalesced throughStateTree.beforeClientResponse, anda one-shot attach listener re-emits on host re-attach; the client's
bindis idempotent via aWeakMapper host so a second call disposes the previous installation. Actions that need aserver-observable mirror (e.g.
SetEnabledAction,ServerCallbackAction) overrideapplyServerSideEffect()and invoke the per-actionnotifyServer()callback from the client; thechannel's
dispatchMirrorresolves the action and runs the effect on the UI thread.Signal arguments.
SignalArgument<T>(Class<T>, Signal<T>)readssignal.peek()into the snapshot at build time and installs anElementEffect.effect(host, …)that re-emits thesnapshot on subsequent signal changes (with an initial-run flag suppressing the dependency-discovery spurious schedule). Snapshot semantics only — composing computed values stays the
signal layer's job (use
Signal#cached).JS escape hatch.
JsTrigger/JsAction/JsArgument<T>accept arbitrary JS expressions and usenew Function(...)(noteval) with helpers in scope (trigger,argument(i)),so add-on authors can ship a working trigger/action/argument without writing a TypeScript module.
Public API stays at the Component layer. Every built-in constructor accepts a
Componentand only aComponent; Element-level access is internal — the protectedAbstractTriggerconstructor still takes either for add-on infrastructure that legitimately works at the DOM level, and
com.vaadin.flow.component.trigger.internal.TriggerSupport.on(Element)/ConfigContext.referenceElement(Element)are reachable from customTrigger/Action/Argumentsubclasses that opt into the internal package.Coverage. Unit tests cover snapshot encoding, action/argument dedup across types, server-side mirror dispatch, shortcut config, signal value re-snapshot, and the
Trigger.triggers(Runnable)sugar path; ITs underflow-tests/test-root-contextexercise each built-in end-to-end (clipboard copy, click-then-disable on shortcut, JS escape-hatch roundtrip, server-callback round trip, signal-backed clipboard with server-side mutation).