Skip to content

Commit 2572b3d

Browse files
fix: preserve slot attribute for initially invisible elements (#24037) (CP: 25.0) (#24050)
This PR cherry-picks changes from the original PR #24037 to branch 25.0. --- #### Original PR description > When a component is set invisible before being attached, Flow's client-side binding skips all attribute binding. This means structural attributes like "slot" are never applied to the DOM, breaking CSS selectors that depend on them (e.g. Aura theme's structural selectors for AppLayout drawer). > > On the server side, `StateNode.collectChanges()` now emits the slot attribute even for invisible nodes, without consuming the feature's change tracker so the full attribute set is still sent on visibility change. On the client side, > `SimpleElementBindingStrategy` applies the slot attribute to the DOM during initial bind of invisible elements. The attribute name is defined once in `NodeProperties.SLOT_ATTRIBUTE` and referenced by both server and client. > > Fixes vaadin/web-components#11212 --------- Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent 868857d commit 2572b3d

File tree

7 files changed

+211
-0
lines changed

7 files changed

+211
-0
lines changed

flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ assert hasSameTag(stateNode, htmlNode) : "Element tag name is '"
259259
// Prepare teardown
260260
listeners.push(stateNode.addUnregisterListener(
261261
e -> remove(listeners, context, computationsCollection)));
262+
} else {
263+
applyStructuralAttributes(stateNode, htmlNode);
262264
}
263265
listeners.push(bindVisibility(listeners, context,
264266
computationsCollection, nodeFactory));
@@ -667,6 +669,24 @@ private void setElementInvisible(Element element, NodeMap visibilityData) {
667669
}
668670
}
669671

