Skip to content

Commit 27a6962

Browse files
authored
feat: add JsFunction value type for composable executeJs (#24374)
Lets server code build a JS function value with captured parameters and pass it as an executeJs parameter, removing the need to string-concatenate framework boilerplate around user-supplied JS. Captures may themselves be Elements or other JsFunctions; the codec encodes them recursively as @v-fn, and the client reifies the value as a callable function with the captures pre-bound. Fixes #24373
1 parent 3697241 commit 27a6962

9 files changed

Lines changed: 493 additions & 0 deletions

File tree

‎flow-client/src/main/java/com/vaadin/client/flow/util/ClientJsonCodec.java‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ public static Object decodeWithTypeInfo(StateTree tree, JsonValue json) {
129129
tree.getRegistry().getServerConnector());
130130
}
131131

132+
// Check for @v-fn format
133+
JsonValue fnValue = jsonObject.get("@v-fn");
134+
if (fnValue != null) {
135+
if (fnValue.getType() != JsonType.OBJECT) {
136+
throw new IllegalArgumentException(
137+
"@v-fn value must be an object, got "
138+
+ fnValue.getType() + " in "
139+
+ json.toJson());
140+
}
141+
return decodeJsFunction(tree, (JsonObject) fnValue,
142+
json.toJson());
143+
}
144+
132145
// Check for unknown @v- types
133146
for (String key : jsonObject.keys()) {
134147
if (key.startsWith("@v-")) {
@@ -154,6 +167,75 @@ private static native NativeFunction createReturnChannelCallback(int nodeId,
154167
});
155168
}-*/;
156169

