diff --git a/jmix-flowui/flowui-kit/src/main/java/io/jmix/flowui/kit/component/ComponentUtils.java b/jmix-flowui/flowui-kit/src/main/java/io/jmix/flowui/kit/component/ComponentUtils.java index 57908e91ae..4fa48b241b 100644 --- a/jmix-flowui/flowui-kit/src/main/java/io/jmix/flowui/kit/component/ComponentUtils.java +++ b/jmix-flowui/flowui-kit/src/main/java/io/jmix/flowui/kit/component/ComponentUtils.java @@ -30,6 +30,7 @@ import com.vaadin.flow.component.select.Select; import com.vaadin.flow.component.shared.SlotUtils; import com.vaadin.flow.data.provider.HasListDataView; +import com.vaadin.flow.dom.Element; import com.vaadin.flow.dom.ElementConstants; import io.jmix.flowui.kit.action.Action; import io.jmix.flowui.kit.component.loginform.EnhancedLoginForm; @@ -133,11 +134,42 @@ public static Icon copyIconComponent(Icon icon) { private static void copyAbstractIconAttributes(AbstractIcon icon, AbstractIcon iconCopy) { iconCopy.setColor(icon.getColor()); iconCopy.setSize(icon.getStyle().get(ElementConstants.STYLE_WIDTH)); - iconCopy.setTooltipText(icon.getTooltip().getText()); + copyTooltip(icon, iconCopy); iconCopy.setVisible(icon.isVisible()); iconCopy.addClassNames(icon.getClassNames().toArray(new String[0])); } + /** + * Copies the tooltip content (plain text or Markdown) from the source icon to its copy. + *

+ * Reads the tooltip directly from the source's slot instead of calling AbstractIcon#getTooltip(), + * which lazily creates and attaches a tooltip element to the source on first access. The source icon + * may be shared between UIs (e.g. an icon from menu configuration), so mutating its state node from + * several threads concurrently would cause a ConcurrentModificationException. + * + * @param icon source icon + * @param iconCopy copy of the source icon + */ + private static void copyTooltip(AbstractIcon icon, AbstractIcon iconCopy) { + Element tooltipElement = SlotUtils.getElementsInSlot(icon, "tooltip") + .findFirst() + .orElse(null); + if (tooltipElement == null) { + return; + } + + String text = tooltipElement.getProperty("text"); + if (text == null) { + return; + } + + if (tooltipElement.getProperty("markdown", false)) { + iconCopy.setTooltipMarkdown(text); + } else { + iconCopy.setTooltipText(text); + } + } + /** * Creates a copy of svg icon component. * diff --git a/jmix-flowui/flowui/src/test/groovy/component_utils/ComponentUtilsTest.groovy b/jmix-flowui/flowui/src/test/groovy/component_utils/ComponentUtilsTest.groovy index 6733173cfc..aa09f7dfce 100644 --- a/jmix-flowui/flowui/src/test/groovy/component_utils/ComponentUtilsTest.groovy +++ b/jmix-flowui/flowui/src/test/groovy/component_utils/ComponentUtilsTest.groovy @@ -16,9 +16,19 @@ package component_utils +import com.vaadin.flow.component.icon.Icon +import com.vaadin.flow.component.icon.VaadinIcon +import com.vaadin.flow.component.shared.SlotUtils import io.jmix.flowui.kit.component.ComponentUtils import spock.lang.Specification +import java.util.concurrent.CountDownLatch +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + class ComponentUtilsTest extends Specification { def "Check isAutoSize()"(String size, boolean expected) { @@ -38,4 +48,85 @@ class ComponentUtilsTest extends Specification { "-12em" | false "-1px" | false } + + def "copyIcon does not create a tooltip on the source icon"() { + given: "A cold icon whose tooltip has not been created yet" + Icon source = VaadinIcon.HOME.create() + + when: "The icon is copied" + ComponentUtils.copyIcon(source) + + then: "The source icon is left untouched, no tooltip element is attached to it" + SlotUtils.getElementsInSlot(source, "tooltip").findAny().isEmpty() + } + + def "copyIcon preserves the plain text tooltip of the source icon"() { + given: "An icon with a plain text tooltip" + Icon source = VaadinIcon.HOME.create() + source.setTooltipText("My tooltip") + + when: "The icon is copied" + Icon copy = (Icon) ComponentUtils.copyIcon(source) + + then: "The copy has the same content and is not treated as Markdown" + def tooltip = SlotUtils.getElementsInSlot(copy, "tooltip").findFirst().orElse(null) + tooltip != null + tooltip.getProperty("text") == "My tooltip" + !tooltip.getProperty("markdown", false) + } + + def "copyIcon preserves the Markdown tooltip of the source icon"() { + given: "An icon with a Markdown tooltip" + Icon source = VaadinIcon.HOME.create() + source.setTooltipMarkdown("**bold**") + + when: "The icon is copied" + Icon copy = (Icon) ComponentUtils.copyIcon(source) + + then: "The copy keeps the content and the Markdown flag" + def tooltip = SlotUtils.getElementsInSlot(copy, "tooltip").findFirst().orElse(null) + tooltip != null + tooltip.getProperty("text") == "**bold**" + tooltip.getProperty("markdown", false) + } + + def "copyIcon does not mutate the shared source icon under concurrency"() { + given: "A thread pool that repeatedly copies a freshly created shared icon" + int threads = 16 + int rounds = 500 + ExecutorService executor = Executors.newFixedThreadPool(threads) + AtomicReference failure = new AtomicReference<>() + + when: "All threads copy the same cold icon simultaneously in each round" + try { + for (int round = 0; round < rounds && failure.get() == null; round++) { + Icon shared = VaadinIcon.HOME.create() + CyclicBarrier barrier = new CyclicBarrier(threads) + CountDownLatch done = new CountDownLatch(threads) + threads.times { + executor.execute { + try { + barrier.await() + ComponentUtils.copyIcon(shared) + } catch (Throwable ex) { + failure.compareAndSet(null, ex) + } finally { + done.countDown() + } + } + } + done.await(30, TimeUnit.SECONDS) + } + } finally { + executor.shutdownNow() + } + + Throwable t = failure.get() + if (t != null) { + throw t + } + + then: "Copying never mutates the shared icon, so no exception is thrown" + noExceptionThrown() + } }