672+
/**
673+
* Applies structural attributes (like "slot") to the element even when it
674+
* is initially invisible. This preserves CSS selectors that depend on these
675+
* attributes without exposing backend data.
676+
*/
677+
private void applyStructuralAttributes(StateNode stateNode,
678+
Element element) {
679+
if (stateNode.hasFeature(NodeFeatures.ELEMENT_ATTRIBUTES)) {
680+
NodeMap attributeMap = stateNode
681+
.getMap(NodeFeatures.ELEMENT_ATTRIBUTES);
682+
String attr = NodeProperties.SLOT_ATTRIBUTE;
683+
if (attributeMap.hasPropertyValue(attr)) {
684+
MapProperty property = attributeMap.getProperty(attr);
685+
updateAttribute(property, element);
686+
}
687+
}
688+
}
689+
670690
private void restoreInitialHiddenAttribute(Element element,
671691
NodeMap visibilityData) {
672692
storeInitialHiddenAttribute(element, visibilityData);

flow-client/src/test-gwt/java/com/vaadin/client/flow/GwtBasicElementBinderTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,6 +1611,35 @@ public void testBindInvisibleElement_unbind() {
16111611
assertNull(element.getAttribute("hidden"));
16121612
}
16131613

1614+
public void testBindInvisibleNode_slotAttributeIsPreserved() {
1615+
node.getMap(NodeFeatures.ELEMENT_DATA)
1616+
.getProperty(NodeProperties.VISIBLE).setValue(false);
1617+
1618+
attributes.getProperty("slot").setValue("drawer");
1619+
1620+
Binder.bind(node, element);
1621+
1622+
Reactive.flush();
1623+
1624+
assertEquals(Boolean.TRUE.toString(), element.getAttribute("hidden"));
1625+
assertEquals("drawer", element.getAttribute("slot"));
1626+
}
1627+
1628+
public void testBindInvisibleNode_nonStructuralAttributesAreNotApplied() {
1629+
node.getMap(NodeFeatures.ELEMENT_DATA)
1630+
.getProperty(NodeProperties.VISIBLE).setValue(false);
1631+
1632+
attributes.getProperty("data-info").setValue("secret");
1633+
attributes.getProperty("slot").setValue("drawer");
1634+
1635+
Binder.bind(node, element);
1636+
1637+
Reactive.flush();
1638+
1639+
assertEquals("drawer", element.getAttribute("slot"));
1640+
assertNull(element.getAttribute("data-info"));
1641+
}
1642+
16141643
/**
16151644
* The StateNode is visible (the visibility is true).
16161645
*

flow-server/src/main/java/com/vaadin/flow/internal/StateNode.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,16 @@
4444
import com.vaadin.flow.function.SerializableConsumer;
4545
import com.vaadin.flow.internal.StateTree.BeforeClientResponseEntry;
4646
import com.vaadin.flow.internal.StateTree.ExecutionRegistration;
47+
import com.vaadin.flow.internal.change.MapPutChange;
4748
import com.vaadin.flow.internal.change.NodeAttachChange;
4849
import com.vaadin.flow.internal.change.NodeChange;
4950
import com.vaadin.flow.internal.change.NodeDetachChange;
51+
import com.vaadin.flow.internal.nodefeature.ElementAttributeMap;
5052
import com.vaadin.flow.internal.nodefeature.ElementData;
5153
import com.vaadin.flow.internal.nodefeature.InertData;
5254
import com.vaadin.flow.internal.nodefeature.NodeFeature;
5355
import com.vaadin.flow.internal.nodefeature.NodeFeatureRegistry;
56+
import com.vaadin.flow.internal.nodefeature.NodeProperties;
5457
import com.vaadin.flow.server.Command;
5558
import com.vaadin.flow.shared.Registration;
5659

@@ -676,6 +679,7 @@ public void collectChanges(Consumer<NodeChange> collector) {
676679
if (hasFeature(ElementData.class)) {
677680
doCollectChanges(collector,
678681
Stream.of(getFeature(ElementData.class)));
682+
collectStructuralAttributeChanges(collector);
679683
}
680684
return;
681685
}
@@ -708,6 +712,30 @@ private void doCollectChanges(Consumer<NodeChange> collector,
708712
}
709713
}
710714

715+
/**
716+
* Emits only structural attribute changes for invisible elements. This
717+
* sends a filtered subset of {@link ElementAttributeMap} values to avoid
718+
* leaking data attributes to the client while preserving attributes needed
719+
* for CSS selectors.
720+
* <p>
721+
* This does not consume the feature's change tracker, so the full attribute
722+
* set is still available when the element becomes visible and gets fully
723+
* bound.
724+
*/
725+
private void collectStructuralAttributeChanges(
726+
Consumer<NodeChange> collector) {
727+
if (!hasFeature(ElementAttributeMap.class)) {
728+
return;
729+
}
730+
ElementAttributeMap attributeMap = getFeature(
731+
ElementAttributeMap.class);
732+
String attr = NodeProperties.SLOT_ATTRIBUTE;
733+
if (attributeMap.has(attr)) {
734+
collector.accept(new MapPutChange(attributeMap, attr,
735+
attributeMap.get(attr)));
736+
}
737+
}
738+
711739
private boolean hasChangeTracker(NodeFeature nodeFeature) {
712740
return changes != null && changes.containsKey(nodeFeature.getClass());
713741
}

flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeProperties.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ public final class NodeProperties {
121121
*/
122122
public static final String URI_ATTRIBUTE = "uri";
123123

124+
/**
125+
* The "slot" attribute, which should be sent to the client and applied to
126+
* the DOM element even when the element is initially invisible. This is a
127+
* structural attribute needed for CSS selectors and layout.
128+
*/
129+
public static final String SLOT_ATTRIBUTE = "slot";
130+
124131
private NodeProperties() {
125132
}
126133
}

flow-server/src/test/java/com/vaadin/flow/internal/StateNodeTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,4 +1885,34 @@ private void assertCollectChanges_initiallyVisible(StateNode stateNode,
18851885

18861886
Assert.assertEquals(0, changes.size());
18871887
}
1888+
1889+
@Test
1890+
public void collectChanges_invisibleNode_slotAttributeCollected() {
1891+
UI ui = new UI();
1892+
Element element = ElementFactory.createDiv();
1893+
element.setAttribute("slot", "drawer");
1894+
element.setAttribute("data-secret", "sensitive");
1895+
element.setVisible(false);
1896+
1897+
ui.getElement().appendChild(element);
1898+
1899+
StateNode stateNode = element.getNode();
1900+
1901+
List<NodeChange> changes = new ArrayList<>();
1902+
stateNode.collectChanges(changes::add);
1903+
1904+
List<MapPutChange> attributeChanges = changes.stream()
1905+
.filter(MapPutChange.class::isInstance)
1906+
.map(MapPutChange.class::cast)
1907+
.filter(change -> change
1908+
.getFeature() == ElementAttributeMap.class)
1909+
.collect(Collectors.toList());
1910+
1911+
// Only "slot" should be sent, not "data-secret"
1912+
Assert.assertEquals(
1913+
"Only structural attributes should be collected for invisible nodes",
1914+
1, attributeChanges.size());
1915+
Assert.assertEquals("slot", attributeChanges.get(0).getKey());
1916+
Assert.assertEquals("drawer", attributeChanges.get(0).getValue());
1917+
}
18881918
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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.uitest.ui;
17+
18+
import com.vaadin.flow.component.html.Div;
19+
import com.vaadin.flow.component.html.NativeButton;
20+
import com.vaadin.flow.router.Route;
21+
import com.vaadin.flow.uitest.servlet.ViewTestLayout;
22+
23+
@Route(value = "com.vaadin.flow.uitest.ui.InvisibleSlotAttributeView", layout = ViewTestLayout.class)
24+
public class InvisibleSlotAttributeView extends AbstractDivView {
25+
26+
public InvisibleSlotAttributeView() {
27+
Div target = new Div("Initially invisible");
28+
target.setId("target");
29+
target.getElement().setAttribute("slot", "drawer");
30+
target.getElement().setAttribute("data-info", "sensitive");
31+
target.setVisible(false);
32+
33+
NativeButton showButton = new NativeButton("Make visible",
34+
e -> target.setVisible(true));
35+
showButton.setId("show-button");
36+
37+
add(showButton, target);
38+
}
39+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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.uitest.ui;
17+
18+
import org.junit.Assert;
19+
import org.junit.Test;
20+
import org.openqa.selenium.By;
21+
import org.openqa.selenium.WebElement;
22+
import org.openqa.selenium.support.ui.ExpectedConditions;
23+
24+
import com.vaadin.flow.component.html.testbench.NativeButtonElement;
25+
import com.vaadin.flow.testutil.ChromeBrowserTest;
26+
27+
public class InvisibleSlotAttributeIT extends ChromeBrowserTest {
28+
29+
@Test
30+
public void initiallyInvisibleElement_slotAttributeImmediatelyPropagated() {
31+
open();
32+
33+
// Find the element by the "slot" attribute, since "id" property
34+
// is not bound for invisible elements
35+
WebElement target = findElement(By.cssSelector("[slot='drawer']"));
36+
37+
// Element is hidden but "slot" attribute should be present
38+
Assert.assertEquals(Boolean.TRUE.toString(),
39+
target.getAttribute("hidden"));
40+
Assert.assertEquals("drawer", target.getAttribute("slot"));
41+
// Non-structural attributes must NOT be sent for invisible elements
42+
Assert.assertNull(target.getAttribute("data-info"));
43+
44+
$(NativeButtonElement.class).id("show-button").click();
45+
46+
// After becoming visible, the element is re-bound and gets its id
47+
waitUntil(ExpectedConditions.presenceOfElementLocated(By.id("target")));
48+
WebElement visibleTarget = findElement(By.id("target"));
49+
50+
// "hidden" should be gone and "slot" should still be present
51+
Assert.assertNull(visibleTarget.getAttribute("hidden"));
52+
Assert.assertEquals("drawer", visibleTarget.getAttribute("slot"));
53+
54+
// All attributes are sent after the element becomes visible
55+
Assert.assertEquals("sensitive",
56+
visibleTarget.getAttribute("data-info"));
57+
}
58+
}

0 commit comments

Comments
 (0)