From d468fd59cae33dc36ca77c6a45336d7e9bdb8b9a Mon Sep 17 00:00:00 2001 From: William Malinowski Date: Sat, 3 Oct 2026 21:30:16 -0400 Subject: [PATCH 1/5] fix: bind a servlet free web request when rendering outside a request (#60) RenderEnvironment only bound a request in the TEST environment and relied on spring-test mocks, so rendering from jobs/async tasks in production NPE'd in taglibs. Use PageRenderer's request/response proxies in every environment. --- .../document/RenderEnvironmentSpec.groovy | 122 ++++++++++++++++++ .../document/RenderEnvironment.groovy | 63 ++++----- 2 files changed, 155 insertions(+), 30 deletions(-) create mode 100644 example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy new file mode 100644 index 0000000..abf48d5 --- /dev/null +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy @@ -0,0 +1,122 @@ +package grails.plugins.rendering.document + +import grails.core.GrailsApplication +import grails.testing.mixin.integration.Integration +import grails.util.Environment +import grails.util.GrailsWebMockUtil +import org.grails.web.servlet.WrappedResponseHolder +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.servlet.support.RequestContextUtils +import spock.lang.Specification + +@Integration +class RenderEnvironmentSpec extends Specification { + + GrailsApplication grailsApplication + + private String previousEnv + + def setup() { + previousEnv = System.getProperty(Environment.KEY) + System.setProperty(Environment.KEY, Environment.PRODUCTION.name) + RequestContextHolder.resetRequestAttributes() + WrappedResponseHolder.wrappedResponse = null + } + + def cleanup() { + if (previousEnv == null) { + System.clearProperty(Environment.KEY) + } else { + System.setProperty(Environment.KEY, previousEnv) + } + RequestContextHolder.resetRequestAttributes() + WrappedResponseHolder.wrappedResponse = null + } + + def "bind a web request when one does not exist"() { + given: + def out = new StringWriter() + GrailsWebRequest bound = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, out) { + bound = GrailsWebRequest.lookup() + } + + then: + Environment.current == Environment.PRODUCTION + bound != null + bound.out.is(out) + } + + def "leave no request bound afterwards"() { + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) {} + + then: + RequestContextHolder.requestAttributes == null + WrappedResponseHolder.wrappedResponse == null + } + + def "restore the callers request"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + original.controllerName = 'book' + def layoutBufferingResponse = new MockHttpServletResponse() + WrappedResponseHolder.wrappedResponse = layoutBufferingResponse + GrailsWebRequest bound = null + String controllerName = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) { RenderEnvironment env -> + bound = GrailsWebRequest.lookup() + controllerName = env.controllerName + } + + then: + !bound.is(original) + controllerName == 'book' + RequestContextHolder.requestAttributes.is(original) + WrappedResponseHolder.wrappedResponse.is(layoutBufferingResponse) + } + + def "restore state when the block throws"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) { + throw new IllegalStateException('boom') + } + + then: + thrown(IllegalStateException) + RequestContextHolder.requestAttributes.is(original) + } + + def "render locale is #expected when explicit=#explicit and request=#requestLocale"() { + given: + if (requestLocale) { + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + (original.currentRequest as MockHttpServletRequest).addPreferredLocale(requestLocale) + } + Locale seen = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter(), explicit) { + seen = RequestContextUtils.getLocale(GrailsWebRequest.lookup().currentRequest) + } + + then: + seen == expected + + where: + explicit | requestLocale | expected + Locale.FRENCH | Locale.GERMAN | Locale.FRENCH + null | Locale.GERMAN | Locale.GERMAN + null | null | Locale.default + } +} diff --git a/src/main/groovy/grails/plugins/rendering/document/RenderEnvironment.groovy b/src/main/groovy/grails/plugins/rendering/document/RenderEnvironment.groovy index 63f5efb..07d3243 100644 --- a/src/main/groovy/grails/plugins/rendering/document/RenderEnvironment.groovy +++ b/src/main/groovy/grails/plugins/rendering/document/RenderEnvironment.groovy @@ -1,24 +1,29 @@ package grails.plugins.rendering.document -import grails.util.Environment -import grails.util.GrailsWebMockUtil +import grails.gsp.PageRenderer +import groovy.transform.CompileStatic +import jakarta.servlet.ServletContext +import jakarta.servlet.http.HttpServletResponse import org.grails.web.servlet.WrappedResponseHolder +import org.grails.web.servlet.mvc.GrailsWebRequest import org.springframework.context.ApplicationContext +import org.springframework.web.context.WebApplicationContext +import org.springframework.web.context.request.RequestAttributes import org.springframework.web.context.request.RequestContextHolder import org.springframework.web.servlet.DispatcherServlet import org.springframework.web.servlet.i18n.FixedLocaleResolver import org.springframework.web.servlet.support.RequestContextUtils +@CompileStatic class RenderEnvironment { final Writer out final Locale locale final ApplicationContext applicationContext - private originalRequestAttributes - private renderRequestAttributes - - private originalOut + private RequestAttributes originalRequestAttributes + private GrailsWebRequest renderRequestAttributes + private HttpServletResponse originalWrappedResponse RenderEnvironment(ApplicationContext applicationContext, Writer out, Locale locale = null) { this.out = out @@ -26,37 +31,35 @@ class RenderEnvironment { this.applicationContext = applicationContext } - private init() { - if (Environment.current == Environment.TEST) { - originalRequestAttributes = RequestContextHolder.getRequestAttributes() - renderRequestAttributes = GrailsWebMockUtil.bindMockWebRequest(applicationContext) - - if (originalRequestAttributes) { - renderRequestAttributes.controllerName = originalRequestAttributes.controllerName - } + private void init() { + originalRequestAttributes = RequestContextHolder.getRequestAttributes() + originalWrappedResponse = WrappedResponseHolder.wrappedResponse + GrailsWebRequest originalWebRequest = originalRequestAttributes instanceof GrailsWebRequest ? + (GrailsWebRequest) originalRequestAttributes : null - def renderLocale - if (locale) { - renderLocale = locale - } else if (originalRequestAttributes) { - renderLocale = RequestContextUtils.getLocale(originalRequestAttributes.request) - } + Locale renderLocale = locale ?: (originalWebRequest ? RequestContextUtils.getLocale(originalWebRequest.currentRequest) : Locale.default) - renderRequestAttributes.request.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, - new FixedLocaleResolver(defaultLocale: renderLocale)) + def request = PageRenderer.PageRenderRequestCreator.createInstance('/', renderLocale) + request.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, new FixedLocaleResolver(renderLocale)) + def response = PageRenderer.PageRenderResponseCreator.createInstance( + out instanceof PrintWriter ? (PrintWriter) out : new PrintWriter(out), renderLocale) - renderRequestAttributes.setOut(out) - WrappedResponseHolder.wrappedResponse = renderRequestAttributes.currentResponse + renderRequestAttributes = new GrailsWebRequest(request, response, servletContext, applicationContext) + renderRequestAttributes.controllerName = originalWebRequest?.controllerName + renderRequestAttributes.out = out - } + RequestContextHolder.setRequestAttributes(renderRequestAttributes) + WrappedResponseHolder.wrappedResponse = response + } + private void close() { + RequestContextHolder.setRequestAttributes(originalRequestAttributes) + WrappedResponseHolder.wrappedResponse = originalWrappedResponse } - private close() { - if (originalRequestAttributes) { - RequestContextHolder.setRequestAttributes(originalRequestAttributes) // null ok - WrappedResponseHolder.wrappedResponse = originalRequestAttributes?.currentResponse - } + private ServletContext getServletContext() { + applicationContext instanceof WebApplicationContext ? + ((WebApplicationContext) applicationContext).servletContext : null } /** From 16fa4f1cf7a0a350dbc961e88ee4be474e1d29cc Mon Sep 17 00:00:00 2001 From: William Malinowski Date: Sat, 3 Oct 2026 21:34:12 -0400 Subject: [PATCH 2/5] test: cover taglib rendering outside and inside web requests (#60) --- example/grails-app/views/_taglib.gsp | 5 ++ .../document/XhtmlDocumentServiceSpec.groovy | 78 +++++++++++++++++-- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 example/grails-app/views/_taglib.gsp diff --git a/example/grails-app/views/_taglib.gsp b/example/grails-app/views/_taglib.gsp new file mode 100644 index 0000000..f28ef90 --- /dev/null +++ b/example/grails-app/views/_taglib.gsp @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy index b31b8c7..8a85f28 100644 --- a/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy @@ -15,18 +15,84 @@ */ package grails.plugins.rendering.document +import grails.core.GrailsApplication import grails.testing.mixin.integration.Integration +import grails.util.Environment +import grails.util.GrailsWebMockUtil +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.springframework.web.context.request.RequestContextHolder +import org.w3c.dom.Document import spock.lang.Specification -/** - * The xhtmlDocumentService is exercised in the rendering - * service tests. This is here for testing future functionality. - */ @Integration class XhtmlDocumentServiceSpec extends Specification { - def xhtmlDocumentService + XhtmlDocumentService xhtmlDocumentService + GrailsApplication grailsApplication + + private String previousEnv + + def setup() { + previousEnv = System.getProperty(Environment.KEY) + System.setProperty(Environment.KEY, Environment.PRODUCTION.name) + RequestContextHolder.resetRequestAttributes() + } + + def cleanup() { + if (previousEnv == null) { + System.clearProperty(Environment.KEY) + } else { + System.setProperty(Environment.KEY, previousEnv) + } + RequestContextHolder.resetRequestAttributes() + } + + def "render a taglib template outside web request"() { + when: + Document document = xhtmlDocumentService.createDocument(template: '/taglib', model: [value: 'from a job']) + + then: + hiddenFieldValue(document) == 'from a job' + RequestContextHolder.requestAttributes == null + } + + def "render taglib template on a background thread"() { + given: + Document document = null + Throwable error = null + + when: + def thread = Thread.start { + try { + document = xhtmlDocumentService.createDocument(template: '/taglib', model: [value: 'from a thread']) + } catch (Throwable t) { + error = t + } + } + thread.join(30_000) + + then: + error == null + hiddenFieldValue(document) == 'from a thread' + } + + def "does not write into current request's output"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + def requestOut = new StringWriter() + original.out = requestOut + + when: + Document document = xhtmlDocumentService.createDocument(template: '/taglib', model: [value: 'in a request']) + + then: + hiddenFieldValue(document) == 'in a request' + original.out.is(requestOut) + requestOut.toString() == '' + RequestContextHolder.requestAttributes.is(original) + } - def emptyScenario() { + private static String hiddenFieldValue(Document document) { + document.getElementsByTagName('input').item(0).getAttribute('value') } } From 1c59c4d6049ffea22888ee95c636b517af97b1b3 Mon Sep 17 00:00:00 2001 From: William Malinowski Date: Sat, 3 Oct 2026 22:58:03 -0400 Subject: [PATCH 3/5] fix: keep the caller's request when rendering inside web request (#60) --- example/grails-app/views/_requestState.gsp | 8 ++++ example/grails-app/views/_taglib.gsp | 2 +- .../document/RenderEnvironmentSpec.groovy | 37 ++++++++++++++-- .../document/XhtmlDocumentServiceSpec.groovy | 24 +++++++++++ .../document/RenderEnvironment.groovy | 43 +++++++++++++------ 5 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 example/grails-app/views/_requestState.gsp diff --git a/example/grails-app/views/_requestState.gsp b/example/grails-app/views/_requestState.gsp new file mode 100644 index 0000000..7f49023 --- /dev/null +++ b/example/grails-app/views/_requestState.gsp @@ -0,0 +1,8 @@ + + +

${request.getAttribute('foo')}

+

${params.q}

+

${params.id}

+

${session.getAttribute('user')}

+ + diff --git a/example/grails-app/views/_taglib.gsp b/example/grails-app/views/_taglib.gsp index f28ef90..054f4b1 100644 --- a/example/grails-app/views/_taglib.gsp +++ b/example/grails-app/views/_taglib.gsp @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy index abf48d5..56ffd4b 100644 --- a/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy @@ -9,6 +9,8 @@ import org.grails.web.servlet.mvc.GrailsWebRequest import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.servlet.DispatcherServlet +import org.springframework.web.servlet.i18n.FixedLocaleResolver import org.springframework.web.servlet.support.RequestContextUtils import spock.lang.Specification @@ -61,31 +63,43 @@ class RenderEnvironmentSpec extends Specification { WrappedResponseHolder.wrappedResponse == null } - def "restore the callers request"() { + def "reuse the callers request and restore it afterwards"() { given: GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) original.controllerName = 'book' + def callerOut = new StringWriter() + original.out = callerOut + def callerLocaleResolver = new FixedLocaleResolver(Locale.ITALIAN) + original.currentRequest.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, callerLocaleResolver) def layoutBufferingResponse = new MockHttpServletResponse() WrappedResponseHolder.wrappedResponse = layoutBufferingResponse + def renderOut = new StringWriter() GrailsWebRequest bound = null + Writer boundOut = null String controllerName = null when: - RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) { RenderEnvironment env -> + RenderEnvironment.with(grailsApplication.mainContext, renderOut) { RenderEnvironment env -> bound = GrailsWebRequest.lookup() + boundOut = bound.out controllerName = env.controllerName } then: - !bound.is(original) + bound.is(original) + boundOut.is(renderOut) controllerName == 'book' RequestContextHolder.requestAttributes.is(original) + original.out.is(callerOut) + original.currentRequest.getAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE).is(callerLocaleResolver) WrappedResponseHolder.wrappedResponse.is(layoutBufferingResponse) } def "restore state when the block throws"() { given: + def callerOut = new StringWriter() GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + original.out = callerOut when: RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) { @@ -95,6 +109,23 @@ class RenderEnvironmentSpec extends Specification { then: thrown(IllegalStateException) RequestContextHolder.requestAttributes.is(original) + original.out.is(callerOut) + } + + def "restore the outer renders writer after a nested render"() { + given: + def outerOut = new StringWriter() + Writer afterInner = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, outerOut) { + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) {} + afterInner = GrailsWebRequest.lookup().out + } + + then: + afterInner.is(outerOut) + RequestContextHolder.requestAttributes == null } def "render locale is #expected when explicit=#explicit and request=#requestLocale"() { diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy index 8a85f28..99bb180 100644 --- a/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy @@ -20,6 +20,7 @@ import grails.testing.mixin.integration.Integration import grails.util.Environment import grails.util.GrailsWebMockUtil import org.grails.web.servlet.mvc.GrailsWebRequest +import org.springframework.mock.web.MockHttpServletRequest import org.springframework.web.context.request.RequestContextHolder import org.w3c.dom.Document import spock.lang.Specification @@ -92,7 +93,30 @@ class XhtmlDocumentServiceSpec extends Specification { RequestContextHolder.requestAttributes.is(original) } + def "renders template that reads caller's request state"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + MockHttpServletRequest request = original.currentRequest as MockHttpServletRequest + request.setAttribute('foo', 'bar') + request.addParameter('q', 'query') + request.session.setAttribute('user', 'alice') + original.params.id = '42' + + when: + Document document = xhtmlDocumentService.createDocument(template: '/requestState') + + then: + paragraphs(document) == ['bar', 'query', '42', 'alice'] + original.currentResponse.contentType == null + RequestContextHolder.requestAttributes.is(original) + } + private static String hiddenFieldValue(Document document) { document.getElementsByTagName('input').item(0).getAttribute('value') } + + private static List paragraphs(Document document) { + def nodes = document.getElementsByTagName('p') + (0.. Date: Sat, 3 Oct 2026 23:17:59 -0400 Subject: [PATCH 4/5] fix: give the off request stub the real context path, caller locale and request completion (#60) --- .../document/RenderEnvironmentSpec.groovy | 88 +++++++++++++++++++ .../document/RenderEnvironment.groovy | 21 ++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy index 56ffd4b..581f03b 100644 --- a/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy @@ -6,9 +6,12 @@ import grails.util.Environment import grails.util.GrailsWebMockUtil import org.grails.web.servlet.WrappedResponseHolder import org.grails.web.servlet.mvc.GrailsWebRequest +import org.springframework.context.support.StaticApplicationContext import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.web.context.request.RequestAttributes import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes import org.springframework.web.servlet.DispatcherServlet import org.springframework.web.servlet.i18n.FixedLocaleResolver import org.springframework.web.servlet.support.RequestContextUtils @@ -150,4 +153,89 @@ class RenderEnvironmentSpec extends Specification { null | Locale.GERMAN | Locale.GERMAN null | null | Locale.default } + + def "expose the application context path outside of a web request"() { + given: + String contextPath = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter(), { + contextPath = GrailsWebRequest.lookup().contextPath + }) + + then: + contextPath == '/rendering' + } + + def "inherit the locale of a plain servlet request"() { + given: + def request = new MockHttpServletRequest() + request.addPreferredLocale(Locale.JAPANESE) + def plain = new ServletRequestAttributes(request) + RequestContextHolder.requestAttributes = plain + Locale seen = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter(), { + seen = RequestContextUtils.getLocale(GrailsWebRequest.lookup().currentRequest) + }) + + then: + seen == Locale.JAPANESE + RequestContextHolder.requestAttributes.is(plain) + } + + def "run request destruction callbacks registered during the render"() { + given: + boolean destroyed = false + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter(), { + GrailsWebRequest.lookup().registerDestructionCallback('probe', { destroyed = true } as Runnable, + RequestAttributes.SCOPE_REQUEST) + }) + + then: + destroyed + } + + def "leave the caller's request destruction callbacks alone"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + boolean destroyed = false + original.registerDestructionCallback('probe', { destroyed = true } as Runnable, RequestAttributes.SCOPE_REQUEST) + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) {} + + then: + !destroyed + } + + def "restore state when a destruction callback throws"() { + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) { + GrailsWebRequest.lookup().registerDestructionCallback('probe', { throw new IllegalStateException('boom') } as Runnable, RequestAttributes.SCOPE_REQUEST) + } + + then: + thrown(IllegalStateException) + RequestContextHolder.requestAttributes == null + WrappedResponseHolder.wrappedResponse == null + } + + def "bind a web request for a non web application context"() { + given: + def out = new StringWriter() + GrailsWebRequest bound = null + + when: + RenderEnvironment.with(new StaticApplicationContext(), out) { + bound = GrailsWebRequest.lookup() + } + + then: + bound.out.is(out) + RequestContextHolder.requestAttributes == null + } } diff --git a/src/main/groovy/grails/plugins/rendering/document/RenderEnvironment.groovy b/src/main/groovy/grails/plugins/rendering/document/RenderEnvironment.groovy index 4350eac..fa181a9 100644 --- a/src/main/groovy/grails/plugins/rendering/document/RenderEnvironment.groovy +++ b/src/main/groovy/grails/plugins/rendering/document/RenderEnvironment.groovy @@ -12,6 +12,7 @@ import org.springframework.context.ApplicationContext import org.springframework.web.context.WebApplicationContext import org.springframework.web.context.request.RequestAttributes import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes import org.springframework.web.servlet.DispatcherServlet import org.springframework.web.servlet.i18n.FixedLocaleResolver import org.springframework.web.servlet.support.RequestContextUtils @@ -48,8 +49,12 @@ class RenderEnvironment { originalLocaleResolver = renderRequestAttributes.currentRequest.getAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE) } else { ownsRenderRequest = true + ServletContext context = servletContext HttpServletRequest request = PageRenderer.PageRenderRequestCreator.createInstance('/', renderLocale) - renderRequestAttributes = new GrailsWebRequest(request, response, servletContext, applicationContext) + if (context) { + request.setAttribute(GrailsApplicationAttributes.APP_URI_ATTRIBUTE, context.contextPath) + } + renderRequestAttributes = new GrailsWebRequest(request, response, context, applicationContext) RequestContextHolder.setRequestAttributes(renderRequestAttributes) } @@ -59,6 +64,16 @@ class RenderEnvironment { } private void close() { + try { + if (ownsRenderRequest) { + renderRequestAttributes.requestCompleted() + } + } finally { + restoreCallerState() + } + } + + private void restoreCallerState() { if (ownsRenderRequest) { RequestContextHolder.setRequestAttributes(originalRequestAttributes) } else { @@ -70,8 +85,8 @@ class RenderEnvironment { } private Locale callerLocale() { - originalRequestAttributes instanceof GrailsWebRequest ? - RequestContextUtils.getLocale(((GrailsWebRequest) originalRequestAttributes).currentRequest) : Locale.default + originalRequestAttributes instanceof ServletRequestAttributes ? + RequestContextUtils.getLocale(((ServletRequestAttributes) originalRequestAttributes).request) : Locale.default } private ServletContext getServletContext() { From 49458024fdd691d0846b11966119d2d2bc4206d8 Mon Sep 17 00:00:00 2001 From: William Malinowski Date: Sat, 3 Oct 2026 23:29:20 -0400 Subject: [PATCH 5/5] test: Shared prod env setup and tightened thread test (#60) --- .../document/ProductionEnvironmentSpec.groovy | 29 +++++++++++++++++++ .../document/RenderEnvironmentSpec.groovy | 24 +-------------- .../document/XhtmlDocumentServiceSpec.groovy | 22 ++------------ 3 files changed, 32 insertions(+), 43 deletions(-) create mode 100644 example/src/integration-test/groovy/grails/plugins/rendering/document/ProductionEnvironmentSpec.groovy diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/ProductionEnvironmentSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/ProductionEnvironmentSpec.groovy new file mode 100644 index 0000000..324c832 --- /dev/null +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/ProductionEnvironmentSpec.groovy @@ -0,0 +1,29 @@ +package grails.plugins.rendering.document + +import grails.util.Environment +import org.grails.web.servlet.WrappedResponseHolder +import org.springframework.web.context.request.RequestContextHolder +import spock.lang.Specification + +abstract class ProductionEnvironmentSpec extends Specification { + + private String previousEnv + + def setup() { + previousEnv = System.getProperty(Environment.KEY) + System.setProperty(Environment.KEY, Environment.PRODUCTION.name) + assert Environment.current == Environment.PRODUCTION + RequestContextHolder.resetRequestAttributes() + WrappedResponseHolder.wrappedResponse = null + } + + def cleanup() { + if (previousEnv == null) { + System.clearProperty(Environment.KEY) + } else { + System.setProperty(Environment.KEY, previousEnv) + } + RequestContextHolder.resetRequestAttributes() + WrappedResponseHolder.wrappedResponse = null + } +} diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy index 581f03b..700ab5e 100644 --- a/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy @@ -2,7 +2,6 @@ package grails.plugins.rendering.document import grails.core.GrailsApplication import grails.testing.mixin.integration.Integration -import grails.util.Environment import grails.util.GrailsWebMockUtil import org.grails.web.servlet.WrappedResponseHolder import org.grails.web.servlet.mvc.GrailsWebRequest @@ -15,32 +14,12 @@ import org.springframework.web.context.request.ServletRequestAttributes import org.springframework.web.servlet.DispatcherServlet import org.springframework.web.servlet.i18n.FixedLocaleResolver import org.springframework.web.servlet.support.RequestContextUtils -import spock.lang.Specification @Integration -class RenderEnvironmentSpec extends Specification { +class RenderEnvironmentSpec extends ProductionEnvironmentSpec { GrailsApplication grailsApplication - private String previousEnv - - def setup() { - previousEnv = System.getProperty(Environment.KEY) - System.setProperty(Environment.KEY, Environment.PRODUCTION.name) - RequestContextHolder.resetRequestAttributes() - WrappedResponseHolder.wrappedResponse = null - } - - def cleanup() { - if (previousEnv == null) { - System.clearProperty(Environment.KEY) - } else { - System.setProperty(Environment.KEY, previousEnv) - } - RequestContextHolder.resetRequestAttributes() - WrappedResponseHolder.wrappedResponse = null - } - def "bind a web request when one does not exist"() { given: def out = new StringWriter() @@ -52,7 +31,6 @@ class RenderEnvironmentSpec extends Specification { } then: - Environment.current == Environment.PRODUCTION bound != null bound.out.is(out) } diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy index 99bb180..3ca3e95 100644 --- a/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy @@ -17,37 +17,18 @@ package grails.plugins.rendering.document import grails.core.GrailsApplication import grails.testing.mixin.integration.Integration -import grails.util.Environment import grails.util.GrailsWebMockUtil import org.grails.web.servlet.mvc.GrailsWebRequest import org.springframework.mock.web.MockHttpServletRequest import org.springframework.web.context.request.RequestContextHolder import org.w3c.dom.Document -import spock.lang.Specification @Integration -class XhtmlDocumentServiceSpec extends Specification { +class XhtmlDocumentServiceSpec extends ProductionEnvironmentSpec { XhtmlDocumentService xhtmlDocumentService GrailsApplication grailsApplication - private String previousEnv - - def setup() { - previousEnv = System.getProperty(Environment.KEY) - System.setProperty(Environment.KEY, Environment.PRODUCTION.name) - RequestContextHolder.resetRequestAttributes() - } - - def cleanup() { - if (previousEnv == null) { - System.clearProperty(Environment.KEY) - } else { - System.setProperty(Environment.KEY, previousEnv) - } - RequestContextHolder.resetRequestAttributes() - } - def "render a taglib template outside web request"() { when: Document document = xhtmlDocumentService.createDocument(template: '/taglib', model: [value: 'from a job']) @@ -73,6 +54,7 @@ class XhtmlDocumentServiceSpec extends Specification { thread.join(30_000) then: + !thread.alive error == null hiddenFieldValue(document) == 'from a thread' }