From 59b39688e9533be602ce3ffa3eb8f2f9b3a26f94 Mon Sep 17 00:00:00 2001
From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com>
Date: Thu, 27 Nov 2025 23:34:39 -0300
Subject: [PATCH 1/4] feat: add JsonCodec encodeWithoutTypeInfo
---
.../vaadin/jsonmigration/JsonCodec.java | 53 ++++++++++++++++++-
1 file changed, 52 insertions(+), 1 deletion(-)
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");
+ }
+
}
From c7658a966ad5eeffa3df1dd638af7d376f20aee1 Mon Sep 17 00:00:00 2001
From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com>
Date: Fri, 28 Nov 2025 01:02:20 -0300
Subject: [PATCH 2/4] feat: add utility for class instrumentation
---
pom.xml | 6 +
.../ClassInstrumentationUtil.java | 375 ++++++++++++++++++
.../vaadin/jsonmigration/JsonMigration.java | 37 ++
.../jsonmigration/JsonMigrationHelper.java | 3 +
.../jsonmigration/JsonMigrationHelper25.java | 6 +
.../jsonmigration/LegacyClientCallable.java | 37 ++
.../LegacyJsonMigrationHelper.java | 6 +
7 files changed, 470 insertions(+)
create mode 100644 src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java
create mode 100644 src/main/java/com/flowingcode/vaadin/jsonmigration/LegacyClientCallable.java
diff --git a/pom.xml b/pom.xml
index 06408b4..a2a2379 100644
--- a/pom.xml
+++ b/pom.xml
@@ -68,6 +68,12 @@
vaadin-core
true
+
+ org.ow2.asm
+ asm
+ 9.8
+ true
+
tools.jackson.core
jackson-databind
diff --git a/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java b/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java
new file mode 100644
index 0000000..30cddd5
--- /dev/null
+++ b/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java
@@ -0,0 +1,375 @@
+/*-
+ * #%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 com.vaadin.flow.component.Component;
+import elemental.json.JsonArray;
+import elemental.json.JsonBoolean;
+import elemental.json.JsonNumber;
+import elemental.json.JsonObject;
+import elemental.json.JsonString;
+import elemental.json.JsonValue;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.ConcurrentHashMap;
+import lombok.experimental.UtilityClass;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.node.ArrayNode;
+import tools.jackson.databind.node.BooleanNode;
+import tools.jackson.databind.node.DoubleNode;
+import tools.jackson.databind.node.ObjectNode;
+import tools.jackson.databind.node.StringNode;
+
+/**
+ * Utility class for instrumenting classes at runtime.
+ *
+ *
+ * This class provides methods to dynamically create subclasses of a given parent class using
+ * bytecode instrumentation. Methods annotated with {@link ClientCallable} that return a type
+ * assignable to {@link JsonValue} are automatically overridden to convert the result through
+ * {@link JsonMigration#convertToClientCallableResult(JsonValue)}.
+ *
+ *
+ * @author Javier Godoy / Flowing Code
+ */
+@UtilityClass
+final class ClassInstrumentationUtil {
+
+ private static final Map classLoaderCache =
+ new WeakHashMap<>();
+
+ /**
+ * Creates and returns an instance of a dynamically instrumented class that extends the specified
+ * parent class.
+ *
+ *
+ * This method generates a new class at runtime that extends {@code parent}, and returns a new
+ * instance of that generated class. The instrumented class will have a default constructor that
+ * delegates to the parent's default constructor. All methods annotated with
+ * {@link ClientCallable} that return a type assignable to {@link JsonValue} will be overridden to
+ * convert the result via JsonMigration.convertClientCallableResult().
+ *
+ *
+ *
+ * Requirements:
+ *
+ *
+ * - The parent class must not be final
+ * - The parent class must have an accessible no-argument constructor
+ *
+ *
+ * @param the type of the parent class
+ * @param parent the parent class to extend
+ * @return a new instance of the instrumented class extending {@code parent}
+ * @throws IllegalArgumentException if the parent class is final, an interface, a primitive type,
+ * an array type, or does not have an accessible no-argument constructor
+ * @throws RuntimeException if the instrumentation or instantiation fails
+ */
+ public Class extends T> instrumentClass(Class parent) {
+ // Validate input
+ if (parent == null) {
+ throw new IllegalArgumentException("Parent class cannot be null");
+ }
+
+ if (parent.isInterface()) {
+ throw new IllegalArgumentException("Cannot instrument an interface: " + parent.getName());
+ }
+
+ if (parent.isPrimitive()) {
+ throw new IllegalArgumentException("Cannot instrument a primitive type: " + parent.getName());
+ }
+
+ if (parent.isArray()) {
+ throw new IllegalArgumentException("Cannot instrument an array type: " + parent.getName());
+ }
+
+ if (Modifier.isFinal(parent.getModifiers())) {
+ throw new IllegalArgumentException("Cannot instrument a final class: " + parent.getName());
+ }
+
+ if (!needsInstrumentation(parent)) {
+ return parent;
+ }
+
+ // Check for accessible no-arg constructor
+ try {
+ Constructor> defaultConstructor = parent.getDeclaredConstructor();
+ if (!Modifier.isPublic(defaultConstructor.getModifiers())
+ && !Modifier.isProtected(defaultConstructor.getModifiers())) {
+ try {
+ defaultConstructor.setAccessible(true);
+ } catch (Exception e) {
+ throw new IllegalArgumentException(
+ "Parent class must have an accessible no-argument constructor: " + parent.getName(),
+ e);
+ }
+ }
+ } catch (NoSuchMethodException e) {
+ throw new IllegalArgumentException(
+ "Parent class must have a no-argument constructor: " + parent.getName(), e);
+ }
+
+ try {
+ String instrumentedClassName = parent.getName() + "$Instrumented";
+ return createInstrumentedClass(parent, instrumentedClassName);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to instrument " + parent.getName(), e);
+ }
+ }
+
+ private static boolean needsInstrumentation(Class> parent) {
+ return !getInstrumentableMethods(parent).isEmpty();
+ }
+
+ private static List getInstrumentableMethods(Class> parent) {
+ List methods = new ArrayList<>();
+ for (Method method : parent.getDeclaredMethods()) {
+ if (!Modifier.isStatic(method.getModifiers()) && !Modifier.isPrivate(method.getModifiers())) {
+ boolean isCallable = method.isAnnotationPresent(ClientCallable.class);
+ boolean isLegacyCallable = method.isAnnotationPresent(LegacyClientCallable.class);
+ if (isCallable || isLegacyCallable) {
+ boolean hasJsonValueReturn = JsonValue.class.isAssignableFrom(method.getReturnType());
+ boolean hasJsonValueParams = hasJsonValueParameters(method);
+
+ if (isCallable && hasJsonValueParams) {
+ throw new IllegalArgumentException(String.format(
+ "Instrumented method '%s' in class '%s' has JsonValue arguments and must be annotated with @%s instead of @ClientCallable",
+ method.getName(), method.getDeclaringClass().getName(),
+ LegacyClientCallable.class.getName()));
+ } else if (isCallable && hasJsonValueReturn) {
+ methods.add(method);
+ } else if (isLegacyCallable) {
+ methods.add(method);
+ }
+ }
+ }
+ }
+ return methods;
+ }
+
+ private static boolean hasJsonValueParameters(Method method) {
+ for (Class> paramType : method.getParameterTypes()) {
+ if (JsonValue.class.isAssignableFrom(paramType)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private Class extends T> createInstrumentedClass(Class parent,
+ String className) throws Exception {
+ InstrumentedClassLoader classLoader =
+ getOrCreateInstrumentedClassLoader(parent.getClassLoader());
+ return classLoader.defineInstrumentedClass(className, parent).asSubclass(parent);
+ }
+
+ private InstrumentedClassLoader getOrCreateInstrumentedClassLoader(ClassLoader parent) {
+ synchronized (classLoaderCache) {
+ return classLoaderCache.computeIfAbsent(parent, InstrumentedClassLoader::new);
+ }
+ }
+
+ private static final class InstrumentedClassLoader extends ClassLoader {
+
+ private final Map, Class>> instrumentedClassCache = new ConcurrentHashMap<>();
+
+ public InstrumentedClassLoader(ClassLoader parent) {
+ super(parent);
+ }
+
+ public Class> defineInstrumentedClass(String className, Class> parent) {
+ return instrumentedClassCache.computeIfAbsent(parent, p -> {
+ byte[] bytecode = generateBytecode(className, p);
+ return defineClass(className, bytecode, 0, bytecode.length);
+ });
+ }
+
+ private byte[] generateBytecode(String className, Class> parent) {
+ String internalClassName = className.replace('.', '/');
+ String internalParentName = parent.getName().replace('.', '/');
+
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalClassName, null, internalParentName, null);
+
+ generateConstructor(cw, internalParentName);
+ generateClientCallableOverrides(cw, parent, internalParentName);
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ private void generateConstructor(ClassWriter cw, String internalParentName) {
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, internalParentName, "", "()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/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) {
From 1a724449c257665a156b937b2d995c85f160e262 Mon Sep 17 00:00:00 2001
From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com>
Date: Fri, 28 Nov 2025 01:02:41 -0300
Subject: [PATCH 3/4] feat: add InstrumentationViewInitializer
---
.../InstrumentationViewInitializer.java | 63 +++++++++++++++++++
.../jsonmigration/InstrumentedRoute.java | 46 ++++++++++++++
2 files changed, 109 insertions(+)
create mode 100644 src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java
create mode 100644 src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java
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();
+
+}
From ac509223c876ac383c27ca2f180644d5e47257de Mon Sep 17 00:00:00 2001
From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com>
Date: Fri, 28 Nov 2025 14:09:56 -0300
Subject: [PATCH 4/4] docs(readme): add documentation for new features
---
README.md | 31 ++++++++++++++++++++++++++++++-
1 file changed, 30 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 88f4d3b..d93dce6 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,8 @@ Provides a compatibility layer for JSON handling to abstract away breaking chang
- **Zero-effort migration**: Write your code once and run it seamlessly on Vaadin 14, 23, 24 and 25
- **Automatic version detection**: Detects the runtime Vaadin version and uses the appropriate JSON handling strategy
- **Drop-in replacement**: Simple static methods that replace version-specific APIs
+- **Client Callable compatibility**: Mechanisms to handle JSON arguments and return types in `@ClientCallable` methods.
+- **JsonSerializer and JsonCodec**: Includes `JsonSerializer` and `JsonCodec` classes for serialization and deserialization of elemental JSON values.
## Download release
@@ -117,12 +119,39 @@ When a `@ClientCallable` method needs to return a JSON value, use `convertToClie
```java
@ClientCallable
-public Object getJsonData() {
+public JsonValue getJsonData() {
JsonValue json = ...;
return JsonMigration.convertToClientCallableResult(json);
}
```
+## Receiving JSON in ClientCallable methods
+
+If the method receives `JsonValue` as an argument, it cannot be annotated with `ClientCallable` because of compatibility issues. `LegacyClientCallable` should be used instead.
+
+To use `LegacyClientCallable`, you must use instrumentation. This can be done via `JsonMigration.instrumentClass` or by using `InstrumentedRoute` / `InstrumentationViewInitializer`.
+
+**Note:** Instrumentation is a complex mechanism. While it might warrant a rewrite of the affected code, it is offered here to preserve compatibility with existing implementations.
+
+```java
+@InstrumentedRoute("legacy-view")
+public class ViewWithElementalCallables extends Div {
+ @LegacyClientCallable
+ public void receiveJson(JsonValue json) {
+ // ...
+ }
+}
+
+// Register via META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener
+// or use `@SpringComponent` with Spring.
+public class ViewInitializerImpl extends InstrumentationViewInitializer {
+ @Override
+ public void serviceInit(ServiceInitEvent event) {
+ registerInstrumentedRoute(ViewWithElementalCallables.class);
+ }
+}
+```
+
## Direct Usage
The helper methods can also be used directly from the `JsonMigration` class: