Skip to content

Run GSP in a Spring Boot application without the Grails plugin lifecycle - #16184

Open
codeconsole wants to merge 55 commits into
apache:8.0.xfrom
codeconsole:fix/gsp-spring-boot-standalone-8.0.x
Open

codeconsole wants to merge 55 commits into
apache:8.0.xfrom
codeconsole:fix/gsp-spring-boot-standalone-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

A Spring Boot application that renders its views with GSP could not start, and behind the first failure were four more, each hidden by the one before it. The gsp-spring-boot example now starts, renders, decorates and packages, and its runtime test is enabled again.

Using GSP from a Spring Boot application

Views render from a plain SpringApplication, with no Grails application class and no Grails plugins:

@Configuration
@ComponentScan
@EnableAutoConfiguration
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

The whole of the example's application.properties is now the one property that says something about GSP:

sitemesh.decorator.default=main

spring.main.allow-circular-references and spring.main.allow-bean-definition-overriding are gone. Both were opted into because of how the GSP beans were wired, not because the application wanted either.

Views compiled by the compileGroovyPages build task are rendered from their compiled classes, so an application can ship without its templates:

jar {
    processResources.exclude('**/*.gsp')
}

compileGroovyPages {
    source = project.file("${project.projectDir}/src/main/resources/templates")
    serverpath = '/'   // the path a standalone application looks a view up by
}

Templates on disk still win where they are there to edit, so bootRun re-renders a template as it changes.

Styling with the asset pipeline

The example styles itself with Bootstrap, compiled by the asset pipeline at build time and served by the pipeline's own Spring Boot module. Bootstrap is an input to the build rather than a library the application ships - it goes on the assets configuration, and the artifact carries the compiled, digest-named stylesheet and script instead of the 1.8MB webjar:

apply plugin: 'cloud.wondrify.asset-pipeline'

dependencies {
    assetDevelopmentRuntime platform(project(':grails-bom'))
    assetDevelopmentRuntime 'org.webjars.npm:bootstrap' // compiled from, not packaged
    implementation 'cloud.wondrify:asset-pipeline-spring-boot' // the filter that serves what was compiled
}
@Import(AssetPipelineService.class) // the module's own configuration
public class Application { ... }
<link rel="stylesheet" href="${request.contextPath}/assets/application.css"/>

Nothing Grails-side is declared for it: no tag library, no GrailsApplication wiring, and asset-pipeline-grails is not on the application's class path.

What changed

The Grails plugin lifecycle runs for a Grails application only — one that GrailsApp launched, or one with a Grails application class among the sources. GrailsPluginLifecycleInitializer is registered for every Spring Boot application with grails-core on its class path, so an application using a Grails library was given a GrailsApplication, a plugin manager and the beans of every plugin found, over the top of what the libraries it did ask for auto-configure for themselves. It now gets that library's auto-configuration and nothing else. Documented in the 8.0 upgrade notes, with the one case inside a Grails application it changes: a @SpringBootTest that names configuration classes of its own now names the application class as well.

Bean wiring. Tag libraries are beans of the context, found by the lookup once they exist, rather than held inside it — they are autowired with the lookup, so a lookup that held them was a cycle. The GSP codec lookup is configured after the codecs module, so its @ConditionalOnMissingBean guard can do its work instead of being overridden. The core plugin's auto-configuration is contributed only where there is a GrailsApplication - the condition is on the plugin class, so the generated CoreAutoConfiguration is gated as a whole - and a context without one starts rather than failing on a bean of a type it never had.

Standalone GSP. The view registry compileGroovyPages writes is read into the page locator; <g:applyLayout> resolves its layout; the grailsLayout namespace is registered, so the capture tags the GSP compiler emits no longer reach the browser as markup; the JSP tag library resolver is optional, so GSP renders without JSP support on the class path; and the tag libraries a Grails plugin carries are found alongside the ones marked @TagLib.

Auto-proxy creators. The reflection patch that makes Spring recognise the Groovy aware auto-proxy creator had sat in a GrailsAutoConfiguration static initializer since 2015, while CoreGrailsPlugin is what registers the creator. It moved to GroovyAwareAutoProxyCreators, applied where the creator is registered, so it holds for an application GrailsApp launched from sources that do not load that class.

An application that maps no URLs can still have a link generator. DefaultLinkGenerator requires the URL mappings holder, as it should. GspAutoConfiguration contributes an empty one under @ConditionalOnMissingBean(name = "grailsUrlMappingsHolder"), ordered after UrlMappingsAutoConfiguration so a holder from there wins; only a standalone application has that module on its class path, so a Grails application never sees the bean.

The view registry is written whole rather than merged into what an earlier build left behind, which kept naming views since renamed or removed, against classes no longer there.

The compiled pages are on the test class path of a build that is not a Grails build. GroovyPagePlugin registered them with a path resolved against the project directory rather than the build directory, so an application tested from the build could load none of them. A Grails application's tests render GSP from source and gain nothing from them, so its test task is left as it was - the plugin records whether GrailsGradlePlugin is applied and decides when the task graph is built. They are kept off the main runtime class path, which a boot archive packages into its classes directory - where the archive copies them already.

sitemesh.decorator.default is consulted for the default layout, after both Grails keys. A Grails application that set only the SiteMesh key was decorated with the implicit application layout and now gets the layout the key names; in the upgrade notes.

The JSP compiler is left out of the executable jar — 3.3MB of Eclipse compiler that a jar cannot use, since it packages no JSP to compile. The war and bootRun, which do serve JSPs, keep it.

Limitations

  • JSP cannot be served from an executable jar. Jasper compiles a JSP from the servlet context, and a jar packages none, so the example offers its JSP rendering only where it can serve one. Jasper itself stays in every artifact: the GSP form uses Spring's form tag library, which is a JSP tag library, and renders nothing without the JSP API Jasper carries.
  • Precompiled views need serverpath set to match the template root. The default registers each view below /WEB-INF/grails-app/views/, which is where a Grails application looks and a standalone application never does.
  • A logical asset URL is answered with a one-year Cache-Control. The pipeline's servlet filter rewrites /assets/application.css to the digest-named file and then decides cacheability from the rewritten name, which is never a manifest key, so it answers public, max-age=31536000 on a URL whose content changes on redeploy. Measured against the packaged example. The fix belongs in the asset pipeline, not here.
  • Rendering GSP still puts grails-core on the class path for the GrailsApplication that the page locator, tag library lookup and JSP tag library resolver read, and with it grails-datastore-core, javassist, caffeine and jakarta.persistence-api — around 2.6MB of persistence machinery that GrailsApplication.getMappingContext() makes structural rather than incidental.

…gistered

CoreGrailsPlugin registers a Groovy aware auto-proxy creator under Spring's
internalAutoProxyCreator bean name, and Spring rejects a creator class it does
not know as soon as anything asks it to register or escalate one - Boot's
AopAutoConfiguration, @EnableAspectJAutoProxy, the aop namespace:

    Class name [org.grails.spring.aop.autoproxy.
    GroovyAwareInfrastructureAdvisorAutoProxyCreator]
    is not a known auto-proxy creator class

