diff --git a/core/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java b/core/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java index 08d2e3c..9d58047 100644 --- a/core/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java +++ b/core/src/main/java/com/flowingcode/vaadin/jsonmigration/ClassInstrumentationUtil.java @@ -21,6 +21,7 @@ import com.vaadin.flow.component.ClientCallable; import com.vaadin.flow.component.Component; +import com.vaadin.flow.router.Route; import elemental.json.JsonValue; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; @@ -38,6 +39,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.SneakyThrows; +import org.objectweb.asm.AnnotationVisitor; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; @@ -163,7 +165,12 @@ public Class instrumentClass(Class parent) } private boolean needsInstrumentation(Class parent) { - return !getInstrumentableMethods(parent).isEmpty(); + return needsRouteAnnotation(parent) || !getInstrumentableMethods(parent).isEmpty(); + } + + private static boolean needsRouteAnnotation(Class parent) { + return parent.isAnnotationPresent(InstrumentedRoute.class) + && !parent.isAnnotationPresent(Route.class); } private boolean hasLegacyVaadin() { @@ -289,6 +296,7 @@ private byte[] generateBytecode(String className, Class parent) { internalParentName, null); + generateRouteAnnotation(cw, parent); generateConstructor(cw, internalParentName); generateClientCallableOverrides(cw, parent, internalClassName, internalParentName); @@ -296,6 +304,17 @@ private byte[] generateBytecode(String className, Class parent) { return cw.toByteArray(); } + private void generateRouteAnnotation(ClassWriter cw, Class parent) { + if (needsRouteAnnotation(parent)) { + InstrumentedRoute instrumentedRoute = parent.getAnnotation(InstrumentedRoute.class); + AnnotationVisitor av = cw.visitAnnotation(Type.getDescriptor(Route.class), true); + av.visit("value", instrumentedRoute.value()); + av.visit("layout", Type.getType(instrumentedRoute.layout())); + av.visit("registerAtStartup", false); + av.visitEnd(); + } + } + private void generateConstructor(ClassWriter cw, String internalParentName) { MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); mv.visitCode(); diff --git a/core/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java b/core/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java index dac1f1e..4ead72b 100644 --- a/core/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java +++ b/core/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializer.java @@ -2,7 +2,7 @@ * #%L * Json Migration Helper * %% - * Copyright (C) 2025 Flowing Code + * Copyright (C) 2025-2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,8 +20,14 @@ package com.flowingcode.vaadin.jsonmigration; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.router.ParentLayout; import com.vaadin.flow.router.RouteConfiguration; +import com.vaadin.flow.router.RouterLayout; import com.vaadin.flow.server.VaadinServiceInitListener; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; /** * Abstract base class for Vaadin service initializers that register instrumented views. Subclasses @@ -39,6 +45,10 @@ public abstract class InstrumentationViewInitializer implements VaadinServiceIni * JsonMigration#instrumentClass(Class)} to get the instrumented class and registers it as a * Vaadin view with the route derived from the annotation. * + * The parent layout chain is derived from {@link InstrumentedRoute#layout()} and the {@link + * ParentLayout} annotations of the layout classes, and each layout in the chain is instrumented, or if a layout in the parent chain is not a {@link Component} + * as well. + * * @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 @@ -55,6 +65,32 @@ protected final void registerInstrumentedRoute(Class naviga String route = annotation.value(); navigationTarget = JsonMigration.instrumentClass(navigationTarget); - RouteConfiguration.forApplicationScope().setRoute(route, navigationTarget); + + List> parentChain = getParentChain(annotation.layout()); + RouteConfiguration.forApplicationScope().setRoute(route, navigationTarget, parentChain); + } + + static List> getParentChain( + Class layout) { + List> parentChain = Collections.emptyList(); + while (layout != UI.class) { + if (parentChain.isEmpty()) { + parentChain = new ArrayList<>(); + } + parentChain.add(instrumentLayout(layout)); + ParentLayout parentLayout = layout.getAnnotation(ParentLayout.class); + layout = parentLayout != null ? parentLayout.value() : UI.class; + } + return parentChain; + } + + private static Class instrumentLayout( + Class layout) { + if (!Component.class.isAssignableFrom(layout)) { + throw new IllegalArgumentException( + layout.getName() + " must extend " + Component.class.getName()); + } + return JsonMigration.instrumentClass(layout.asSubclass(Component.class)) + .asSubclass(RouterLayout.class); } } diff --git a/core/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java b/core/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java index 2793c26..be3c549 100644 --- a/core/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java +++ b/core/src/main/java/com/flowingcode/vaadin/jsonmigration/InstrumentedRoute.java @@ -2,7 +2,7 @@ * #%L * Json Migration Helper * %% - * Copyright (C) 2025 Flowing Code + * Copyright (C) 2025-2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,9 @@ package com.flowingcode.vaadin.jsonmigration; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouterLayout; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -42,4 +45,13 @@ * @return the route path */ String value(); + + /** + * Sets the parent component for the route target component. + *

+ * + * @return the layout component class used by the route target component. + * @see Route#layout() + */ + Class layout() default UI.class; } diff --git a/core/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java b/core/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java index f671293..3e7367b 100644 --- a/core/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java +++ b/core/src/main/java/com/flowingcode/vaadin/jsonmigration/JsonMigration.java @@ -59,6 +59,9 @@ private static JsonMigrationHelper initializeHelper() { private static final Class BASE_JSON_NODE = lookup_BaseJsonNode(); private static Class lookup_BaseJsonNode() { + if (Version.getMajorVersion() <= 24) { + return null; + } try { return Class.forName("tools.jackson.databind.node.BaseJsonNode"); } catch (ClassNotFoundException e) { @@ -127,6 +130,12 @@ private static Object invoke(Method method, Object instance, Object... args) { @SneakyThrows private static Method lookup_setPropertyJson() { if (Version.getMajorVersion() > 24) { + if (BASE_JSON_NODE == null) { + throw new IllegalStateException( + "tools.jackson.databind.node.BaseJsonNode is not available on this runtime, " + + "but is required to resolve Element#setPropertyJson on Vaadin " + + Version.getFullVersion()); + } return Element.class.getMethod("setPropertyJson", String.class, BASE_JSON_NODE); } else { return Element.class.getMethod("setPropertyJson", String.class, JsonValue.class); diff --git a/tests-shared/pom.xml b/tests-shared/pom.xml index 9c2835a..8ee11cb 100644 --- a/tests-shared/pom.xml +++ b/tests-shared/pom.xml @@ -111,6 +111,7 @@ **/*Test24.java **/LitRendererMigrationExtensionTest.java + **/InstrumentationViewInitializerTest.java diff --git a/tests-shared/src/test/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializerTest.java b/tests-shared/src/test/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializerTest.java new file mode 100644 index 0000000..343901f --- /dev/null +++ b/tests-shared/src/test/java/com/flowingcode/vaadin/jsonmigration/InstrumentationViewInitializerTest.java @@ -0,0 +1,151 @@ +/*- + * #%L + * Json Migration Helper + * %% + * Copyright (C) 2025-2026 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.router.ParentLayout; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouterLayout; +import elemental.json.JsonValue; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +/** + * Tests for the parent layout chain derived from {@link InstrumentedRoute#layout()} by {@link + * InstrumentationViewInitializer}. + */ +public class InstrumentationViewInitializerTest { + + public static class RootLayout extends Div implements RouterLayout {} + + @ParentLayout(RootLayout.class) + public static class MiddleLayout extends Div implements RouterLayout {} + + @ParentLayout(MiddleLayout.class) + public static class LeafLayout extends Div implements RouterLayout {} + + public static class CallableLayout extends Div implements RouterLayout { + @LegacyClientCallable + protected JsonValue test(JsonValue value) { + return value; + } + } + + public static class NonComponentLayout implements RouterLayout { + @Override + public Element getElement() { + return null; + } + } + + @InstrumentedRoute("without-layout") + public static class RouteWithoutLayout extends Div {} + + @InstrumentedRoute(value = "with-layout", layout = LeafLayout.class) + public static class RouteWithLayout extends Div {} + + @Route("already-annotated") + @InstrumentedRoute("already-annotated-instrumented") + public static class RouteAlreadyAnnotated extends Div {} + + @Test + public void testLayoutDefaultsToUI() { + InstrumentedRoute annotation = RouteWithoutLayout.class.getAnnotation(InstrumentedRoute.class); + assertEquals(UI.class, annotation.layout()); + } + + @Test + public void testDefaultLayoutYieldsEmptyParentChain() { + InstrumentedRoute annotation = RouteWithoutLayout.class.getAnnotation(InstrumentedRoute.class); + assertTrue(InstrumentationViewInitializer.getParentChain(annotation.layout()).isEmpty()); + } + + @Test + public void testSingleLayout() { + List> chain = + InstrumentationViewInitializer.getParentChain(RootLayout.class); + assertEquals(Arrays.asList(JsonMigration.instrumentClass(RootLayout.class)), chain); + } + + @Test + public void testParentLayoutChainIsWalked() { + InstrumentedRoute annotation = RouteWithLayout.class.getAnnotation(InstrumentedRoute.class); + List> chain = + InstrumentationViewInitializer.getParentChain(annotation.layout()); + assertEquals( + Arrays.asList( + JsonMigration.instrumentClass(LeafLayout.class), + JsonMigration.instrumentClass(MiddleLayout.class), + JsonMigration.instrumentClass(RootLayout.class)), + chain); + } + + @Test + public void testLayoutIsInstrumented() { + List> chain = + InstrumentationViewInitializer.getParentChain(CallableLayout.class); + assertEquals(1, chain.size()); + Class instrumented = chain.get(0); + assertEquals(JsonMigration.instrumentClass(CallableLayout.class), instrumented); + assertTrue(CallableLayout.class.isAssignableFrom(instrumented)); + assertTrue(RouterLayout.class.isAssignableFrom(instrumented)); + } + + @Test(expected = IllegalArgumentException.class) + public void testNonComponentLayoutThrows() { + InstrumentationViewInitializer.getParentChain(NonComponentLayout.class); + } + + @Test + public void testRouteAnnotationIsAdded() { + Class instrumented = JsonMigration.instrumentClass(RouteWithoutLayout.class); + assertNotEquals(RouteWithoutLayout.class, instrumented); + Route route = instrumented.getAnnotation(Route.class); + assertNotNull(route); + assertEquals("without-layout", route.value()); + assertEquals(UI.class, route.layout()); + assertFalse(route.registerAtStartup()); + } + + @Test + public void testRouteAnnotationPropagatesLayout() { + Class instrumented = JsonMigration.instrumentClass(RouteWithLayout.class); + Route route = instrumented.getAnnotation(Route.class); + assertNotNull(route); + assertEquals("with-layout", route.value()); + assertEquals(LeafLayout.class, route.layout()); + assertFalse(route.registerAtStartup()); + } + + @Test + public void testExistingRouteAnnotationIsNotReplaced() { + assertEquals( + RouteAlreadyAnnotated.class, JsonMigration.instrumentClass(RouteAlreadyAnnotated.class)); + } +} diff --git a/tests-v25/pom.xml b/tests-v25/pom.xml index e03d9b8..3799072 100644 --- a/tests-v25/pom.xml +++ b/tests-v25/pom.xml @@ -30,6 +30,7 @@ **/*Test25.java **/JsonMigrationHelper25Test.java **/LitRendererMigrationExtensionTest.java + **/InstrumentationViewInitializerTest.java