Skip to content

Commit d15523b

Browse files
authored
fix: load Image/IFrame sources when disabled (#24346) (CP: 25.1) (#24363)
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 4a2c5f4 commit d15523b

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)