The reflection patch that makes them known has sat in a GrailsAutoConfiguration
static initializer since 2015, which held for as long as the plugin lifecycle
ran through that class. Since the lifecycle was retimed it runs from
GrailsPluginLifecycleInitializer for every Spring Boot application with
grails-core on the class path, so an application that is not a Grails
application registers the creator without ever loading GrailsAutoConfiguration,
and fails to start.

Move the patch to GroovyAwareAutoProxyCreators and apply it where the creator is
registered. It is idempotent, so both call sites can apply it, and it warns
rather than leaving the failure to surface later as Spring's error.
Four things stood between a plain Spring Boot application and a decorated GSP,
leaving the gsp-spring-boot example unable to start and its runtime test
disabled:

- the tag library beans were autowired by name, which injected the view
  resolver eagerly - viewResolver is an alias of gspViewResolver - and defeated
  the @lazy that RenderSitemeshTagLib declares to break exactly that cycle
- <g:applyLayout> resolves its layout through a bean qualified jspViewResolver,
  the name Grails gives an application's own view resolver, which a standalone
  application has no bean for; gspViewResolver now answers to that name too, as
  GrailsSiteMeshViewResolverBeanPostProcessor already documented that it would
- that post processor compared the target against the bean name only, so an
  aliased resolver was never wrapped and no page was decorated
- Sitemesh3LayoutTagLib, which carries the grailsLayout namespace the GSP
  compiler emits for the head, title and body of a decorated page, was not
  registered, so those capture tags reached the browser as literal markup

The SiteMesh 3 layout finder also ignored sitemesh.decorator.default, the key a
Spring Boot application configures its default layout with; it is now consulted
after the two Grails keys, which keep precedence.

Re-enable the example's runtime test, covering decoration as well as rendering.
The example excluded *.gsp from its resources on the assumption that the
precompiled GSP classes would serve the views. They cannot: compileGroovyPages
keys its view registry by the Grails view convention
(/WEB-INF/grails-app/views/...), which a standalone Spring Boot application
never searches - it looks under its configured template roots - and only the
Grails GSP plugin loads that registry into the page locator at all.

The result was a bootJar that started and then answered 404 for every view,
having fallen through to a servlet dispatch for a path nothing serves. Ship the
sources so the packaged application renders, as bootRun already did from the
project directory.
GroovyPagePlugin registered its compiled pages with output.dir('gsp-classes'),
which resolves against the project directory rather than the build directory and
so named a directory that is never written. The pages and the view registry
beside them reached the archives only through the copies the jar and war tasks
make of the compile task's real destination, which left them off the class path
of anything else: an application started with bootRun or exercised by a test
could not load a single page it would ship with.

Add the compile tasks' destinations to the runtime and test runtime class paths
instead. They cannot be registered as source set output, which is what the
`classes` task builds, because compileGroovyPages runs after `classes` and the
two would form a cycle.
Only the Grails GSP plugin read the view registry that the compileGroovyPages
build task writes, so a standalone Spring Boot application could not render a
view it had compiled: it searched its template roots, found no template where
the templates had been left out of the artifact, and fell through to a servlet
dispatch for a path nothing serves.

Read the registry into the page locator, and search the path a view is
registered under - the one below the template root, naming no resource root -
alongside the roots themselves.

The registry is left unread when a template root on the file system is in play.
The locator prefers a compiled view over a template it can find, so reading it
during development would serve every page as it stood when the application was
built, however often its template was edited.
Now that a standalone application can render the views compiled into it, the
example ships those rather than the templates they were built from, which is
what it always intended: the *.gsp exclusion is restored, undoing the stopgap in
"Ship the GSP sources in the packaged gsp-spring-boot example".

serverpath registers each view under its path below the template root, where the
default registers it below the /WEB-INF/grails-app/views/ a Grails application
looks under, and nothing here ever searches.

The new test renders with the template root pointed at the class path, which
holds no templates, so only the compiled views can answer it - the same
arrangement the packaged application runs under, where every earlier failure in
this series was invisible to a test run from the project directory.
GroovyPageCompiler merged the registry it writes into the one an earlier run had
left behind, on the stated grounds that only changed pages are added to the
mapping. They are not: compileGSP records a page whether or not it had to
recompile it, so the mapping already names every page of the run.

The merge only preserved entries no run would write again - a page since
renamed or removed, or one registered under a different view prefix - each
naming a class that is no longer there. They survived a clean, since a merged
registry is what the build cache had stored, and were left for the locator to
trip over at runtime, where a mapping that resolves to no class costs a failed
Class.forName and a warning per render before it falls back.
The template engine bean took a TagLibraryResolver as a required dependency,
and the only resolver is contributed by a configuration conditional on
grails-web-jsp being present. An application that renders GSP without JSP
support therefore did not start at all, failing on a missing bean of a type it
had no use for.

Take the resolver as an ObjectProvider. A page that uses a JSP tag library still
needs the resolver, and could not have used one without JSP support anyway.
The executable jar packages no JSP - src/main/webapp goes into a war, not a jar
- and Jasper compiles a JSP from the servlet context, so the /jsp link the
layout rendered led to a 500 there while working in a war and under bootRun.

Offer the link, and switch the form over, only where the JSP is in the servlet
context. A request typed in by hand now leaves the form on GSP rather than
failing to render. What settles it is the page being there rather than a
document root existing, which an executable jar has - a temporary and empty one
- and rather than the JSP libraries being on the class path, which they are in
every artifact: the GSP form uses Spring's form tag library, a JSP tag library,
and renders nothing without them.

That is also why the jar keeps those libraries. Leaving them out would take
rewriting the GSP form to drop <form:form>, giving up what it demonstrates.
Two auto-configurations define a bean named codecLookup: the codecs module's,
unconditionally and as the primary one, and GSP's stand-in for it, guarded by
@ConditionalOnMissingBean. With no order declared between them GSP's was
processed first, so its guard found nothing to back off from and the
unconditional definition overrode it - leaving an application to enable
bean-definition overriding before it would start, for a bean it never wanted
twice.

Declaring GSP's after the codecs module lets the guard do its work: the module's
lookup registers, GSP's backs off, and the stand-in is left to the application
that has no codecs module on its class path. Named rather than referenced,
since that is the application GSP has to keep working for.

The gsp-spring-boot example no longer opts into bean-definition overriding, and
starts with no override logged at all, which is what its tests now hold to.
Every tag library is autowired with the lookup that finds it, by the
TagLibraryInvoker trait they all carry. The standalone auto-configuration in
turn built the lookup out of its tag libraries, holding them as inner beans of
its tagLibInstances property - so the lookup depended on beans that depended on
the lookup, and an application had to allow circular references before it would
start:

    gspTagLibraryLookup <-> (inner bean) RenderTagLib

