", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ }
+
+ private void generateClientCallableOverrides(ClassWriter cw, Class> parent,
+ String internalParentName) {
+ for (Method method : getInstrumentableMethods(parent)) {
+ generateMethodOverride(cw, method, internalParentName);
+ }
+ }
+
+ private void generateMethodOverride(ClassWriter cw, Method method, String internalParentName) {
+ boolean hasJsonValueReturn = JsonValue.class.isAssignableFrom(method.getReturnType());
+ boolean hasJsonValueParams = hasJsonValueParameters(method);
+
+ String overrideDescriptor = getMethodDescriptor(method, hasJsonValueParams);
+ String superDescriptor = getMethodDescriptor(method, false);
+ int access = method.getModifiers() & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED);
+
+ MethodVisitor mv = cw.visitMethod(access, method.getName(), overrideDescriptor, null,
+ getExceptionInternalNames(method.getExceptionTypes()));
+
+ mv.visitAnnotation(Type.getDescriptor(ClientCallable.class), true);
+ mv.visitCode();
+
+ // Load 'this'
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+
+ // Load and convert parameters
+ Class>[] paramTypes = method.getParameterTypes();
+ int localVarIndex = 1;
+ for (Class> paramType : paramTypes) {
+ if (hasJsonValueParams && JsonValue.class.isAssignableFrom(paramType)) {
+ // Load the JsonNode parameter
+ mv.visitVarInsn(Opcodes.ALOAD, localVarIndex);
+
+ // Call JsonMigration.convertToJsonValue(JsonNode) -> JsonValue
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/flowingcode/vaadin/jsonmigration/JsonMigration",
+ "convertToJsonValue", "(Ljava/lang/Object;)Lelemental/json/JsonValue;", false);
+
+ // Cast to the original type if not JsonValue
+ if (paramType != JsonValue.class) {
+ mv.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(paramType));
+ }
+
+ localVarIndex++;
+ } else {
+ localVarIndex += loadParameter(mv, paramType, localVarIndex);
+ }
+ }
+
+ // Call super.methodName(params) with original descriptor
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, internalParentName, method.getName(), superDescriptor,
+ false);
+
+ if (hasJsonValueReturn) {
+ // Store result in local variable
+ mv.visitVarInsn(Opcodes.ASTORE, localVarIndex);
+
+ // Load result back
+ mv.visitVarInsn(Opcodes.ALOAD, localVarIndex);
+
+ // Call JsonMigration.convertToClientCallableResult(aux)
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/flowingcode/vaadin/jsonmigration/JsonMigration",
+ "convertToClientCallableResult", "(Lelemental/json/JsonValue;)Lelemental/json/JsonValue;",
+ false);
+ }
+
+ // Return converted result or void
+ if (method.getReturnType() == Void.TYPE) {
+ mv.visitInsn(Opcodes.RETURN);
+ } else {
+ mv.visitInsn(Opcodes.ARETURN);
+ }
+
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ }
+
+ private int loadParameter(MethodVisitor mv, Class> paramType, int localVarIndex) {
+ if (!paramType.isPrimitive()) {
+ mv.visitVarInsn(Opcodes.ALOAD, localVarIndex);
+ return 1;
+ } else if (paramType == Long.TYPE) {
+ mv.visitVarInsn(Opcodes.LLOAD, localVarIndex);
+ return 2;
+ } else if (paramType == Float.TYPE) {
+ mv.visitVarInsn(Opcodes.FLOAD, localVarIndex);
+ return 1;
+ } else if (paramType == Double.TYPE) {
+ mv.visitVarInsn(Opcodes.DLOAD, localVarIndex);
+ return 2;
+ } else {
+ mv.visitVarInsn(Opcodes.ILOAD, localVarIndex);
+ return 1;
+ }
+ }
+
+ private String getMethodDescriptor(Method method, boolean convertJsonValueParams) {
+ StringBuilder sb = new StringBuilder("(");
+ for (Class> paramType : method.getParameterTypes()) {
+ if (convertJsonValueParams && JsonValue.class.isAssignableFrom(paramType)) {
+ sb.append(getConvertedTypeDescriptor(paramType));
+ } else {
+ sb.append(Type.getDescriptor(paramType));
+ }
+ }
+ sb.append(")");
+ sb.append(Type.getDescriptor(method.getReturnType()));
+ return sb.toString();
+ }
+
+ private String getConvertedTypeDescriptor(Class> type) {
+ if (type == JsonObject.class) {
+ return Type.getDescriptor(ObjectNode.class);
+ } else if (type == JsonArray.class) {
+ return Type.getDescriptor(ArrayNode.class);
+ } else if (type == JsonBoolean.class) {
+ return Type.getDescriptor(BooleanNode.class);
+ } else if (type == JsonNumber.class) {
+ return Type.getDescriptor(DoubleNode.class);
+ } else if (type == JsonString.class) {
+ return Type.getDescriptor(StringNode.class);
+ } else if (JsonValue.class.isAssignableFrom(type)) {
+ return Type.getDescriptor(JsonNode.class);
+ }
+ return Type.getDescriptor(type);
+ }
+
+ private String[] getExceptionInternalNames(Class>[] exceptionTypes) {
+ if (exceptionTypes == null || exceptionTypes.length == 0) {
+ return null;
+ }
+ String[] names = new String[exceptionTypes.length];
+ for (int i = 0; i < exceptionTypes.length; i++) {
+ names[i] = exceptionTypes[i].getName().replace('.', '/');
+ }
+ return names;
+ }
+ }
+}
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java
new file mode 100644
index 0000000..1ade68a
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java
@@ -0,0 +1,63 @@
+/*-
+ * #%L
+ * Json Migration Helper
+ * %%
+ * Copyright (C) 2025 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.jsonmigration;
+
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.router.RouteConfiguration;
+import com.vaadin.flow.server.VaadinServiceInitListener;
+import com.vaadin.flow.server.Version;
+
+/**
+ * Abstract base class for Vaadin service initializers that register instrumented views. Subclasses
+ * should implement {@link #serviceInit(com.vaadin.flow.server.ServiceInitEvent)} and call
+ * {@link #registerInstrumentedRoute(Class)} to register views with instrumented routes.
+ *
+ * @author Javier Godoy / Flowing Code
+ */
+@SuppressWarnings("serial")
+public abstract class InstrumentationViewInitializer implements VaadinServiceInitListener {
+
+ /**
+ * Registers an instrumented route for the given navigation target. The navigation target must be
+ * annotated with {@link InstrumentedRoute} to specify the route path. This method calls
+ * {@link JsonMigration#instrumentClass(Class)} to get the instrumented class and registers it as
+ * a Vaadin view with the route derived from the annotation.
+ *
+ * @param navigationTarget the component class to instrument and register, must be annotated with
+ * {@link InstrumentedRoute}
+ * @throws IllegalArgumentException if the navigationTarget is not annotated with
+ * {@link InstrumentedRoute}
+ */
+ protected final void registerInstrumentedRoute(Class extends Component> navigationTarget) {
+ InstrumentedRoute annotation = navigationTarget.getAnnotation(InstrumentedRoute.class);
+ if (annotation == null) {
+ throw new IllegalArgumentException(
+ navigationTarget.getName() + " must be annotated with @"
+ + InstrumentedRoute.class.getSimpleName());
+ }
+
+ String route = annotation.value();
+ if (Version.getMajorVersion() > 24) {
+ navigationTarget = JsonMigration.instrumentClass(navigationTarget);
+ }
+ RouteConfiguration.forApplicationScope().setRoute(route, navigationTarget);
+ }
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java
new file mode 100644
index 0000000..4e93475
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java
@@ -0,0 +1,46 @@
+/*-
+ * #%L
+ * Json Migration Helper
+ * %%
+ * Copyright (C) 2025 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.jsonmigration;
+
+import com.vaadin.flow.component.Component;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation to mark a {@link Component} class for instrumented route registration.
+ *
+ * @author Javier Godoy / Flowing Code
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@Documented
+public @interface InstrumentedRoute {
+
+ /**
+ * The route path for this component.
+ *
+ * @return the route path
+ */
+ String value();
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonCodec.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonCodec.java
index 0decf7a..3e7dbec 100644
--- a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonCodec.java
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonCodec.java
@@ -36,7 +36,10 @@
import com.vaadin.flow.component.Component;
import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.dom.Node;
import com.vaadin.flow.internal.ReflectTools;
+import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration;
+import elemental.json.Json;
import elemental.json.JsonType;
import elemental.json.JsonValue;
@@ -58,7 +61,7 @@
*
* @author Vaadin Ltd
*/
-class JsonCodec {
+public class JsonCodec {
/**
* Decodes the given JSON value as the given type.
@@ -101,4 +104,52 @@ public static T decodeAs(JsonValue json, Class type) {
}
+ /**
+ * Helper for checking whether the type is supported by
+ * {@link #encodeWithoutTypeInfo(Object)}. Supported value types are
+ * {@link String}, {@link Integer}, {@link Double}, {@link Boolean},
+ * {@link JsonValue}.
+ *
+ * @param type
+ * the type to check
+ * @return whether the type can be encoded
+ */
+ public static boolean canEncodeWithoutTypeInfo(Class> type) {
+ assert type != null;
+ return String.class.equals(type) || Integer.class.equals(type)
+ || Double.class.equals(type) || Boolean.class.equals(type)
+ || JsonValue.class.isAssignableFrom(type);
+ }
+
+ /**
+ * Helper for encoding any "primitive" value that is directly supported in
+ * JSON. Supported values types are {@link String}, {@link Number},
+ * {@link Boolean}, {@link JsonValue}. null is also supported.
+ *
+ * @param value
+ * the value to encode
+ * @return the value encoded as JSON
+ */
+ public static JsonValue encodeWithoutTypeInfo(Object value) {
+ if (value == null) {
+ return Json.createNull();
+ }
+
+ assert canEncodeWithoutTypeInfo(value.getClass());
+
+ Class> type = value.getClass();
+ if (String.class.equals(value.getClass())) {
+ return Json.create((String) value);
+ } else if (Integer.class.equals(type) || Double.class.equals(type)) {
+ return Json.create(((Number) value).doubleValue());
+ } else if (Boolean.class.equals(type)) {
+ return Json.create(((Boolean) value).booleanValue());
+ } else if (JsonValue.class.isAssignableFrom(type)) {
+ return (JsonValue) value;
+ }
+ assert !canEncodeWithoutTypeInfo(type);
+ throw new IllegalArgumentException(
+ "Can't encode " + value.getClass() + " to json");
+ }
+
}
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java
index 1c3b080..e63c8c0 100644
--- a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java
@@ -20,6 +20,7 @@
package com.flowingcode.vaadin.jsonmigration;
import com.vaadin.flow.component.ClientCallable;
+import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.page.PendingJavaScriptResult;
import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.dom.Element;
@@ -168,4 +169,40 @@ public static JsonObject getEventData(DomEvent event) {
return (JsonObject) convertToJsonValue(invoke(DomEvent_getEventData, event));
}
+ /**
+ * Instruments a component class to ensure compatibility with Vaadin 25+ JSON handling changes
+ * in {@link ClientCallable} methods.
+ *
+ *
+ * This method creates a dynamically generated subclass of the given component class that
+ * automatically converts {@code JsonValue} return values from {@link ClientCallable} methods to
+ * the appropriate {@code JsonNode} type required by Vaadin 25+.
+ *
+ *
+ * Behavior by Vaadin version:
+ *
+ * - Vaadin 25+: Returns an instrumented subclass that overrides all
+ * {@code @ClientCallable} methods returning {@code JsonValue} types. These overridden methods
+ * call the parent implementation and then convert the result through
+ * {@link #convertToClientCallableResult(JsonValue)} to ensure compatibility with the new
+ * Jackson-based JSON API.
+ * - Vaadin 24 and earlier: Returns the original class unchanged, as no instrumentation
+ * is needed for the elemental.json API.
+ *
+ *
+ * @param the type of the component class
+ * @param clazz the component class to instrument
+ * @return in Vaadin 25+, an instrumented subclass of {@code clazz}; in earlier versions, the
+ * original {@code clazz}
+ * @throws IllegalArgumentException if the class does not meet the requirements for
+ * instrumentation
+ * @throws RuntimeException if the instrumentation fails
+ * @see ClientCallable
+ * @see InstrumentedRoute
+ * @see #convertToClientCallableResult(JsonValue)
+ */
+ public static Class extends T> instrumentClass(Class clazz) {
+ return helper.instrumentClass(clazz);
+ }
+
}
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper.java
index 5681823..00dcea1 100644
--- a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper.java
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper.java
@@ -19,6 +19,7 @@
*/
package com.flowingcode.vaadin.jsonmigration;
+import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.page.PendingJavaScriptResult;
import elemental.json.JsonValue;
@@ -35,4 +36,6 @@ interface JsonMigrationHelper {
ElementalPendingJavaScriptResult convertPendingJavaScriptResult(PendingJavaScriptResult result);
+ Class extends T> instrumentClass(Class clazz);
+
}
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper25.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper25.java
index 2aa6ca3..24fc762 100644
--- a/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper25.java
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigrationHelper25.java
@@ -21,6 +21,7 @@
import java.lang.reflect.Method;
import java.util.Arrays;
+import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.page.PendingJavaScriptResult;
import com.vaadin.flow.function.SerializableConsumer;
import elemental.json.Json;
@@ -41,6 +42,11 @@
@NoArgsConstructor
class JsonMigrationHelper25 implements JsonMigrationHelper {
+ @Override
+ public Class extends T> instrumentClass(Class clazz) {
+ return ClassInstrumentationUtil.instrumentClass(clazz);
+ }
+
@Override
public JsonValue convertToJsonValue(Object object) {
if (object instanceof JsonValue) {
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyClientCallable.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyClientCallable.java
new file mode 100644
index 0000000..1842928
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyClientCallable.java
@@ -0,0 +1,37 @@
+/*-
+ * #%L
+ * Json Migration Helper
+ * %%
+ * Copyright (C) 2025 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+package com.flowingcode.vaadin.jsonmigration;
+
+import com.vaadin.flow.component.ClientCallable;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * When instrumented, publishes the annotated method as if {@link ClientCallable} has been used.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+@Documented
+public @interface LegacyClientCallable {
+
+}
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyJsonMigrationHelper.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyJsonMigrationHelper.java
index 449538c..ea5c75a 100644
--- a/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyJsonMigrationHelper.java
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyJsonMigrationHelper.java
@@ -19,6 +19,7 @@
*/
package com.flowingcode.vaadin.jsonmigration;
+import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.page.PendingJavaScriptResult;
import java.lang.reflect.Method;
import elemental.json.JsonValue;
@@ -30,6 +31,11 @@
@NoArgsConstructor
class LegacyJsonMigrationHelper implements JsonMigrationHelper {
+ @Override
+ public Class instrumentClass(Class clazz) {
+ return clazz;
+ }
+
@Override
public JsonValue convertToJsonValue(Object object) {
if (object instanceof JsonValue) {