Skip to content

Commit 66bb13f

Browse files
authored
feat: Add Html(signal) and bindHtmlContent(signal) (#23270)
Fixes #23268
1 parent 05fd490 commit 66bb13f

File tree

3 files changed

+283
-0
lines changed

3 files changed

+283
-0
lines changed

flow-server/src/main/java/com/vaadin/flow/component/Html.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
import org.jsoup.nodes.Document;
2828

2929
import com.vaadin.flow.dom.Element;
30+
import com.vaadin.flow.dom.ElementEffect;
31+
import com.vaadin.flow.internal.nodefeature.SignalBindingFeature;
32+
import com.vaadin.flow.shared.Registration;
33+
import com.vaadin.signals.BindingActiveException;
34+
import com.vaadin.signals.Signal;
3035

3136
import static java.nio.charset.StandardCharsets.UTF_8;
3237

@@ -116,6 +121,35 @@ public Html(String outerHtml) {
116121
setOuterHtml(outerHtml, false);
117122
}
118123

124+
/**
125+
* Creates an instance based on the given HTML fragment signal. The signal's
126+
* current value must have exactly one root element. Subsequent changes to
127+
* the signal will update the component's content (root tag cannot be
128+
* changed after creation).
129+
*
130+
* @param htmlSignal
131+
* the signal that provides the HTML outer content
132+
* @throws IllegalArgumentException
133+
* if the signal is {@code null} or its current value is null or
134+
* empty, or doesn't have exactly one root element
135+
*/
136+
public Html(Signal<String> htmlSignal) {
137+
super(null);
138+
if (htmlSignal == null) {
139+
throw new IllegalArgumentException("HTML signal cannot be null");
140+
}
141+
String outerHtml = htmlSignal.value();
142+
if (outerHtml == null || outerHtml.isEmpty()) {
143+
throw new IllegalArgumentException("HTML cannot be null or empty");
144+
}
145+
// Initialize from current signal value (sets the root element and
146+
// attrs)
147+
setOuterHtml(outerHtml, false);
148+
// Bind further updates to inner content and attributes (root tag cannot
149+
// change)
150+
bindHtmlContent(htmlSignal);
151+
}
152+
119153
/**
120154
* Sets the content based on the given HTML fragment. The fragment must have
121155
* exactly one root element, which matches the existing one.
@@ -130,6 +164,15 @@ public Html(String outerHtml) {
130164
* the HTML to wrap
131165
*/
132166
public void setHtmlContent(String html) {
167+
// Disallow manual setting while a binding exists
168+
getElement().getNode()
169+
.getFeatureIfInitialized(SignalBindingFeature.class)
170+
.ifPresent(feature -> {
171+
if (feature.hasBinding(SignalBindingFeature.HTML_CONTENT)) {
172+
throw new BindingActiveException(
173+
"setHtmlContent is not allowed while a binding for HTML content exists.");
174+
}
175+
});
133176
setOuterHtml(html, true);
134177
}
135178

@@ -194,4 +237,43 @@ public String getInnerHtml() {
194237
return get(innerHtmlDescriptor);
195238
}
196239

240+
/**
241+
* Binds a {@link com.vaadin.signals.Signal}'s value to this component's
242+
* HTML content (outer HTML) and keeps the content synchronized with the
243+
* signal value while the component is attached. When the component is
244+
* detached, signal value changes have no effect. Passing <code>null</code>
245+
* unbinds any existing binding.
246+
* <p>
247+
* While a Signal is bound to the HTML content, any attempt to set the HTML
248+
* content manually via {@link #setHtmlContent(String)} throws
249+
* {@link com.vaadin.signals.BindingActiveException}. The same happens when
250+
* trying to bind a new Signal while one is already bound.
251+
* <p>
252+
* The first value of the signal must have exactly one root element. When
253+
* updating the content, the root tag name must remain the same as the
254+
* component's current root tag.
255+
*
256+
* @param htmlSignal
257+
* the signal to bind or <code>null</code> to unbind any existing
258+
* binding
259+
* @throws com.vaadin.signals.BindingActiveException
260+
* thrown when there is already an existing binding
261+
*/
262+
public void bindHtmlContent(Signal<String> htmlSignal) {
263+
SignalBindingFeature feature = getElement().getNode()
264+
.getFeature(SignalBindingFeature.class);
265+
266+
if (htmlSignal == null) {
267+
feature.removeBinding(SignalBindingFeature.HTML_CONTENT);
268+
} else {
269+
if (feature.hasBinding(SignalBindingFeature.HTML_CONTENT)) {
270+
throw new BindingActiveException();
271+
}
272+
273+
Registration registration = ElementEffect.bind(getElement(),
274+
htmlSignal, (element, value) -> setOuterHtml(value, true));
275+
feature.setBinding(SignalBindingFeature.HTML_CONTENT, registration,
276+
htmlSignal);
277+
}
278+
}
197279
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public class SignalBindingFeature extends ServerSideFeature {
3535
public static final String ENABLED = "enabled";
3636
public static final String VALUE = "value";
3737
public static final String THEMES = "themes/";
38+
public static final String HTML_CONTENT = "htmlContent";
3839

3940
private Map<String, SignalBinding> values;
4041

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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.component;
17+
18+
import org.junit.Test;
19+
20+
import com.vaadin.flow.dom.SignalsUnitTest;
21+
import com.vaadin.signals.BindingActiveException;
22+
import com.vaadin.signals.ValueSignal;
23+
24+
import static org.junit.Assert.assertEquals;
25+
import static org.junit.Assert.assertThrows;
26+
27+
/**
28+
* Unit tests for Html.bindHtmlContent(Signal<String>).
29+
*/
30+
public class HtmlBindHtmlContentTest extends SignalsUnitTest {
31+
32+
@Test
33+
public void bindHtmlContent_componentAttachedBefore_bindingActive() {
34+
Html html = new Html("<div id='a'>init</div>");
35+
// attach before bind
36+
UI.getCurrent().add(html);
37+
38+
ValueSignal<String> signal = new ValueSignal<>(
39+
"<div id='b'>after</div>");
40+
html.bindHtmlContent(signal);
41+
42+
assertEquals("after", html.getInnerHtml());
43+
assertEquals("b", html.getElement().getAttribute("id"));
44+
}
45+
46+
@Test
47+
public void bindHtmlContent_componentAttachedAfter_bindingActive() {
48+
Html html = new Html("<div id='a'>init</div>");
49+
ValueSignal<String> signal = new ValueSignal<>(
50+
"<div id='b'>after</div>");
51+
html.bindHtmlContent(signal);
52+
53+
// attach after bind
54+
UI.getCurrent().add(html);
55+
56+
assertEquals("after", html.getInnerHtml());
57+
assertEquals("b", html.getElement().getAttribute("id"));
58+
}
59+
60+
@Test
61+
public void bindHtmlContent_componentAttached_bindingActive_updatesOnChange() {
62+
Html html = new Html("<div id='a'>init</div>");
63+
UI.getCurrent().add(html);
64+
65+
ValueSignal<String> signal = new ValueSignal<>("<div id='b'>v1</div>");
66+
html.bindHtmlContent(signal);
67+
68+
assertEquals("v1", html.getInnerHtml());
69+
assertEquals("b", html.getElement().getAttribute("id"));
70+
71+
// update value while attached
72+
signal.value("<div id='c'>v2</div>");
73+
assertEquals("v2", html.getInnerHtml());
74+
assertEquals("c", html.getElement().getAttribute("id"));
75+
}
76+
77+
@Test
78+
public void bindHtmlContent_componentNotAttached_bindingInactive() {
79+
Html html = new Html("<div id='a'>init</div>");
80+
ValueSignal<String> signal = new ValueSignal<>(
81+
"<div id='b'>after</div>");
82+
html.bindHtmlContent(signal);
83+
84+
// change ignored while not attached
85+
signal.value("<div id='c'>ignored</div>");
86+
87+
assertEquals("init", html.getInnerHtml());
88+
assertEquals("a", html.getElement().getAttribute("id"));
89+
}
90+
91+
@Test
92+
public void bindHtmlContent_componentDetached_bindingInactive() {
93+
Html html = new Html("<div id='a'>init</div>");
94+
UI.getCurrent().add(html);
95+
ValueSignal<String> signal = new ValueSignal<>(
96+
"<div id='b'>after</div>");
97+
html.bindHtmlContent(signal);
98+
// detach
99+
html.getElement().removeFromParent();
100+
101+
// change ignored while detached
102+
signal.value("<div id='c'>ignored</div>");
103+
104+
assertEquals("after", html.getInnerHtml());
105+
assertEquals("b", html.getElement().getAttribute("id"));
106+
}
107+
108+
@Test
109+
public void bindHtmlContent_componentReAttached_bindingActivate() {
110+
Html html = new Html("<div id='a'>init</div>");
111+
UI.getCurrent().add(html);
112+
ValueSignal<String> signal = new ValueSignal<>(
113+
"<div id='b'>after</div>");
114+
html.bindHtmlContent(signal);
115+
// detach
116+
html.getElement().removeFromParent();
117+
118+
// change while detached
119+
signal.value("<div id='c'>after2</div>");
120+
// re-attach
121+
UI.getCurrent().add(html);
122+
123+
assertEquals("after2", html.getInnerHtml());
124+
assertEquals("c", html.getElement().getAttribute("id"));
125+
}
126+
127+
@Test
128+
public void bindHtmlContent_withNullValue_recordsErrorAndDoesNotChange() {
129+
Html html = new Html("<div id='a'>init</div>");
130+
UI.getCurrent().add(html);
131+
ValueSignal<String> signal = new ValueSignal<>(
132+
"<div id='b'>after</div>");
133+
html.bindHtmlContent(signal);
134+
135+
// sending null will cause NPE in setHtmlContent inside effect function;
136+
// state should not change, and an error should be captured by
137+
// SignalsUnitTest error handler
138+
signal.value(null);
139+
140+
assertEquals("after", html.getInnerHtml());
141+
assertEquals("b", html.getElement().getAttribute("id"));
142+
// one error captured
143+
assertEquals(1, events.size());
144+
assertEquals(NullPointerException.class,
145+
events.getFirst().getThrowable().getClass());
146+
// clear events for next verification in SignalsUnitTest.after
147+
events.clear();
148+
}
149+
150+
@Test
151+
public void bindHtmlContent_withNullBinding_removesBinding() {
152+
Html html = new Html("<div id='a'>init</div>");
153+
UI.getCurrent().add(html);
154+
ValueSignal<String> signal = new ValueSignal<>(
155+
"<div id='b'>after</div>");
156+
html.bindHtmlContent(signal);
157+
assertEquals("after", html.getInnerHtml());
158+
159+
// remove binding
160+
html.bindHtmlContent(null);
161+
// further changes are ignored
162+
signal.value("<div id='c'>ignored</div>");
163+
assertEquals("after", html.getInnerHtml());
164+
assertEquals("b", html.getElement().getAttribute("id"));
165+
}
166+
167+
@Test
168+
public void bindHtmlContent_withNullBinding_allowsSetHtmlContent() {
169+
Html html = new Html("<div id='a'>init</div>");
170+
UI.getCurrent().add(html);
171+
ValueSignal<String> signal = new ValueSignal<>(
172+
"<div id='b'>after</div>");
173+
html.bindHtmlContent(signal);
174+
assertEquals("after", html.getInnerHtml());
175+
176+
// remove binding
177+
html.bindHtmlContent(null);
178+
179+
html.setHtmlContent("<div id='c'>manual</div>");
180+
assertEquals("manual", html.getInnerHtml());
181+
assertEquals("c", html.getElement().getAttribute("id"));
182+
}
183+
184+
@Test
185+
public void bindHtmlContent_setterAndRebindWhileActive_throwException() {
186+
Html html = new Html("<div id='a'>init</div>");
187+
UI.getCurrent().add(html);
188+
ValueSignal<String> signal = new ValueSignal<>(
189+
"<div id='b'>after</div>");
190+
html.bindHtmlContent(signal);
191+
192+
assertThrows(BindingActiveException.class,
193+
() -> html.setHtmlContent("<div id='c'>manual</div>"));
194+
assertThrows(BindingActiveException.class,
195+
() -> html.bindHtmlContent(new ValueSignal<>("<div>x</div>")));
196+
// state unchanged
197+
assertEquals("after", html.getInnerHtml());
198+
assertEquals("b", html.getElement().getAttribute("id"));
199+
}
200+
}

0 commit comments

Comments
 (0)