Register the tag libraries as beans of the context, leaving the lookup to find
them through the @taglib annotation once they all exist. StandaloneTagLibraryLookup
already looked for them on a context refreshed event; it now does so as a
SmartInitializingSingleton as well, which is before the web server accepts a
request rather than after, so the first page to render cannot outrun the tag
libraries it uses.

An application can still register a tag library under one of these names itself,
and the registrar leaves that one alone.

The gsp-spring-boot example no longer allows circular references. With the
preceding commit it now asks for neither that nor bean-definition overriding -
it renders GSP with nothing but its own configuration - and its tests hold it
there, since either would stop the application from starting.
…is one

CoreAutoConfiguration is contributed to every Spring Boot application with
grails-core on its class path, and two of its beans are the GrailsApplication
read through another type: the class loader it was built with, and its config as
ConfigProperties. Both took the application as a bean, so a context without one
could not be created at all - it failed asking for a bean of a type it had no
use for.

Condition the two on a GrailsApplication being there. An application that has
one is unchanged; one that has none keeps the beans that never needed it, the
placeholder configurer among them, and starts.
GrailsPluginLifecycleInitializer is registered for every Spring Boot application
that has grails-core on its class path, so the lifecycle ran for applications
that are not Grails applications: one depending on a Grails library - GSP for
its views, say - was given a GrailsApplication, a plugin manager and the beans
of every plugin found, over the top of what the libraries it did ask for
auto-configure for themselves.

Stand the phase down unless one of the context's sources is a GrailsApplicationClass.
A Grails application is unaffected. A Spring Boot application using a Grails
library gets that library's auto-configuration and nothing else - which is what
it asked for, and what such an application got before the lifecycle was retimed
onto this initializer.

The GSP auto-configuration contributes the GrailsApplicationAware post processor
that the core plugin contributes to a Grails application, so the beans that read
the application through it - the page locator, the tag library lookup, the JSP
tag library resolver - are handed it either way.
The configuration that applies the SiteMesh defaults to a context built without
SpringApplication - a test context, where the environment post processor that
serves every other application does not run - was a BeanDefinitionRegistryPostProcessor
with both of its methods empty. That is a way of being instantiated early enough
for EnvironmentAware to matter, and it cost every application two warnings a
boot about a @configuration class created too early to enhance.

It declares no beans, so there is nothing to enhance: say so with
proxyBeanMethods = false, and implement the plainer BeanFactoryPostProcessor,
which is instantiated just as early and has one method to leave empty rather
than two.
The plugin lifecycle stood down unless a source of the context was a Grails
application class, which left out an application that GrailsApp launched from
sources of another kind - a plain @configuration class, as DevelopmentModeWatchSpec
starts one. Being launched by GrailsApp is as much a statement that this is a
Grails application as the application class is, and GrailsApp already records it
by stashing the sources it was given.

Take either as the answer. What stays out is what was meant to: an application
that Spring Boot launched, of sources that say nothing about Grails.
Two of its three properties said nothing about GSP. The trace level for web
logging is a debugging aid that made every request print a page of Spring
internals, and the tag library descriptor scan pattern restated a narrower form
of what the GSP JSP integration already defaults to - the Spring form tag
library the example's form uses is scanned for either way.

What is left is the default layout, which is the one thing an application
rendering GSP through SiteMesh has to say.
…t-standalone-8.0.x

# Conflicts:
#	grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy
#	grails-doc/src/en/guide/upgrading/upgrading80x.adoc
Jasper compiles a JSP at run time with the Eclipse compiler, 3.3MB of the
example's 41.5MB jar. An executable jar packages no JSP to compile - a JSP is
served from the servlet context, which a war carries and a jar does not - so
none of it is reachable there.

Excluding ecj from Jasper itself would take the compiler from the war and from
bootRun as well, where a JSP does render: both answer /jsp with a 500 and "No
Java compiler available" without it. It is left out of the jar rather than out
of the dependency, which is the artifact that cannot use it.

Jasper stays in every artifact regardless: the GSP form uses Spring's form tag
library, which is a JSP tag library and needs the JSP API that Jasper carries.
GSP arrives with the web tier behind it, and this application uses the part of
it that renders a view. It routes and binds with Spring MVC, it has no domain
classes, and it answers in HTML, so the rest is packaged and never called:
Grails URL mappings and the constraint validation behind them - which carries
commons-validator and commons-collections 3.2.2 - Grails data binding, Grails
MVC, and Jackson.

Each is excluded from the dependency that brings it and named with what it is
for, rather than filtered out of the class path as a set, so what the
application does not use is a statement about the application.

The jar goes from 41.5MB to 34.2MB. The GSP page, both layouts, the form and
its validation, and the JSP rendering in the war and under bootRun are unchanged.
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.08696% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.7736%. Comparing base (3067d0a) to head (bf09ab4).
⚠️ Report is 40 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ils/gsp/boot/StandaloneGroovyPageViewResolver.java 54.5454% 3 Missing and 2 partials ⚠️
...ng/aop/autoproxy/GroovyAwareAutoProxyCreators.java 76.9231% 3 Missing ⚠️
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 62.5000% 2 Missing and 1 partial ⚠️
...ain/java/grails/gsp/boot/GspAutoConfiguration.java 93.0233% 3 Missing ⚠️
...ig/GrailsEarlyPluginRegistrationPostProcessor.java 90.9091% 0 Missing and 1 partial ⚠️
...g/grails/web/pages/StandaloneTagLibraryLookup.java 91.6667% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16184        +/-   ##
==================================================
+ Coverage     55.2582%   55.7736%   +0.5154%     
- Complexity      20976      21186       +210     
==================================================
  Files            2113       2116         +3     
  Lines          101632     101869       +237     
  Branches        18045      18112        +67     
==================================================
+ Hits            56160      56816       +656     
+ Misses          37422      36916       -506     
- Partials         8050       8137        +87     
Files with missing lines Coverage Δ
.../grails/boot/config/GrailsAutoConfiguration.groovy 60.7143% <100.0000%> (-3.9916%) ⬇️
.../groovy/org/grails/plugins/CoreGrailsPlugin.groovy 71.6049% <100.0000%> (+0.3549%) ⬆️
.../org/grails/gsp/compiler/GroovyPageCompiler.groovy 73.5043% <ø> (+5.1709%) ⬆️
...GrailsSiteMeshViewResolverBeanPostProcessor.groovy 68.4210% <100.0000%> (+8.4210%) ⬆️
...ils/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy 76.4706% <100.0000%> (+1.4706%) ⬆️
...ils/plugins/sitemesh3/Sitemesh3LayoutTagLib.groovy 14.7059% <ø> (+14.7059%) ⬆️
...daloneGrailsApplicationAwareBeanPostProcessor.java 100.0000% <100.0000%> (ø)
...ig/GrailsEarlyPluginRegistrationPostProcessor.java 83.9623% <90.9091%> (-2.6357%) ⬇️
...g/grails/web/pages/StandaloneTagLibraryLookup.java 76.9231% <91.6667%> (+76.9231%) ⬆️
...ng/aop/autoproxy/GroovyAwareAutoProxyCreators.java 76.9231% <76.9231%> (ø)
... and 3 more

