Skip to content

feat: Server-side trigger API for client-side actions#24353

Closed
Artur- wants to merge 13 commits into
mainfrom
feature/trigger
Closed

feat: Server-side trigger API for client-side actions#24353
Artur- wants to merge 13 commits into
mainfrom
feature/trigger

Conversation

@Artur-
Copy link
Copy Markdown
Member

@Artur- Artur- commented May 15, 2026

Summary

  • Introduce 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 (DOM
    click, 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 that
    browser APIs like clipboard, fullscreen and share require.
  • Ship the public Trigger / Action / Argument<T> contracts with extensible AbstractTrigger / 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.
  • Add a new flow-client/src/main/frontend/Triggers.ts module imported by Flow.ts, exposing window.Vaadin.Flow.triggers with a type-id registry that applications and add-ons extend
    via @JsModule-loaded TypeScript files registering against the same global.

Details

Wire format. A new server-only TriggerSupport ServerSideFeature registered on every BasicElementStateProvider element holds per-host id-keyed pools of triggers, actions and
arguments plus a bindings list, and on every change emits a single Element.executeJs call carrying a snapshot, any secondary element references the snapshot indexes, and a per-host
ReturnChannelRegistration (with DisabledUpdateMode.ALWAYS) that arrives at the client as a callable JS function. Mutations are coalesced through StateTree.beforeClientResponse, and
a one-shot attach listener re-emits on host re-attach; the client's bind is idempotent via a WeakMap per host so a second call disposes the previous installation. Actions that need a
server-observable mirror (e.g. SetEnabledAction, ServerCallbackAction) override applyServerSideEffect() and invoke the per-action notifyServer() callback from the client; the
channel's dispatchMirror resolves the action and runs the effect on the UI thread.

Signal arguments. SignalArgument<T>(Class<T>, Signal<T>) reads signal.peek() into the snapshot at build time and installs an ElementEffect.effect(host, …) that re-emits the
snapshot 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 use new Function(...) (not eval) 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 Component and only a Component; Element-level access is internal — the protected AbstractTrigger
constructor 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 custom Trigger / Action / Argument subclasses 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 under flow-tests/test-root-context exercise each built-in end-to-end (clipboard copy, click-then-disable on shortcut, JS escape-hatch round
trip, server-callback round trip, signal-backed clipboard with server-side mutation).

Artur- added 4 commits May 15, 2026 14:08
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.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 15, 2026

Test Results

 1 418 files  +10   1 418 suites  +10   1h 21m 45s ⏱️ - 1m 12s
10 168 tests +18  10 098 ✅ +18  70 💤 ±0  0 ❌ ±0 
10 643 runs  +18  10 564 ✅ +18  79 💤 ±0  0 ❌ ±0 

Results for commit dce7be5. ± Comparison against base commit 371f6cd.

♻️ This comment has been updated with latest results.

Artur- added 5 commits May 15, 2026 15:39
…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.
Artur- added 2 commits May 15, 2026 14:49
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).
@Artur- Artur- changed the title feat: add server-side trigger API feat: Server-side trigger API for client-side actions and outputs May 16, 2026
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.
@Artur- Artur- changed the title feat: Server-side trigger API for client-side actions and outputs feat: Server-side trigger API for client-side actions May 16, 2026
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."
@sonarqubecloud
Copy link
Copy Markdown

@Artur- Artur- closed this May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant