Skip to content

Commit e1d46ea

Browse files
authored
fix: load Image/IFrame sources when disabled (#24346)
When an `Image` or `IFrame` backed by a `DownloadHandler` lives inside a disabled component, the browser receives a 403 and the resource never loads. `Image.setSrc(DownloadHandler)` and `IFrame.setSrc(DownloadHandler)` now allow the resource to be served regardless of the owner's enabled state, since these sources are fetched passively as part of rendering rather than as a user action. Fixes #22772
1 parent 1311662 commit e1d46ea

8 files changed

Lines changed: 385 additions & 4 deletions

File tree

flow-html-components/src/main/java/com/vaadin/flow/component/html/IFrame.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ public void setSrc(AbstractStreamResource src) {
189189
* {@link DownloadHandler}, as well as for other
190190
* {@link AbstractDownloadHandler} implementations.
191191
*
192+
* The handler is wrapped with {@link DownloadHandler#allowDisabled()} so
193+
* that the iframe content is still served when the component, or one of its
194+
* ancestors, is disabled. The browser fetches the content as part of
195+
* rendering rather than as a user action, so blocking the request on the
196+
* disabled state would leave the iframe empty.
197+
*
192198
* @see #setSrc(String)
193199
*
194200
* @param downloadHandler
@@ -200,7 +206,7 @@ public void setSrc(DownloadHandler downloadHandler) {
200206
// where it is 'attachment' by default
201207
handler.inline();
202208
}
203-
getElement().setAttribute("src", downloadHandler);
209+
getElement().setAttribute("src", downloadHandler.allowDisabled());
204210
}
205211

206212
/**

flow-html-components/src/main/java/com/vaadin/flow/component/html/Image.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ public void setSrc(AbstractStreamResource src) {
225225
* {@link DownloadHandler}, as well as for other
226226
* {@link AbstractDownloadHandler} implementations.
227227
*
228+
* The handler is wrapped with {@link DownloadHandler#allowDisabled()} so
229+
* that the image is still served when the component, or one of its
230+
* ancestors, is disabled. The browser fetches the image as part of
231+
* rendering rather than as a user action, so blocking the request on the
232+
* disabled state would leave the icon broken.
233+
*
228234
* @param downloadHandler
229235
* the download handler resource, not null
230236
*/
@@ -234,7 +240,7 @@ public void setSrc(DownloadHandler downloadHandler) {
234240
// where it is 'attachment' by default
235241
handler.inline();
236242
}
237-
getElement().setAttribute("src", downloadHandler);
243+
getElement().setAttribute("src", downloadHandler.allowDisabled());
238244
}
239245

240246
/**

flow-html-components/src/test/java/com/vaadin/flow/component/html/IFrameTest.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@
1818
import java.lang.reflect.Field;
1919

2020
import org.junit.jupiter.api.Test;
21+
import org.mockito.ArgumentCaptor;
2122
import org.mockito.Mockito;
2223

2324
import com.vaadin.flow.component.Component;
25+
import com.vaadin.flow.dom.DisabledUpdateMode;
2426
import com.vaadin.flow.dom.Element;
2527
import com.vaadin.flow.server.streams.DownloadHandler;
2628
import com.vaadin.flow.server.streams.DownloadResponse;
2729
import com.vaadin.flow.server.streams.InputStreamDownloadHandler;
2830

31+
import static org.junit.jupiter.api.Assertions.assertEquals;
2932
import static org.junit.jupiter.api.Assertions.assertFalse;
3033
import static org.junit.jupiter.api.Assertions.assertTrue;
3134

@@ -70,6 +73,29 @@ protected void testHasAriaLabelIsImplemented() {
7073
super.testHasAriaLabelIsImplemented();
7174
}
7275

76+
@Test
77+
void setSrc_downloadHandler_disabledUpdateModeIsAlways() {
78+
Element element = Mockito.mock(Element.class);
79+
class TestIFrame extends IFrame {
80+
@Override
81+
public Element getElement() {
82+
return element;
83+
}
84+
}
85+
// Plain lambda DownloadHandler, not an AbstractDownloadHandler subclass
86+
DownloadHandler lambda = event -> {
87+
};
88+
89+
new TestIFrame().setSrc(lambda);
90+
91+
ArgumentCaptor<DownloadHandler> captor = ArgumentCaptor
92+
.forClass(DownloadHandler.class);
93+
Mockito.verify(element).setAttribute(Mockito.eq("src"),
94+
captor.capture());
95+
assertEquals(DisabledUpdateMode.ALWAYS,
96+
captor.getValue().getDisabledUpdateMode());
97+
}
98+
7399
@Test
74100
void downloadHandler_isSetToInline() {
75101
Element element = Mockito.mock(Element.class);

flow-html-components/src/test/java/com/vaadin/flow/component/html/ImageTest.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.mockito.ArgumentCaptor;
2424
import org.mockito.Mockito;
2525

26+
import com.vaadin.flow.dom.DisabledUpdateMode;
2627
import com.vaadin.flow.dom.Element;
2728
import com.vaadin.flow.server.VaadinRequest;
2829
import com.vaadin.flow.server.VaadinResponse;
@@ -62,6 +63,29 @@ void emptyAltKeepsAttribute() {
6263
assertFalse(img.getElement().hasAttribute("alt"));
6364
}
6465

66+
@Test
67+
void setSrc_downloadHandler_disabledUpdateModeIsAlways() {
68+
Element element = Mockito.mock(Element.class);
69+
class TestImage extends Image {
70+
@Override
71+
public Element getElement() {
72+
return element;
73+
}
74+
}
75+
// Plain lambda DownloadHandler, not an AbstractDownloadHandler subclass
76+
DownloadHandler lambda = event -> {
77+
};
78+
79+
new TestImage().setSrc(lambda);
80+
81+
ArgumentCaptor<DownloadHandler> captor = ArgumentCaptor
82+
.forClass(DownloadHandler.class);
83+
Mockito.verify(element).setAttribute(Mockito.eq("src"),
84+
captor.capture());
85+
assertEquals(DisabledUpdateMode.ALWAYS,
86+
captor.getValue().getDisabledUpdateMode());
87+
}
88+
6589
@Test
6690
void downloadHandler_isSetToInline() {
6791
Element element = Mockito.mock(Element.class);
@@ -95,8 +119,8 @@ private String captureAndInvokeDownloadHandler(Element element)
95119
handlerCaptor.capture());
96120

97121
DownloadHandler handler = handlerCaptor.getValue();
98-
assertTrue(handler instanceof InputStreamDownloadHandler,
99-
"Handler should be InputStreamDownloadHandler");
122+
assertEquals(DisabledUpdateMode.ALWAYS, handler.getDisabledUpdateMode(),
123+
"Handler set on the image must allow disabled, so the browser can still load the image when the component is disabled");
100124

101125
// Create mock event and response to capture content type
102126
VaadinRequest request = Mockito.mock(VaadinRequest.class);

flow-server/src/main/java/com/vaadin/flow/server/streams/DownloadHandler.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.io.File;
1919
import java.io.IOException;
2020

21+
import com.vaadin.flow.dom.DisabledUpdateMode;
2122
import com.vaadin.flow.dom.Element;
2223
import com.vaadin.flow.server.VaadinRequest;
2324
import com.vaadin.flow.server.VaadinResponse;
@@ -102,6 +103,59 @@ default void handleRequest(VaadinRequest request, VaadinResponse response,
102103
handleDownloadRequest(downloadEvent);
103104
}
104105

106+
/**
107+
* Returns a view of this handler that is served even when the owning
108+
* component is disabled.
109+
* <p>
110+
* By default a {@link DownloadHandler} inherits
111+
* {@link DisabledUpdateMode#ONLY_WHEN_ENABLED} from
112+
* {@link ElementRequestHandler}, which causes the framework to respond with
113+
* HTTP 403 when the owning element is disabled. That is appropriate for
114+
* action-style downloads such as a "save file" link on an
115+
* {@code com.vaadin.flow.component.html.Anchor}, but not for resources that
116+
* the browser fetches passively as part of rendering, such as the
117+
* {@code src} of an icon or image inside a disabled container.
118+
* <p>
119+
* This method returns a wrapper that delegates
120+
* {@link #handleDownloadRequest}, {@link #getUrlPostfix()} and
121+
* {@link #isAllowInert()} to this handler and overrides
122+
* {@link #getDisabledUpdateMode()} to return
123+
* {@link DisabledUpdateMode#ALWAYS}. If this handler already reports
124+
* {@code ALWAYS}, the same instance is returned.
125+
*
126+
* @return a {@link DownloadHandler} that is served regardless of the
127+
* owner's enabled state, or {@code this} if the disabled mode is
128+
* already {@link DisabledUpdateMode#ALWAYS}
129+
*/
130+
default DownloadHandler allowDisabled() {
131+
if (getDisabledUpdateMode() == DisabledUpdateMode.ALWAYS) {
132+
return this;
133+
}
134+
DownloadHandler delegate = this;
135+
return new DownloadHandler() {
136+
@Override
137+
public void handleDownloadRequest(DownloadEvent event)
138+
throws IOException {
139+
delegate.handleDownloadRequest(event);
140+
}
141+
142+
@Override
143+
public String getUrlPostfix() {
144+
return delegate.getUrlPostfix();
145+
}
146+
147+
@Override
148+
public boolean isAllowInert() {
149+
return delegate.isAllowInert();
150+
}
151+
152+
@Override
153+
public DisabledUpdateMode getDisabledUpdateMode() {
154+
return DisabledUpdateMode.ALWAYS;
155+
}
156+
};
157+
}
158+
105159
/**
106160
* Get a download handler for serving given {@link File}.
107161
* <p>
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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.server.streams;
17+
18+
import java.util.concurrent.atomic.AtomicReference;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.mockito.Mockito;
22+
23+
import com.vaadin.flow.dom.DisabledUpdateMode;
24+
import com.vaadin.flow.dom.Element;
25+
import com.vaadin.flow.server.VaadinRequest;
26+
import com.vaadin.flow.server.VaadinResponse;
27+
import com.vaadin.flow.server.VaadinSession;
28+
29+
import static org.junit.jupiter.api.Assertions.assertEquals;
30+
import static org.junit.jupiter.api.Assertions.assertSame;
31+
import static org.junit.jupiter.api.Assertions.assertTrue;
32+
33+
class DownloadHandlerTest {
34+
35+
@Test
36+
void allowDisabled_lambda_disabledUpdateModeIsAlways() {
37+
DownloadHandler delegate = event -> {
38+
};
39+
40+
DownloadHandler wrapped = delegate.allowDisabled();
41+
42+
assertEquals(DisabledUpdateMode.ALWAYS,
43+
wrapped.getDisabledUpdateMode());
44+
}
45+
46+
@Test
47+
void allowDisabled_lambda_forwardsHandleDownloadRequest() throws Exception {
48+
AtomicReference<DownloadEvent> received = new AtomicReference<>();
49+
DownloadHandler delegate = received::set;
50+
51+
DownloadEvent event = new DownloadEvent(
52+
Mockito.mock(VaadinRequest.class),
53+
Mockito.mock(VaadinResponse.class),
54+
Mockito.mock(VaadinSession.class), Mockito.mock(Element.class));
55+
56+
delegate.allowDisabled().handleDownloadRequest(event);
57+
58+
assertSame(event, received.get(),
59+
"Wrapped handler must forward the event to the delegate");
60+
}
61+
62+
@Test
63+
void allowDisabled_forwardsUrlPostfixAndAllowInert() {
64+
DownloadHandler delegate = new DownloadHandler() {
65+
@Override
66+
public void handleDownloadRequest(DownloadEvent event) {
67+
}
68+
69+
@Override
70+
public String getUrlPostfix() {
71+
return "icon.svg";
72+
}
73+
74+
@Override
75+
public boolean isAllowInert() {
76+
return true;
77+
}
78+
};
79+
80+
DownloadHandler wrapped = delegate.allowDisabled();
81+
82+
assertEquals("icon.svg", wrapped.getUrlPostfix());
83+
assertTrue(wrapped.isAllowInert());
84+
}
85+
86+
@Test
87+
void allowDisabled_abstractDownloadHandlerWrapped_modeIsAlways_inlinePreserved() {
88+
InputStreamDownloadHandler handler = DownloadHandler
89+
.fromInputStream(event -> DownloadResponse.error(500));
90+
handler.inline();
91+
92+
DownloadHandler wrapped = handler.allowDisabled();
93+
94+
assertEquals(DisabledUpdateMode.ALWAYS,
95+
wrapped.getDisabledUpdateMode());
96+
// The wrap does not interfere with the original handler's inline state
97+
assertTrue(handler.isInline());
98+
}
99+
100+
@Test
101+
void allowDisabled_isIdempotent_returnsSameInstance() {
102+
DownloadHandler alwaysAllowed = new DownloadHandler() {
103+
@Override
104+
public void handleDownloadRequest(DownloadEvent event) {
105+
}
106+
107+
@Override
108+
public DisabledUpdateMode getDisabledUpdateMode() {
109+
return DisabledUpdateMode.ALWAYS;
110+
}
111+
};
112+
113+
assertSame(alwaysAllowed, alwaysAllowed.allowDisabled(),
114+
"allowDisabled() on a handler that is already ALWAYS must be a no-op");
115+
}
116+
117+
@Test
118+
void allowDisabled_doubleWrap_returnsFirstWrapper() {
119+
DownloadHandler delegate = event -> {
120+
};
121+
DownloadHandler wrappedOnce = delegate.allowDisabled();
122+
DownloadHandler wrappedTwice = wrappedOnce.allowDisabled();
123+
124+
assertSame(wrappedOnce, wrappedTwice,
125+
"Wrapping a handler that is already ALWAYS must be a no-op");
126+
}
127+
}

0 commit comments

Comments
 (0)