... and 30 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A boot archive packages every directory of its class path into its classes
directory, and the compiled pages are copied there by the archive already, so
every page arrived twice and the copy failed - which is what building any Grails
example with views did.

They stay on the test runtime class path, which no archive reads, so a test
still loads the pages the application ships, the view registry among them. An
application run from the build renders its templates as they are edited, which
a page compiled ahead of the edit would have stood in the way of.
A Spring Boot application has no plugins to scan for artefacts, so a tag library
reaches a page by being a bean of the context. The lookup found the beans marked
@taglib, which is how a tag library written for GSP is marked, and passed over
the ones marked @ArteFact("TagLib"), which is how a Grails plugin marks the tag
libraries it carries - the asset pipeline's <asset:...> among them.

Both are now detected, so a tag library out of a plugin can be declared as a
bean and used in a page exactly as in a Grails application. Artefacts of other
kinds are left alone.
The example now uses a tag library written for Grails - the asset pipeline's
<asset:stylesheet> - from a Spring Boot application, which is what a plugin's
tag libraries are for an application that installs no plugins: beans of the
context, declared in AssetPipelineConfiguration alongside the filter that serves
what the pipeline compiled.

Bootstrap is an input to the build rather than a library the application ships.
It is declared on the assets configuration, the manifest names the one file the
page needs, and 246KB of compiled CSS is packaged instead of the 1.8MB webjar.

The URL mappings exclusion comes back out: the asset pipeline contributes a link
generator, and a link generator reads a mappings holder.
The link generator declared the URL mappings holder as a required dependency,
though it reads it in one place: the link to a controller and action. A link to
a resource or to a path never touches it, so an application routing with Spring
MVC could not have a link generator at all - it had to declare an empty mappings
holder for a bean it would never ask a mapping of.

The holder is optional now, and a link that does need mappings says so instead
of failing on a null. The gsp-spring-boot example drops the empty holder it
declared for the asset pipeline's link generator.
The form is Bootstrap's - labels, controls, help text, and the invalid state Spring's
form tag library marks a rejected field with - inside a card, under a navbar that
carries the light / dark / auto theme menu the Grails welcome page has. The menu is
Bootstrap's own colour modes, applied before the page paints by a script in the head,
and remembered per browser.

Bootstrap's script and its icon font join the stylesheet on the assets configuration.
Only what the two manifests compile ships, plus the icon font's own files - the ones
its stylesheet asks the browser for by name - and no source maps.

Two things the styling turned up:

The session id was carried in the URL for a visitor arriving without a cookie, which
is how the container rewrites a form action, and Spring MVC answers a path carrying
one with a 404 - so submitting the form on a first visit failed. Sessions are tracked
by cookie now, where the id belongs.

The results view was told nothing of what rendered it, because only the form handler
said so, and its heading came out unfinished. Every view is told, by the same
interceptor that offers the JSP rendering.
@codeconsole
codeconsole requested review from jamesfredley and matrei and removed request for jamesfredley August 21, 2026 09:46
Both sides added sections to the upgrade guide. Upstream's marshalling section
reused number 54, which the guide already had, so it is 58 and this branch's two
sections follow it as 59 and 60.
…odule

The application declared the asset pipeline's tag libraries as beans, with the
manifest and filter beside them, to write <asset:...> in its layout. That is
wiring the asset pipeline itself is the place for, and none of it is needed to
serve what the pipeline compiles: asset-pipeline-spring-boot registers the
filter from its own configuration, which the application imports, and the
layout links the compiled files by name - the filter resolves each to the
digest-named file the build wrote. The compiled assets are packaged with the
application's resources rather than only into its archives, so a test run from
the project serves the same files the artifact does.

Nothing Grails-side is declared for it, and asset-pipeline-grails is off the
class path.
…path

Both were excluded until the asset pipeline's link generator asked for a URL
mappings holder. The example maps no URLs and validates no domain classes, and
nothing it depends on asks for either now.
The runtime classpath extends the assets configuration, so a webjar declared
there is both compiled from and shipped: 1.8MB of Bootstrap and 1.7MB of icons
sat in the artifact behind the compiled files that are what actually gets
served, while the build file said Bootstrap was an input to the build rather
than a library the application ships.

assetDevelopmentRuntime is read by the compiler and is not extended by the
runtime classpath, which is what that sentence describes. The artifact keeps the
icon font, which the compiled stylesheet asks the browser for by name.

Executable jar 41.5MB -> 37.9MB, war 44.8MB -> 41.2MB.
The javadoc claimed the gsp-spring-boot example starts a whole application with
an asset pipeline link generator in it. That example has no link generator, and
it excludes grails-web-url-mappings, so the configuration's @ConditionalOnClass
does not even match there. This specification is the only coverage.
@matrei

matrei commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

AI Review

Verified the framework changes against the modules they touch and ran the example. The lifecycle gate, the whole-configuration gate on CoreAutoConfiguration, the auto-proxy creator move (Spring 7.0.9 still keeps APC_PRIORITY_LIST as a mutable ArrayList, and the order of the two creators is preserved), the write-whole view registry (up-to-date pages are re-registered on every run, and the two compile tasks write to separate directories) and the SiteMesh alias handling all check out. @Integration is unaffected by the gate because GrailsApplicationContextLoader launches through GrailsApp. One defect, a few test and doc gaps, and two design notes below.

Bug: Sitemesh3LayoutTagLib renders attribute values unencoded in a standalone application

TagLibraryLookupRegistrar.createTagLibBeanDefinition registers the tag libraries with no autowire mode, relying on each class's own @Autowired declarations. RenderTagLib and RenderSitemeshTagLib declare them; Sitemesh3LayoutTagLib does not - its codecLookup is a plain property that a Grails application fills by name through TagLibBeanDefinitionsPostProcessor (AUTOWIRE_BY_NAME). In the gsp-spring-boot example the field is null (probed with a @SpringBootTest: renderTagLib.codecLookup is a DefaultCodecLookup, layoutTagLib.codecLookup is null), so captureTagContent takes its htmlEncoder == null branch and writes every attribute of the captured <head>, <body>, <title> and friends verbatim. A layout or page with <body class="${...}"> puts the raw value into the response.

Fix: @Autowired on Sitemesh3LayoutTagLib.codecLookup (as RenderSitemeshTagLib does), and a test that a captured attribute value is HTML-encoded in the standalone context - either in TagLibraryLookupRegistrarSpec or as a rendered-page assertion in the example.