170+
private static Object decodeJsFunction(StateTree tree, JsonObject fnObject,
171+
String originalJson) {
172+
JsonValue bodyValue = fnObject.get("body");
173+
if (bodyValue == null || bodyValue.getType() != JsonType.STRING) {
174+
throw new IllegalArgumentException(
175+
"@v-fn 'body' must be a string in " + originalJson);
176+
}
177+
JsonValue capturesValue = fnObject.get("captures");
178+
if (capturesValue == null
179+
|| capturesValue.getType() != JsonType.ARRAY) {
180+
throw new IllegalArgumentException(
181+
"@v-fn 'captures' must be an array in " + originalJson);
182+
}
183+
String body = bodyValue.asString();
184+
JsonArray capturesJson = (JsonArray) capturesValue;
185+
int captureCount = capturesJson.length();
186+
JsArray<Object> captures = JsCollections.array();
187+
for (int i = 0; i < captureCount; i++) {
188+
captures.push(decodeWithTypeInfo(tree, capturesJson.get(i)));
189+
}
190+
// Optional 'args' field: names of runtime parameters the manifested
191+
// function should accept at call time, after the bound captures.
192+
JsonArray argsJson;
193+
JsonValue argsValue = fnObject.get("args");
194+
if (argsValue == null) {
195+
argsJson = null;
196+
} else {
197+
if (argsValue.getType() != JsonType.ARRAY) {
198+
throw new IllegalArgumentException(
199+
"@v-fn 'args' must be an array in " + originalJson);
200+
}
201+
argsJson = (JsonArray) argsValue;
202+
}
203+
int argCount = argsJson == null ? 0 : argsJson.length();
204+
String[] paramsAndCode = new String[captureCount + argCount + 1];
205+
for (int i = 0; i < captureCount; i++) {
206+
paramsAndCode[i] = "$" + i;
207+
}
208+
for (int i = 0; i < argCount; i++) {
209+
paramsAndCode[captureCount + i] = argsJson.getString(i);
210+
}
211+
paramsAndCode[captureCount + argCount] = body;
212+
NativeFunction fn = new NativeFunction(paramsAndCode);
213+
return applyCaptures(fn, captures);
214+
}
215+
216+
/**
217+
* Wraps {@code fn} in a function that prepends {@code captures} to the
218+
* runtime arguments before delegating, while leaving {@code this}
219+
* controlled by the caller. {@code Function.prototype.bind} would also
220+
* pre-bind {@code this} to {@code undefined}, which prevents callers from
221+
* setting {@code this} via {@code .call()} or {@code .apply()} on the
222+
* resulting function.
223+
*/
224+
private static native Object applyCaptures(NativeFunction fn,
225+
JsArray<Object> captures)
226+
/*-{
227+
return function() {
228+
var args = new Array(captures.length + arguments.length);
229+
for (var i = 0; i < captures.length; i++) {
230+
args[i] = captures[i];
231+
}
232+
for (var j = 0; j < arguments.length; j++) {
233+
args[captures.length + j] = arguments[j];
234+
}
235+
return fn.apply(this, args);
236+
};
237+
}-*/;
238+
157239
/**
158240
* Decodes a value encoded on the server using
159241
* {@link JacksonCodec#encodeWithoutTypeInfo(Object)}. This is a no-op in

‎flow-server/src/main/java/com/vaadin/flow/component/page/Page.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation;
3838
import com.vaadin.flow.dom.DomListenerRegistration;
3939
import com.vaadin.flow.dom.Element;
40+
import com.vaadin.flow.dom.JsFunction;
4041
import com.vaadin.flow.function.SerializableConsumer;
4142
import com.vaadin.flow.internal.UrlUtil;
4243
import com.vaadin.flow.shared.Registration;
@@ -338,6 +339,8 @@ public void addDynamicImport(String expression) {
338339
* attached)
339340
* <li>{@link tools.jackson.databind.node.BaseJsonNode} (sent as-is without
340341
* additional wrapping)
342+
* <li>{@link JsFunction} (manifested as a callable JavaScript function with
343+
* its captured parameters pre-bound)
341344
* </ul>
342345
* Note that the parameter variables can only be used in contexts where a
343346
* JavaScript variable can be used. You should for instance do

‎flow-server/src/main/java/com/vaadin/flow/dom/Element.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,6 +1792,8 @@ public PendingJavaScriptResult callJsFunction(String functionName,
17921792
* invocation is sent to the client, or as <code>null</code> if not
17931793
* attached)
17941794
* <li>{@link BaseJsonNode} (sent as-is without additional wrapping)
1795+
* <li>{@link JsFunction} (manifested as a callable JavaScript function with
1796+
* its captured parameters pre-bound)
17951797
* </ul>
17961798
* Note that the parameter variables can only be used in contexts where a
17971799
* JavaScript variable can be used. You should for instance do
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.dom;
17+
18+
import java.io.Serializable;
19+
import java.util.Arrays;
20+
import java.util.Collections;
21+
import java.util.List;
22+
import java.util.Objects;
23+
24+
import org.jspecify.annotations.Nullable;
25+
26+
import com.vaadin.flow.internal.JacksonCodec;
27+
28+
/**
29+
* A JavaScript function value with captured parameters that can be passed to
30+
* {@link Element#executeJs(String, Object...)} as a parameter and reified on
31+
* the client as an actual callable JS function.
32+
* <p>
33+
* The {@link #getBody() body} is a JavaScript function body. Captures are
34+
* referenced positionally as {@code $0}, {@code $1}, &hellip; (the same naming
35+
* convention as {@code executeJs} parameters). Additional named runtime
36+
* arguments can be declared with {@link #withArguments(String...)}: the
37+
* materialised function then accepts them positionally and the body references
38+
* them by name. On the client the value materialises as a function with the
39+
* captures pre-bound, so the parent {@code executeJs} can invoke it as
40+
* {@code $N(arg1, arg2, ...)} without ever concatenating user-supplied
41+
* JavaScript with framework boilerplate.
42+
* <p>
43+
* Captures may be any value accepted as a parameter to
44+
* {@link Element#executeJs(String, Object...) executeJs}, including
45+
* {@link Element} (attached elements arrive as DOM nodes, detached ones as
46+
* {@code null}) and nested {@link JsFunction} instances. Capture types are
47+
* validated when the value is created.
48+
* <p>
49+
* Inside the body, {@code this} is whatever the caller of the materialised
50+
* function chooses (i.e. it follows normal JavaScript call semantics &ndash;
51+
* not the host element). To use the host element from within the body, pass it
52+
* as a capture.
53+
*
54+
* @author Vaadin Ltd
55+
*/
56+
public final class JsFunction implements Serializable {
57+
58+
private final String body;
59+
private final List<@Nullable Object> captures;
60+
private final List<String> argumentNames;
61+
62+
private JsFunction(String body, List<@Nullable Object> captures,
63+
List<String> argumentNames) {
64+
this.body = body;
65+
this.captures = captures;
66+
this.argumentNames = argumentNames;
67+
}
68+
69+
/**
70+
* Creates a JavaScript function value with the given body and captured
71+
* parameters.
72+
*
73+
* @param body
74+
* the JavaScript function body, with captures referenced as
75+
* {@code $0}, {@code $1}, &hellip;; not {@code null}
76+
* @param captures
77+
* the values to capture; each must be a type supported as a
78+
* parameter to {@link Element#executeJs(String, Object...)}
79+
* @return a new {@code JsFunction} instance
80+
* @throws IllegalArgumentException
81+
* if any capture has a type that cannot be sent to the client
82+
*/
83+
public static JsFunction of(String body, @Nullable Object... captures) {
84+
Objects.requireNonNull(body, "body");
85+
Objects.requireNonNull(captures, "captures");
86+
@Nullable
87+
Object[] copy = captures.clone();
88+
// Dry-run encode each capture so unsupported types fail fast. Mirrors
89+
// the validation done by the JavaScriptInvocation constructor for
90+
// executeJs parameters.
91+
for (@Nullable
92+
Object capture : copy) {
93+
JacksonCodec.encodeWithTypeInfo(capture);
94+
}
95+
return new JsFunction(body,
96+
Collections.unmodifiableList(Arrays.asList(copy)),
97+
Collections.emptyList());
98+
}
99+
100+
/**
101+
* Returns a copy of this function that declares the given names as
102+
* positional runtime arguments. The materialised function accepts these
103+
* arguments at call time, and the body references them by name.
104+
* <p>
105+
* Example:
106+
*
107+
* <pre>
108+
* JsFunction alerter = JsFunction.of("alert(message);")
109+
* .withArguments("message");
110+
* element.executeJs("$0('Hello')", alerter);
111+
* </pre>
112+
*
113+
* @param argumentNames
114+
* the names of runtime arguments, in positional order; each must
115+
* be a valid JavaScript identifier
116+
* @return a new {@code JsFunction} with the given argument names
117+
* @throws IllegalStateException
118+
* if argument names have already been set on this instance
119+
*/
120+
public JsFunction withArguments(String... argumentNames) {
121+
if (!this.argumentNames.isEmpty()) {
122+
throw new IllegalStateException(
123+
"withArguments has already been called on this JsFunction");
124+
}
125+
return new JsFunction(body, captures, List.of(argumentNames));
126+
}
127+
128+
/**
129+
* The JavaScript function body.
130+
*
131+
* @return the body string
132+
*/
133+
public String getBody() {
134+
return body;
135+
}
136+
137+
/**
138+
* The captured values, in declaration order.
139+
*
140+
* @return an unmodifiable list of captures
141+
*/
142+
public List<@Nullable Object> getCaptures() {
143+
return captures;
144+
}
145+
146+
/**
147+
* The names of the runtime arguments declared via
148+
* {@link #withArguments(String...)}, in positional order.
149+
*
150+
* @return an unmodifiable list of argument names; empty if none were
151+
* declared
152+
*/
153+
public List<String> getArgumentNames() {
154+
return argumentNames;
155+
}
156+
}

