diff --git a/.gitignore b/.gitignore
index 5bc4cc6abfdd..febbaa89d3c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
.DS_Store
.classpath
.factorypath
+.attach_pid*
.gradle
.idea
.metadata
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mustache/MustacheReactiveWebConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mustache/MustacheReactiveWebConfiguration.java
index f2c3f98a54ea..26956e4a0048 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mustache/MustacheReactiveWebConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/mustache/MustacheReactiveWebConfiguration.java
@@ -38,6 +38,7 @@ public MustacheViewResolver mustacheViewResolver(Compiler mustacheCompiler,
resolver.setPrefix(mustache.getPrefix());
resolver.setSuffix(mustache.getSuffix());
resolver.setViewNames(mustache.getViewNames());
+ resolver.setCache(mustache.isCache());
resolver.setRequestContextAttribute(mustache.getRequestContextAttribute());
resolver.setCharset(mustache.getCharsetName());
resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mustache/MustacheAutoConfigurationReactiveIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mustache/MustacheAutoConfigurationReactiveIntegrationTests.java
index 431387ad5880..f469cca47725 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mustache/MustacheAutoConfigurationReactiveIntegrationTests.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/mustache/MustacheAutoConfigurationReactiveIntegrationTests.java
@@ -16,10 +16,13 @@
package org.springframework.boot.autoconfigure.mustache;
+import java.time.Duration;
import java.util.Date;
import com.samskivert.mustache.Mustache;
+import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
@@ -35,6 +38,7 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.ui.Model;
@@ -46,7 +50,7 @@
* Integration Tests for {@link MustacheAutoConfiguration}, {@link MustacheViewResolver}
* and {@link MustacheView}.
*
- * @author Brian Clozel
+ * @author Brian Clozel, Dave Syer
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
properties = "spring.main.web-application-type=reactive")
@@ -69,6 +73,14 @@ public void testPartialPage() {
assertThat(result).contains("Hello App").contains("Hello World");
}
+ @Test
+ public void testSse() {
+ this.client.get().uri("/sse").exchange() //
+ .expectBody(String.class).value(Matchers.containsString("event: message"))
+ .value(Matchers.containsString("\ndata: Hello "))
+ .value(Matchers.containsString("World")).value(Matchers.endsWith("\n\n"));
+ }
+
@Configuration(proxyBeanMethods = false)
@Import({ ReactiveWebServerFactoryAutoConfiguration.class,
WebFluxAutoConfiguration.class, HttpHandlerAutoConfiguration.class,
@@ -92,6 +104,16 @@ public String layout(Model model) {
return "partial";
}
+ @RequestMapping(path = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public String sse(Model model) {
+ model.addAttribute("time", new Date());
+ model.addAttribute("async:message",
+ Flux.just("Hello ", "World ")
+ .delayElements(Duration.ofMillis(10)));
+ model.addAttribute("title", "Hello App");
+ return "sse";
+ }
+
@Bean
public MustacheViewResolver viewResolver() {
Mustache.Compiler compiler = Mustache.compiler().withLoader(
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/mustache-templates/sse.html b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/mustache-templates/sse.html
new file mode 100644
index 000000000000..da10c1581366
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/mustache-templates/sse.html
@@ -0,0 +1,7 @@
+{{#async:message}}
+event: message
+{{#sse:data}}
+
Title
+{{{.}}}
+{{/sse:data}}
+{{/async:message}}
diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc
index 15550b8e3c59..e722309985a6 100644
--- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc
+++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc
@@ -3032,6 +3032,54 @@ Spring Boot includes auto-configuration support for the following templating eng
When you use one of these templating engines with the default configuration, your
templates are picked up automatically from `src/main/resources/templates`.
+==== Mustache Views
+
+There are some special features of the `MustacheView` that make it suitable for handling the rendering of reactive elements. Most browsers will start to show content before the HTML tags are closed, so you can drip feed a list or a table into the view as the content becomes available.
+
+===== Progressive Rendering
+
+A model element of type `Publisher` will be left in the model (instead of expanding it before the view is rendered), if its name starts with "async:". The `View` is then rendered and flushed to the HTTP response as soon as each element is published. Browsers are really good at rendering partially complete HTML, so the flux elements will most likely be visible to the user as soon as they are available. This is useful for rendering the "main" content of a page if it is a list or a table, for instance.
+
+===== Sserver Sent Event (SSE) Support
+
+To render a `View` with content type `text/event-stream` you need a model element of type `Publisher`, and also a template that includes that element (probably starts and ends with it). There is a convenience Lambda (`ssedata`) added to the model for you that prepends every line with `data:` - you can use it if you wish to simplify the rendering of the data elements. Two new lines are added after each item in `{{#sse:data}}`. E.g. with an element called `async:events` of type `Flux`:
+
+```
+{{#async:events}}
+event: message
+id: {{id}}
+{{#sse:data}}
+
+ Name: {{name}}
+ Value: {{value}}
+
+{{/sse:data}}
+{{/async:events}}
+```
+
+the output will be
+
+```
+event: message
+id: 0
+data:
+data: Name: foo
+data: Value: bar
+data:
+
+
+event: message
+id: 1
+data:
+data: Name: spam
+data: Value: bucket
+data:
+
+
+... etc.
+```
+
+assuming the `Event` object has fields `id`, `name`, `value`.
[[boot-features-webflux-error-handling]]
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/FluxWriter.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/FluxWriter.java
new file mode 100644
index 000000000000..dac477b1a558
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/FluxWriter.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2019-2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.web.reactive.result.view;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.reactivestreams.Publisher;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferFactory;
+
+/**
+ * A {@link Writer} that can write a {@link Flux} (or {@link Publisher}) to a data buffer.
+ * Used to render progressive output in a {@link MustacheView}.
+ *
+ * @author Dave Syer
+ */
+class FluxWriter extends Writer {
+
+ private final DataBufferFactory factory;
+
+ private final Charset charset;
+
+ private List accumulated = new ArrayList<>();
+
+ FluxWriter(DataBufferFactory factory, Charset charset) {
+ this.factory = factory;
+ this.charset = charset;
+ }
+
+ @SuppressWarnings("unchecked")
+ public Flux extends Publisher extends DataBuffer>> getBuffers() {
+ Flux buffers = Flux.empty();
+ List chunks = new ArrayList<>();
+ for (Object thing : this.accumulated) {
+ if (thing instanceof Publisher) {
+ buffers = concatValues(chunks, buffers);
+ buffers = buffers.concatWith((Publisher) thing);
+ }
+ else {
+ chunks.add((String) thing);
+ }
+ }
+ buffers = concatValues(chunks, buffers);
+ return buffers.map((string) -> Mono.fromCallable(
+ () -> this.factory.allocateBuffer().write(string, this.charset)));
+ }
+
+ private Flux concatValues(List chunks, Flux buffers) {
+ if (!chunks.isEmpty()) {
+ buffers = buffers.concatWithValues(chunks.toArray(new String[0]));
+ chunks.clear();
+ }
+ return buffers;
+ }
+
+ @Override
+ public void write(char[] cbuf, int off, int len) throws IOException {
+ this.accumulated.add(new String(cbuf, off, len));
+ }
+
+ @Override
+ public void flush() throws IOException {
+ }
+
+ @Override
+ public void close() throws IOException {
+ }
+
+ public void release() {
+ // TODO: maybe implement this and call it on error
+ }
+
+ public void write(Object thing) {
+ this.accumulated.add(thing);
+ }
+
+}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheView.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheView.java
index 5a6e4bedcf5d..9f1f93fdad29 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheView.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheView.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2012-2018 the original author or authors.
+ * Copyright 2012-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,25 +16,31 @@
package org.springframework.boot.web.reactive.result.view;
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
-import java.io.OutputStreamWriter;
import java.io.Reader;
+import java.io.StringWriter;
import java.io.Writer;
import java.nio.charset.Charset;
+import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
+import com.samskivert.mustache.Mustache;
import com.samskivert.mustache.Mustache.Compiler;
import com.samskivert.mustache.Template;
+import com.samskivert.mustache.Template.Fragment;
+import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
import org.springframework.core.io.Resource;
-import org.springframework.core.io.buffer.DataBuffer;
-import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.reactive.result.view.AbstractUrlBasedView;
import org.springframework.web.reactive.result.view.View;
import org.springframework.web.server.ServerWebExchange;
@@ -42,7 +48,7 @@
/**
* Spring WebFlux {@link View} using the Mustache template engine.
*
- * @author Brian Clozel
+ * @author Brian Clozel, Dave Syer
* @since 2.0.0
*/
public class MustacheView extends AbstractUrlBasedView {
@@ -51,6 +57,18 @@ public class MustacheView extends AbstractUrlBasedView {
private String charset;
+ private static Map templates = new HashMap<>();
+
+ private boolean cache = true;
+
+ /**
+ * Flag to indiciate that templates ought to be cached.
+ * @param cache the flag value
+ */
+ public void setCache(boolean cache) {
+ this.cache = cache;
+ }
+
/**
* Set the JMustache compiler to be used by this view. Typically this property is not
* set directly. Instead a single {@link Compiler} is expected in the Spring
@@ -82,21 +100,66 @@ protected Mono renderInternal(Map model, MediaType content
return Mono.error(new IllegalStateException(
"Could not find Mustache template with URL [" + getUrl() + "]"));
}
- DataBuffer dataBuffer = exchange.getResponse().bufferFactory().allocateBuffer();
- try (Reader reader = getReader(resource)) {
- Template template = this.compiler.compile(reader);
- Charset charset = getCharset(contentType).orElse(getDefaultCharset());
- try (Writer writer = new OutputStreamWriter(dataBuffer.asOutputStream(),
- charset)) {
- template.execute(model, writer);
- writer.flush();
+ boolean sse = MediaType.TEXT_EVENT_STREAM.isCompatibleWith(contentType);
+ Charset charset = getCharset(contentType).orElse(getDefaultCharset());
+ ServerHttpResponse response = exchange.getResponse();
+ FluxWriter writer = new FluxWriter(response.bufferFactory(), charset);
+ Mono rendered;
+ if (!this.cache || !templates.containsKey(resource)) {
+ rendered = Mono.fromCallable(() -> compile(resource))
+ .subscribeOn(Schedulers.elastic());
+ }
+ else {
+ rendered = Mono.just(templates.get(resource));
+ }
+ Map map;
+ if (sse) {
+ map = new HashMap<>(model);
+ map.put("sse:data", new SseLambda());
+ }
+ else {
+ map = model;
+ }
+ return rendered.flatMap((template) -> {
+ template.execute(map, writer);
+ return response.writeAndFlushWith(writer.getBuffers());
+ }).doOnTerminate(() -> close(writer));
+ }
+
+ private void close(FluxWriter writer) {
+ try {
+ writer.close();
+ }
+ catch (IOException ex) {
+ writer.release();
+ }
+ }
+
+ private Template compile(Resource resource) {
+ try {
+ try (Reader reader = getReader(resource)) {
+ Template template = this.compiler.compile(reader);
+ return template;
}
}
- catch (Exception ex) {
- DataBufferUtils.release(dataBuffer);
- return Mono.error(ex);
+ catch (IOException ex) {
+ throw new IllegalStateException("Cannot close reader");
+ }
+ }
+
+ @Override
+ protected Mono resolveAsyncAttributes(Map model) {
+ Map result = new HashMap<>();
+ for (String key : model.keySet()) {
+ if (!key.startsWith("async:")) {
+ result.put(key, model.get(key));
+ }
+ else {
+ model.put(key, new FluxLambda((Publisher>) model.get(key)));
+ }
}
- return exchange.getResponse().writeWith(Flux.just(dataBuffer));
+ return super.resolveAsyncAttributes(result)
+ .doOnSuccess((v) -> model.putAll(result));
}
private Resource resolveResource() {
@@ -108,14 +171,69 @@ private Resource resolveResource() {
}
private Reader getReader(Resource resource) throws IOException {
+ Reader result;
if (this.charset != null) {
- return new InputStreamReader(resource.getInputStream(), this.charset);
+ result = new InputStreamReader(resource.getInputStream(), this.charset);
+ }
+ else {
+ result = new InputStreamReader(resource.getInputStream());
}
- return new InputStreamReader(resource.getInputStream());
+ return result;
}
private Optional getCharset(MediaType mediaType) {
return Optional.ofNullable((mediaType != null) ? mediaType.getCharset() : null);
}
+ private static class FluxLambda implements Mustache.Lambda {
+
+ private Publisher> publisher;
+
+ FluxLambda(Publisher> publisher) {
+ this.publisher = publisher;
+ }
+
+ @Override
+ public void execute(Fragment frag, Writer out) throws IOException {
+ try {
+ if (out instanceof FluxWriter) {
+ FluxWriter fluxWriter = (FluxWriter) out;
+ fluxWriter.flush();
+ fluxWriter.write(Flux.from(this.publisher).map(frag::execute));
+ }
+ }
+ catch (IOException ex) {
+ ex.printStackTrace();
+ }
+ }
+
+ }
+
+ private static class SseLambda implements Mustache.Lambda {
+
+ @Override
+ public void execute(Fragment frag, Writer out) throws IOException {
+ try {
+ StringWriter writer = new StringWriter();
+ frag.execute(writer);
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(
+ new ByteArrayInputStream(writer.toString().getBytes())))) {
+ reader.lines().forEach((line) -> {
+ try {
+ out.write("data: " + line + "\n");
+ }
+ catch (IOException ex) {
+ throw new IllegalStateException("Cannot write data", ex);
+ }
+ });
+ }
+ out.write(new char[] { '\n', '\n' });
+ }
+ catch (IOException ex) {
+ throw new IllegalStateException("Cannot render SSE data", ex);
+ }
+ }
+
+ }
+
}
diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheViewResolver.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheViewResolver.java
index f66fb79b18c0..8cc800580657 100644
--- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheViewResolver.java
+++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/reactive/result/view/MustacheViewResolver.java
@@ -16,9 +16,12 @@
package org.springframework.boot.web.reactive.result.view;
+import java.util.Arrays;
+
import com.samskivert.mustache.Mustache;
import com.samskivert.mustache.Mustache.Compiler;
+import org.springframework.http.MediaType;
import org.springframework.web.reactive.result.view.AbstractUrlBasedView;
import org.springframework.web.reactive.result.view.UrlBasedViewResolver;
import org.springframework.web.reactive.result.view.ViewResolver;
@@ -26,7 +29,7 @@
/**
* Spring WebFlux {@link ViewResolver} for Mustache.
*
- * @author Brian Clozel
+ * @author Brian Clozel, Dave Syer
* @since 2.0.0
*/
public class MustacheViewResolver extends UrlBasedViewResolver {
@@ -35,6 +38,13 @@ public class MustacheViewResolver extends UrlBasedViewResolver {
private String charset;
+ private boolean cache;
+
+ {
+ setSupportedMediaTypes(
+ Arrays.asList(MediaType.TEXT_HTML, MediaType.TEXT_EVENT_STREAM));
+ }
+
/**
* Create a {@code MustacheViewResolver} backed by a default instance of a
* {@link Compiler}.
@@ -62,6 +72,14 @@ public void setCharset(String charset) {
this.charset = charset;
}
+ /**
+ * Flag to indicate that the view template should be cached. Default false.
+ * @param cache the flag value.
+ */
+ public void setCache(boolean cache) {
+ this.cache = cache;
+ }
+
@Override
protected Class> requiredViewClass() {
return MustacheView.class;
@@ -72,6 +90,7 @@ protected AbstractUrlBasedView createView(String viewName) {
MustacheView view = (MustacheView) super.createView(viewName);
view.setCompiler(this.compiler);
view.setCharset(this.charset);
+ view.setCache(this.cache);
return view;
}
diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/result/view/MustacheViewTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/result/view/MustacheViewTests.java
index 2de80b931b40..b8ece1ba23db 100644
--- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/result/view/MustacheViewTests.java
+++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/result/view/MustacheViewTests.java
@@ -23,6 +23,7 @@
import com.samskivert.mustache.Mustache;
import org.junit.Before;
import org.junit.Test;
+import reactor.core.publisher.Flux;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.http.MediaType;
@@ -34,12 +35,12 @@
/**
* Tests for {@link MustacheView}.
*
- * @author Brian Clozel
+ * @author Brian Clozel, Dave Syer
*/
public class MustacheViewTests {
private final String templateUrl = "classpath:/"
- + getClass().getPackage().getName().replace(".", "/") + "/template.html";
+ + getClass().getPackage().getName().replace(".", "/");
private GenericApplicationContext context = new GenericApplicationContext();
@@ -56,7 +57,7 @@ public void viewResolvesHandlebars() {
.from(MockServerHttpRequest.get("/test").build());
MustacheView view = new MustacheView();
view.setCompiler(Mustache.compiler());
- view.setUrl(this.templateUrl);
+ view.setUrl(this.templateUrl + "/template.html");
view.setCharset(StandardCharsets.UTF_8.displayName());
view.setApplicationContext(this.context);
view.render(Collections.singletonMap("World", "Spring"), MediaType.TEXT_HTML,
@@ -65,4 +66,52 @@ public void viewResolvesHandlebars() {
.block(Duration.ofSeconds(30))).isEqualTo("Hello Spring");
}
+ @Test
+ public void viewResolvesPublisher() {
+ this.exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.get("/test").build());
+ MustacheView view = new MustacheView();
+ view.setCompiler(Mustache.compiler());
+ view.setUrl(this.templateUrl + "/flux.html");
+ view.setCharset(StandardCharsets.UTF_8.displayName());
+ view.setApplicationContext(this.context);
+ view.render(Collections.singletonMap("async:value", Flux.just("World", "Spring")),
+ MediaType.TEXT_HTML, this.exchange).block(Duration.ofSeconds(30));
+ assertThat(this.exchange.getResponse().getBodyAsString()
+ .block(Duration.ofSeconds(30))).isEqualTo("Hello\nWorld\nSpring\n");
+ }
+
+ @Test
+ public void viewResolvesSseManual() {
+ this.exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.get("/test").build());
+ MustacheView view = new MustacheView();
+ view.setCompiler(Mustache.compiler());
+ view.setUrl(this.templateUrl + "/sse.html");
+ view.setCharset(StandardCharsets.UTF_8.displayName());
+ view.setApplicationContext(this.context);
+ view.render(Collections.singletonMap("async:value", Flux.just("World", "Spring")),
+ MediaType.TEXT_EVENT_STREAM, this.exchange).block(Duration.ofSeconds(30));
+ assertThat(this.exchange.getResponse().getBodyAsString()
+ .block(Duration.ofSeconds(30))).isEqualTo(
+ "event: message\ndata: World\n\n\nevent: message\ndata: Spring\n\n\n");
+ }
+
+ @Test
+ public void viewResolvesSseData() {
+ this.exchange = MockServerWebExchange
+ .from(MockServerHttpRequest.get("/test").build());
+ MustacheView view = new MustacheView();
+ view.setCompiler(Mustache.compiler());
+ view.setUrl(this.templateUrl + "/ssedata.html");
+ view.setCharset(StandardCharsets.UTF_8.displayName());
+ view.setApplicationContext(this.context);
+ view.render(Collections.singletonMap("async:value", Flux.just("World", "Spring")),
+ MediaType.TEXT_EVENT_STREAM, this.exchange)
+ .block(Duration.ofSeconds(300));
+ assertThat(this.exchange.getResponse().getBodyAsString()
+ .block(Duration.ofSeconds(30))).isEqualTo(
+ "event: message\ndata: World\n\n\nevent: message\ndata: Spring\n\n\n");
+ }
+
}
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/flux.html b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/flux.html
new file mode 100644
index 000000000000..fe399fddb9e9
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/flux.html
@@ -0,0 +1,4 @@
+Hello
+{{#async:value}}
+{{.}}
+{{/async:value}}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/sse.html b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/sse.html
new file mode 100644
index 000000000000..fe5765eabd5b
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/sse.html
@@ -0,0 +1,6 @@
+{{#async:value}}
+event: message
+data: {{.}}
+
+
+{{/async:value}}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/ssedata.html b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/ssedata.html
new file mode 100644
index 000000000000..58038b18260d
--- /dev/null
+++ b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/web/reactive/result/view/ssedata.html
@@ -0,0 +1,6 @@
+{{#async:value}}
+event: message
+{{#sse:data}}
+{{.}}
+{{/sse:data}}
+{{/async:value}}
\ No newline at end of file