Tests

  • CoreGrailsPluginRegistrarSpec ("nothing here loads GrailsAutoConfiguration"): APC_PRIORITY_LIST is JVM-global and test-config.gradle sets forkEvery = 100, so any spec that touched GrailsAutoConfiguration earlier in the fork (EarlyPluginRegistrationOrderingSpec, GrailsAutoConfigurationArtefactsSpec, ...) has already patched the list. Reverting the registerWithAopConfigUtils() call in CoreGrailsPlugin does not reliably fail this test.
  • GroovyPageCompilerSpec never sets generatedGroovyPagesDirectory, so the compiler falls back to ${java.io.tmpdir}/gspcompile, shared across parallel forks and never cleaned. Point it at the @TempDir.
  • GspAutoConfigurationSpec exercises only the protected resolvePrecompiledViews. The public surface - that the groovyPageLocator bean receives the map, and the new paths.add(cleanUri(uri)) search path - is covered only by the example's PrecompiledViewTest; nothing in the module fails if either is reverted.
  • GroovyPagePluginFunctionalSpec: the non-Grails project asserts the pages are on the test runtime class path but not that test depends on compileGroovyPages; the Grails project asserts the negative. The builtBy wiring is the whole point of the change and is unverified.
  • No test asserts that CoreAutoConfiguration stays out of the standalone context once GspAutoConfiguration contributes its own GrailsApplication. That holds today only because Core is @AutoConfigureOrder(HIGHEST_PRECEDENCE) and is evaluated before the standalone grailsApplication bean definition exists; if that ordering ever changed, core beans would land silently. A context.containsBean('pluginManager') == false in the example would pin it.

Docs

  • upgrading80x.adoc (SiteMesh note) names Sitemesh3EnvironmentPostProcessor, an internal class (rule 8). The sentence works without it.
  • Nothing user-facing documents the standalone additions: precompiled views read from classpath:gsp/views.properties and skipped when a file: template root is in use, the serverpath = '/' requirement, the empty grailsUrlMappingsHolder, and @Artefact("TagLib") beans being picked up. The module README is unchanged. Rule 7 asks for doc coverage with the feature.

Design notes

  • @Bean(name = {"gspViewResolver", "jspViewResolver"}): a Spring Boot application that also serves JSP may well have its own jspViewResolver. User configuration is processed before auto-configuration, and SimpleAliasRegistry.registerAlias does not check bean definition names, so the alias would silently take over getBean("jspViewResolver") rather than fail. Registering the alias only when no bean of that name exists (the ReplaceViewResolverRegistrar already does this kind of thing) would be safer.
  • Example: JspViewController's Javadoc says it "is mapped only where a JSP can be served"; it is always mapped and the guard is the runtime canServeJsp check. The private static boolean jsp in WebController is application-wide state that JspViewTest has to reset; session state would be the idiomatic example.

Process

The thread still has the open question of whether this lands on 8.0.x before RC1; the lifecycle gate changes what a Grails application's @SpringBootTest(classes = SomeConfig) gets, which is the kind of change the "review then commit" policy is for.

Resolved grails-doc/src/en/guide/upgrading/upgrading80x.adoc: both branches appended
numbered sections after 57. Kept upstream's three new sections and this branch's two,
renumbered so the list stays sequential — 58 non-public bean marshalling, 59 interceptor
URI matching, 60 sort and order validation, 61 XML DOCTYPE, 62 plugin lifecycle,
63 sitemesh.decorator.default.
The tag libraries of a Spring Boot application are wired from their own
declarations. Sitemesh3LayoutTagLib declared none for its codec lookup - a Grails
application fills that field in by name - so in a standalone context it was null,
and captureTagContent took its unencoded branch.

The GSP compiler rewrites the <head>, <body> and <title> of every decorated page
to capture tags, so every attribute of those elements was written verbatim: a
layout or page with <body class="${...}"> put the raw value into the response,
where it could close the attribute and open a tag of its own.

The field declares the dependency now, as RenderSitemeshTagLib does. Covered by
the tag library's own specification, which pins both the encoded output and what
an unwired lookup costs, and by an assertion in the example that the context
wires every tag library's lookup - that one fails without the declaration.

The example also pins that the plugin runtime stays out of a standalone context,
which holds today only because the core auto-configuration is evaluated before
the standalone GrailsApplication exists.

Found by Mattias Reichel in review of apache#16184.
CoreGrailsPluginRegistrarSpec asserted that Spring Boot's AOP auto-configuration
accepts the registered creator, but Spring's priority list is static and shared
by the whole fork: any specification that loaded GrailsAutoConfiguration earlier
had already patched it, so the assertion held whether or not the code under test
did anything. It now clears the Grails entries first and restores them after.

GroovyPageCompilerSpec left generatedGroovyPagesDirectory unset, so the compiler
wrote the Groovy it generates under java.io.tmpdir - shared by every parallel
fork and never cleaned. It is pointed at the temporary directory of the test.

GroovyPagePluginFunctionalSpec asserted the compiled pages are on the test
runtime class path but not that they are built before the tests run, which is
what the builtBy wiring is for. The non-Grails project asserts both compile
tasks are dependencies of test, as the Grails project asserts neither is.

Found by Mattias Reichel in review of apache#16184.
GspAutoConfigurationSpec exercised only the method that reads the registry. That
the locator bean is given the map, and that it searches the bare path a view is
registered under, were covered only by the example application - nothing in the
module failed if either were reverted.

The registry fixture now names classes that exist, so the specification drives
the contributed locator through findPage and gets a compiled script source back.
Reverting either the map or the search path fails it.

Found by Mattias Reichel in review of apache#16184.
The GSP view resolver was declared under both gspViewResolver and
jspViewResolver, which makes the second an alias. An alias is resolved ahead of a
bean definition of the same name and registering one does not check for a
conflict, so a Spring Boot application that also serves JSP and has a
jspViewResolver of its own would silently get the GSP resolver from
getBean("jspViewResolver") rather than an error.

The alias is registered by the registrar that already handles the viewResolver
name, and only when no bean definition holds jspViewResolver. User configuration
is parsed before auto-configuration, so an application's own resolver is there to
be seen.

Found by Mattias Reichel in review of apache#16184.
Which rendering of the form to serve was a static field, so one visitor's choice
of JSP was every visitor's, and JspViewTest had to put it back afterwards. It is
a session attribute now, which is what an example should show; the test keeps its
cookies instead, so its requests are one visit.

The comment on the setter said JspViewController is mapped only where a JSP can
be served. It is always mapped - what it checks is the servlet context, at the
time of the request.

Found by Mattias Reichel in review of apache#16184.
Nothing user-facing covered what this adds: the views compiled at build time and
read from the class path, the serverpath the compile task needs, the templates
being rendered instead wherever a template root is a file: URL, the empty URL
mappings holder, and a tag library declared as a bean being picked up whether it
is annotated @taglib or @ArteFact("TagLib").