‎flow-server/src/main/java/com/vaadin/flow/internal/JacksonCodec.java‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import com.vaadin.flow.component.Component;
3030
import com.vaadin.flow.dom.Element;
31+
import com.vaadin.flow.dom.JsFunction;
3132
import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration;
3233

3334
/**
@@ -43,6 +44,8 @@
4344
* <li>{@link JsonNode} and all its sub types
4445
* <li>{@link Element} (encoded as a reference to the element)
4546
* <li>{@link Component} (encoded as a reference to the root element)
47+
* <li>{@link JsFunction} (encoded as a JS function literal that closes over its
48+
* captured parameters on the client)
4649
* </ul>
4750
*
4851
* <p>
@@ -76,6 +79,8 @@ public static JsonNode encodeWithTypeInfo(Object value) {
7679
return encodeWithoutTypeInfo(value);
7780
} else if (value instanceof ReturnChannelRegistration) {
7881
return encodeReturnChannel((ReturnChannelRegistration) value);
82+
} else if (value instanceof JsFunction) {
83+
return encodeJsFunction((JsFunction) value);
7984
} else if (canEncodeWithoutTypeInfo(value.getClass())) {
8085
// Native JSON types - no wrapping needed
8186
return encodeWithoutTypeInfo(value);
@@ -98,6 +103,25 @@ private static JsonNode encodeReturnChannel(
98103
return obj;
99104
}
100105

106+
private static JsonNode encodeJsFunction(JsFunction value) {
107+
ObjectMapper mapper = JacksonUtils.getMapper();
108+
ObjectNode obj = mapper.createObjectNode();
109+
ObjectNode payload = mapper.createObjectNode();
110+
payload.put("body", value.getBody());
111+
ArrayNode captures = mapper.createArrayNode();
112+
for (Object capture : value.getCaptures()) {
113+
captures.add(encodeWithTypeInfo(capture));
114+
}
115+
payload.set("captures", captures);
116+
ArrayNode args = mapper.createArrayNode();
117+
for (String name : value.getArgumentNames()) {
118+
args.add(name);
119+
}
120+
payload.set("args", args);
121+
obj.set("@v-fn", payload);
122+
return obj;
123+
}
124+
101125
/**
102126
* Helper for checking whether the type is supported by
103127
* {@link #encodeWithoutTypeInfo(Object)}. Supported value types are
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.dom;
17+
18+
import org.junit.jupiter.api.Test;
19+
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
21+
22+
class JsFunctionTest {
23+
24+
@Test
25+
void withArguments_alreadySet_throws() {
26+
JsFunction fn = JsFunction.of("return a;").withArguments("a");
27+
assertThrows(IllegalStateException.class, () -> fn.withArguments("b"));
28+
}
29+
}

0 commit comments

Comments
 (0)