Adds a guide page under Groovy Server Pages, and replaces the module README,
which described sample applications that no longer exist.

The SiteMesh upgrade note named an internal class to explain where the property
comes from; the sentence says the same thing without it.

Found by Mattias Reichel in review of apache#16184.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei All addressed, pushed as 3ac93a3..16eb980.

Bug — unencoded attribute values (3ac93a3). Confirmed: with the registrar's wiring codecLookup was null and captureTagContent wrote <body class=""><script>x</script>"></body>. @Autowired on the field, as RenderSitemeshTagLib has it. Two tests in Sitemesh3LayoutTagLibSpec pin the encoded output and what an unwired lookup costs, and StandaloneContextTest in the example asserts the context wires the lookup on both tag libraries — that one fails with the declaration removed.

Tests (7dc87ac, 3ceb572)

  • CoreGrailsPluginRegistrarSpec now clears the Grails entries from APC_PRIORITY_LIST before the feature and restores them after. Reverting registerWithAopConfigUtils() in CoreGrailsPlugin now fails it; it did not before.
  • GroovyPageCompilerSpec points generatedGroovyPagesDirectory at its @TempDir.
  • GspAutoConfigurationSpec drives the contributed locator through findPage and gets a GroovyPageCompiledScriptSource back. The registry fixture names classes that exist, so reverting either setPrecompiledGspMap or paths.add(cleanUri(uri)) fails it in-module.
  • GroovyPagePluginFunctionalSpec: the non-Grails project asserts test depends on both compileGroovyPages and compileWebappGroovyPages; the Grails project asserts neither.
  • StandaloneContextTest asserts pluginManager, grailsConfigProperties and classLoader are absent from the example's context.

Docs (16eb980). The SiteMesh note no longer names Sitemesh3EnvironmentPostProcessor. New guide page The Web Layer → Groovy Server Pages → GSP in a Spring Boot Application covering the precompiled views and when they are skipped, the serverpath = '/' requirement, the empty grailsUrlMappingsHolder, @Artefact("TagLib") beans, and the spring.gsp.* properties. The module README described sample applications that no longer exist and is replaced.

Design notes

  • jspViewResolver (3d23083): moved out of @Bean(name = {...}) into ReplaceViewResolverRegistrar, which registers the alias only when no bean definition holds the name. Two cases in TagLibraryLookupRegistrarSpec cover both branches.
  • Example (6ca3141): the selected rendering is a session attribute; JspViewTest keeps its cookies rather than resetting application state. The stale sentence was on WebController.selectJsp and is corrected — the controller is always mapped, and what it checks is the servlet context at request time.

Process. The 8.0.x-before-RC1 question is still open and is @codeconsole's call, not something these commits settle.

@matrei

matrei commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Second round, on 16eb980 against 8.0.x (merge base a5758d6 is the current tip of 8.0.x, so the branch is clean). Reviewed the six commits since the first round, 3ac93a3..16eb980, and ran :grails-gsp-spring-boot:test, :grails-sitemesh3:test, GroovyPageCompilerSpec, CoreGrailsPluginRegistrarSpec, the gsp-spring-boot example's tests, codeStyle for the touched modules and GroovyPagePluginFunctionalSpec in grails-gradle. All green.

Every point from the first round is addressed: the encoding defect, the four test gaps, the two doc gaps and both design notes. What is left is in the new guide page, plus one test-hygiene detail.

Docs: the tag library example in gspInSpringBoot.adoc does not compile and is not what the example does

grails-doc/src/en/guide/theWebLayer/gsp/gspInSpringBoot.adoc:80-81:

public AssetsTagLib assetsTagLib(AssetProcessorService assetProcessorService) {
    return new AssetsTagLib(assetProcessorService);
}

AssetsTagLib (asset-pipeline-grails 5.2.0-M3, the version the BOM manages) has only a no-arg constructor; assetProcessorService is a plain Object property. And AssetProcessorService is a Grails service artefact of that plugin, so in an application with no plugin lifecycle there is no bean of it to inject either - the snippet needs the reader to declare that too, and the tag library also expects grailsApplication and a tagLibraryLookup from the TagLibraryInvoker trait. None of this is exercised: per your own comment on 48175cf, asset-pipeline-grails is off the example's class path and the layout links the compiled files by name. The TIP at line 117 ("using all of this - precompiled views, the asset pipeline, ...") therefore points the reader at an application that does not demonstrate the section it closes.

Suggest either a neutral example that is actually covered - a @grails.gsp.TagLib class the application declares as a @Bean, which is what StandaloneTagLibraryLookupSpec and TagLibraryLookupRegistrarSpec test - or wiring the asset tag library in the example for real and documenting what that took. Keep the @Artefact("TagLib") sentence at line 85; that is covered.

Docs: two rendering details in the same page

  • gspInSpringBoot.adoc:26,35: {version} inside a [source,groovy] block is not substituted; listing blocks only get verbatim subs. pluginSupport.adoc uses [source,groovy,subs="attributes"] for the same line. Use subs="attributes+" here so the // <1> callouts keep working.
  • gspInSpringBoot.adoc:31: id 'org.apache.grails.gradle.grails-gsp' with no version does not resolve in a plain Spring Boot build. Either version '{version}' on the line, or say that the version comes from pluginManagement / the gradle BOM.

Test: CoreGrailsPluginRegistrarSpec leaves duplicates behind

grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginRegistrarSpec.groovy:221-222: the feature removes the two Grails creators, the registrar under test puts them back (GroovyAwareAutoProxyCreators.registerWithAopConfigUtils adds anything the list does not contain), and then cleanup does priorityList.addAll(removed) - so every run appends a second copy of both classes to APC_PRIORITY_LIST. Harmless for findPriorityForClass, which uses indexOf, but the Javadoc at line 216 says the fork is left as it was found, and it is not. Restore only what is missing:

cleanupActions << { removed.each { if (!priorityList.contains(it)) priorityList.add(it) } }

Nit

Sitemesh3LayoutTagLibSpec.groovy:80-89 pins the unencoded output of a tag library built with no codecLookup. It makes the wiring load-bearing, as the comment says, but it also turns the absence of a guard into specified behaviour. If the intent is "the lookup is required", a @Autowired(required = true) failure at context start is the stronger contract, and this case could go.

Verified as correct

  • Sitemesh3LayoutTagLib: @Autowired on the property lands on the field, as it does in RenderSitemeshTagLib; a Grails application is unaffected because the codecs plugin always contributes codecLookup. StandaloneContextTest fails with the annotation removed.
  • ReplaceViewResolverRegistrar (GspAutoConfiguration.java:428): the registrar runs after user configuration classes are loaded, so a user jspViewResolver is seen and left alone; TagLibraryLookupRegistrarSpec covers both branches. A bean of that name registered by a later auto-configuration now hits BeanDefinitionOverrideException under Boot's default rather than being silently aliased over - same timing as the old @Bean(name = {...}) form, and loud, which is the right failure. The alias is registered regardless of spring.gsp.replaceViewResolverBean, as before.
  • GspAutoConfigurationSpec: the views.properties fixture names real GroovyPage subclasses with the metadata constants the compiler emits, so findPage goes through GroovyPageCompiledScriptSource; reverting either setPrecompiledGspMap or the cleanUri search path fails it in-module.
  • GroovyPageCompilerSpec now writes generated sources under its @TempDir.
  • GroovyPagePluginFunctionalSpec: the non-Grails project asserts test depends on both compileGroovyPages and compileWebappGroovyPages; the Grails project asserts neither. Passes against the branch.
  • Example: session-scoped selection, cookie-keeping client in JspViewTest, and StandaloneContextTest pins pluginManager, grailsConfigProperties and classLoader (GrailsApplication.CLASS_LOADER_BEAN) absent. The stale Javadoc sentence is gone.
  • upgrading80x.adoc no longer names Sitemesh3EnvironmentPostProcessor. The new page names only public API and configuration keys; the property defaults it lists match GspAutoConfiguration. The toc.yml entry sits under the GSP section next to resources and makingChangesToADeployedApplication, and the ==== heading level matches those siblings.
  • README rewrite is accurate to the module.

Process

Unchanged from round one: whether this lands on 8.0.x before RC1 is still open in the thread.

The AOP feature removes the Grails creators from APC_PRIORITY_LIST, the registrar
under test adds them back, and cleanup then added them all again - so every run
left a second copy of both classes in a list shared by the whole fork, contrary
to what the helper says it does. Cleanup now restores only a creator that is
missing, which also still restores them if the code under test did not.

Found by Mattias Reichel in review of apache#16184.
The case pinned what captureTagContent writes when a tag library is built with no
codec lookup, which turned the absence of a guard into specified behaviour. The
lookup is a required dependency, so a context without one fails to start, and
StandaloneContextTest already fails if the field is not wired - the stronger
contract. The encoded-output case stays.

Found by Mattias Reichel in review of apache#16184.
The page showed the asset pipeline's AssetsTagLib being declared as a bean with a
constructor it does not have, and taking an AssetProcessorService that is a
service artefact of a plugin, so an application with no plugin lifecycle has no
bean of it to give. Nothing covered it either: the example serves its assets
through the asset pipeline's Spring Boot module and does not use that tag
library. The example is now a @grails.gsp.TagLib class declared as a bean, the
shape the lookup's specifications test, with a sentence saying a plugin's tag
library may need its collaborators declared too. The TIP names what the example
application does demonstrate.

The build.gradle listing gains subs="attributes+", so {version} is substituted
and the callouts still render, and the Grails GSP plugin id gets its version,
without which it does not resolve in a plain Spring Boot build.

Found by Mattias Reichel in review of apache#16184.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei Addressed in 16eb980..76bfb3b.

Tag library example (76bfb3b). Replaced with a @grails.gsp.TagLib class declared as a @Bean, the shape StandaloneTagLibraryLookupSpec tests. The @Artefact("TagLib") sentence stays, followed by one noting that a plugin's tag library may need its collaborators declared as beans. The TIP now names what the example demonstrates - precompiled views, SiteMesh layouts, assets compiled by the asset pipeline, and a JSP alongside GSP - and no longer implies the tag library section.

Rendering (76bfb3b). The build.gradle listing is [source,groovy,subs="attributes+"] and the plugin id carries version '{version}'. In the built guide both lines render 8.0.0-SNAPSHOT and the callouts are intact.

CoreGrailsPluginRegistrarSpec (0327078). Cleanup restores only a creator that is missing, as suggested.

Nit (ec9a9c4). The unencoded-output case is removed. The encoded-output case stays, and StandaloneContextTest still fails if the field is not wired.

codeconsole added a commit to codeconsole/asset-pipeline that referenced this pull request Sep 13, 2026
The Grails compiler already marks both with @ArteFact("TagLib"), which is how a
Grails application finds them. The annotation was added so GSP's standalone lookup
would find them too, and apache/grails-core#16184 teaches that lookup to read the
artefact marker as well - naming the asset pipeline's library as the case.

Against the 8.0.0-M6 this build pins, that lookup still reads only @taglib, so the
specification that resolves the tag libraries through it is pending on #16184, and
alerts once it starts passing. Nothing that works on M6 is lost: standalone GSP
does not start there without the UrlMappingsHolder #16184 also contributes.

The url case takes the tag library from the context instead of the lookup, so the
link generator it guards is still covered on M6.
@matrei

matrei commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Follow-up on 76bfb3b, found while running the example with bootRun: the error page of a standalone application does not render.

Bug: any error or 404 in a standalone application ends in Circular view path [error]

Open http://localhost:8080/error or any unmapped path in a browser and the response is Tomcat's own 500/404 page, with this in the log:

jakarta.servlet.ServletException: Circular view path [error]: would dispatch back to the current handler URL [/error] again. Check your ViewResolver setup!

Reproduced in the example with a @SpringBootTest on a random port sending Accept: text/html (a client sending no Accept gets Boot's JSON error body, which is why the example's tests do not see it). The view resolvers in the example's context, in the order the dispatcher consults them:

bean class order
gspViewResolver GrailsSiteMeshViewResolver LOWEST_PRECEDENCE - 20
beanNameViewResolver BeanNameViewResolver LOWEST_PRECEDENCE - 10
mvcViewResolver, defaultViewResolver ViewResolverComposite, InternalResourceViewResolver LOWEST_PRECEDENCE

BasicErrorController.errorHtml falls back to the view name error, which Boot serves from its whitelabel error bean through BeanNameViewResolver. The GSP resolver is asked first. There is no error.gsp, and spring.gsp.jspEnabled defaults to true (GspAutoConfiguration.java:108, applied at line 290), so GroovyPageViewResolver.createFallbackView (GroovyPageViewResolver.java:258-273) returns a JSTL view for /error without checking that any such JSP exists. That view forwards to /error, the URL being handled, and the servlet container rejects the loop. Boot's whitelabel page is never reached.

GroovyPageViewResolver itself is untouched by this PR (grails-web-gsp has no diff against 8.0.x); what the PR changes is that the standalone module now starts, so this is the first time the path is reachable. A Grails application never hits it because its errors are routed by UrlMappings, not by BasicErrorController.

Two things I checked before suggesting a fix:

  • spring.gsp.jspEnabled=false makes /error and 404s render the whitelabel page, and form.jsp still renders through Boot's defaultViewResolver - but undecorated: the <meta name="layout" content="main"/> in form.jsp is honoured only when the JSP is served through the GSP resolver's fallback, which the SiteMesh view resolver wraps. So flipping the default is not free for the example's JSP demonstration.
  • Adding templates/error.gsp to the example would make the page render, but only hides the module defect: every other standalone application without one gets the container's raw error page instead of Boot's.

Suggested fix, in the module: have the JSP fallback resolve only a JSP that exists. createJstlView can ask servletContext.getResource(url) (the example's JspSupport.canServeJsp does exactly this) and return null otherwise, which lets resolution continue to BeanNameViewResolver and the whitelabel error bean, or to Boot's defaultViewResolver for a real JSP. That keeps JSP decoration in the example and fixes the error page for every consumer. A test in the example - GET /does-not-exist with Accept: text/html is a 404 whose body is the whitelabel page (or an error.gsp if you add one to show it off) - would pin it.

A longer-term item, not for this PR: Boot resolves error/404, error/5xx templates through TemplateAvailabilityProviders, and there is none for .gsp, so a standalone application cannot use per-status GSP error pages the way a Thymeleaf one can.

Any error or 404 a browser asked for ended in the container's own error page,
with "Circular view path [error]" in the log. Boot renders its error page through
the view named error, and the GSP view resolver is asked first. With no error.gsp
and JSP views on, its fallback returned a JSTL view for error without checking
that any such JSP exists; that view forwarded to /error, the URL being handled,
and the container refused the loop. A client sending no Accept header gets Boot's
JSON body instead, which is why no test saw it.

The resolver GspAutoConfiguration contributes now falls back to a JSP only where
the servlet context has one, and otherwise answers nothing, leaving the name to
the resolvers after it - Boot's error page among them. A real JSP resolves as
before, so the example's JSP rendering is still decorated by its layout.

The check lives in a resolver used only by the standalone module. The shared
GroovyPageViewResolver is unchanged, so a Grails application with JSTL present -
where its errors are routed by URL mappings, never through Boot's error
controller - keeps the fallback it has, including for a JSP that exists only as a
precompiled servlet mapping with no resource behind it.

ErrorPageTest in the example requests an unmapped path with Accept: text/html and
gets Boot's page with a 404; StandaloneGroovyPageViewResolverSpec covers both
branches through resolveViewName. Both fail with the check reverted. The guide
page lists spring.gsp.jspEnabled with what it now does.

Found by Mattias Reichel in review of apache#16184.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei Fixed in 5f11919.

Reproduced with Accept: text/html on an unmapped path: Tomcat's 404 page, and Circular view path [error] in the log.

Fix. GspAutoConfiguration now contributes a StandaloneGroovyPageViewResolver whose createJstlView returns null unless servletContext.getResource finds the JSP, so error falls through to BeanNameViewResolver and the whitelabel page. The check is kept out of the shared GroovyPageViewResolver: GroovyPagesGrailsPlugin turns the same fallback on for every Grails application with JSTL present, and a Grails application never reaches this path, so its fallback is left as it was. A JSP that exists resolves as before, so form.jsp is still decorated by its layout (JspViewTest passes).

Tests. ErrorPageTest in the example: GET /does-not-exist with Accept: text/html is a 404 whose body is the whitelabel page. StandaloneGroovyPageViewResolverSpec covers both branches through resolveViewName. Both fail with the check reverted.

Docs. The guide page's configuration table lists spring.gsp.jspEnabled and what it now does.

Per-status GSP error pages through a TemplateAvailabilityProvider are left out of this PR, as suggested.

@testlens-app

This comment has been minimized.

@matrei matrei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fifth round, on 2033f0e against 8.0.x (merge base 3067d0a is the current tip of 8.0.x, so the branch is clean). One commit since round four, 5f11919, which is the fix for the error-page defect from that round. CI on the head is green: 89 checks passed, 4 skipped (publish, docs, forge, wrapper).

Ran :grails-gsp-spring-boot:test, :grails-gsp-spring-boot:codeStyle and the gsp-spring-boot example's tests. All pass. Approving.

Verified

  • The fix (StandaloneGroovyPageViewResolver): the JSP fallback is kept in a subclass the standalone module alone constructs; GroovyPageViewResolver and grails-web-gsp are untouched, so a Grails application keeps the fallback it had. The override calls the base createJstlView, asks the servlet context for the view's URL (leading slash added where missing, MalformedURLException answered with "no"), and returns null when nothing is there. With setCache(false) on the base resolver, a null from loadView is "unresolved" to the dispatcher and the next resolver is asked - BeanNameViewResolver and Boot's whitelabel error bean, as the round-four table showed.
  • Both tests are load-bearing. With the existence check disabled, StandaloneGroovyPageViewResolverSpec fails on the error case (a view comes back where null is expected) and ErrorPageTest fails with Tomcat's own HTTP Status 404 – Not Found body in place of the whitelabel page. Restored afterwards; the tree is clean.
  • JSP decoration survives: JspViewTest still gets the form.jsp rendering through the GSP resolver's fallback, wrapped by the SiteMesh layout, because /form.jsp is a real servlet-context resource in the example.
  • The spec tests through the public surface: resolveViewName on a resolver built from the auto-configuration's public groovyPageLocator bean method, with a MockServletContext over a @TempDir as the document root and the context closed in cleanup. Same construction pattern as GspAutoConfigurationSpec.
  • Docs: the new spring.gsp.jspEnabled row in the configuration table matches the default in GspAutoConfiguration and describes the new behaviour without naming an internal class. The module README needs no change; the resolver's behaviour is described on the guide page.
  • Style: codeStyle on the module is clean; the new Java class carries the license header and explicit imports.

Nit, not blocking

StandaloneGroovyPageViewResolver.servletContext() re-derives what WebApplicationObjectSupport.getServletContext() already provides, and the base resolver already relies on that accessor to build every GSP view (GroovyPageViewResolver.java:221). Its two null branches are unreachable in practice, since the resolver cannot render anything without a web context. getServletContext() in place of the helper would drop a dozen lines.

Observation, no change needed

A miss is cached like a hit: CacheEntry.setValue(null) marks the entry initialised, so a name with neither a template nor a JSP is remembered for spring.gsp.view.cacheTimeout while reloading is on, and for the life of the context when it is off. That is the same treatment a missing GSP always had, and it keeps the error lookup cheap, so it is the right trade; noting it only so the next reader does not expect a JSP dropped into a running production deployment to be picked up.

Process

Unchanged: whether this lands on 8.0.x before RC1 is a release-management call still open in the thread, not something in the diff.

@codeconsole

codeconsole commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@matrei @jdaugherty Addressed in bf09ab4.

The upgrade note now distinguishes Grails 7 from the earlier Grails 8 milestones: a custom @SpringBootTest(classes = SomeConfig) without the application class did not receive the Grails plugin lifecycle in Grails 7. It also explains automatic application-class discovery, the existing Hibernate startup limitation, and how to apply the startup defaults using useMainMethod = ALWAYS or the two Spring Boot properties.

Also addressed the non-blocking nit by replacing the duplicate servlet-context helper with Spring's inherited getServletContext(). Expanded coverage for JSP paths with and without a leading slash, missing views, disabled JSP fallback, and JSP/error-page rendering under a non-root context path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants