Skip to content

Apply SiteMesh 3 decoration at the bean-definition level#15964

Merged
jdaugherty merged 13 commits into
apache:8.0.xfrom
codeconsole:fix/sitemesh3-viewresolver-definition-wrap
Jul 11, 2026
Merged

Apply SiteMesh 3 decoration at the bean-definition level#15964
jdaugherty merged 13 commits into
apache:8.0.xfrom
codeconsole:fix/sitemesh3-viewresolver-definition-wrap

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

Summary

Since #13863 stopped auto-adding @EnableWebMvc, Spring Boot's WebMvcAutoConfiguration is active for Grails servlet apps and registers a ContentNegotiatingViewResolver as the highest-precedence viewResolver. While initializing, CNVR collects every ViewResolver bean via getBeansOfType, which force-creates the lazy jspViewResolver. If that happens before the SiteMesh 3 BeanPostProcessor wrap (#15585) is in effect, CNVR captures the raw, non-decorating resolver and keeps rendering HTML through it — GSP layouts silently stop applying. Whether the race is hit depends on bean-creation order, so it varies with the plugin set: some apps on 8.0.0-M3 decorate fine, others lose all layouts.

Diagnosis (reproduced on 8.0.0-M3)

In an affected app:

  • jspViewResolver bean = GrailsSiteMeshViewResolver@55871443 wrapping ScaffoldingViewResolver@344207224 — the wrap itself worked.
  • CNVR's delegate list contained ScaffoldingViewResolver@344207224the same instance as the wrapper's inner, captured pre-wrap.
  • Requests with Accept: text/html rendered undecorated (CNVR short-circuits); requests with an Accept header CNVR cannot satisfy fell through to the wrapped resolver and rendered fully decorated.

Fix

  • Sitemesh3ViewResolverDefinitionPostProcessor (registered by Sitemesh3AutoConfiguration) rewrites the jspViewResolver bean definition into a GrailsSiteMeshViewResolver with the original definition embedded as the inner bean — mirroring the SiteMesh 2 module's GrailsLayoutViewResolverPostProcessor. The decorating resolver is what gets instantiated no matter how early a consumer forces the bean into existence, so the race is structurally impossible. It skips contexts where decoration is impossible (no contentProcessor/decoratorSelector, e.g. grails-testing-support unit-test contexts) and definitions that already decorate (the SiteMesh 2 layout resolver, or an existing SiteMeshViewResolver).
  • The existing bean post-processor is retained as a safety net for instance-registered resolvers and to keep upstream's wrap-all post-processor from registering; upstream never re-wraps a SiteMeshViewResolver, so the two mechanisms cannot double-decorate.

Modernization (builds on #15934)

Sitemesh3GrailsPlugin no longer declares doWithSpring() at all:

  • The SiteMesh property defaults moved to Sitemesh3EnvironmentPostProcessor (META-INF/spring.factories), contributed with lowest precedence before refresh — which also removes the post-hoc PropertySourcesConfig reassignment the plugin used to perform.
  • grailsRenderViewMutator is registered through beanRegistrar(), making this the first in-tree plugin on the modern registration API.

Testing

  • New Sitemesh3ViewResolverDefinitionPostProcessorSpec (9 tests) including a regression test that simulates CNVR's early getBeansOfType(ViewResolver) sweep against a registry with no bean post-processors at all and asserts the captured instance decorates.
  • New Sitemesh3EnvironmentPostProcessorSpec (7 tests) covering defaults, application-set keys winning, and the SiteMesh 2 default-layout fallback.
  • :grails-sitemesh3:test and :grails-layout:test pass.
  • Verified end-to-end against a previously affected application on 8.0.0-M3: with only the patched grails-sitemesh3 jar swapped in, both a (view:) URL mapping and a controller-rendered page produce fully decorated output, byte-identical through the CNVR path and the fall-through path.

Spring Boot's ContentNegotiatingViewResolver — active for Grails servlet
apps since @EnableWebMvc stopped being auto-added (apache#13863) — collects
every ViewResolver bean while it initializes, force-creating the lazy
jspViewResolver. When that happens before the SiteMesh
BeanPostProcessor wrap is in effect, the raw resolver is captured and,
sitting behind the highest-precedence viewResolver, renders every HTML
request undecorated: layouts silently stop applying. Whether the race is
hit depends on bean-creation order, so it varies with the plugin set from
app to app.

Sitemesh3ViewResolverDefinitionPostProcessor now rewrites the
jspViewResolver bean definition itself into a GrailsSiteMeshViewResolver
with the original definition embedded as the inner bean — mirroring the
SiteMesh 2 module's GrailsLayoutViewResolverPostProcessor — so the
decorating resolver is what gets instantiated no matter how early a
consumer forces the bean into existence. The bean post-processor is kept
as a safety net for instance-registered resolvers and to keep upstream's
wrap-all post-processor from registering; upstream never re-wraps a
SiteMeshViewResolver, so the mechanisms cannot double-decorate.

The plugin's doWithSpring() is removed outright: the SiteMesh property
defaults now come from a Sitemesh3EnvironmentPostProcessor registered in
spring.factories (making the post-hoc PropertySourcesConfig reassignment
unnecessary), and grailsRenderViewMutator is registered through
beanRegistrar(), the modern replacement for the bean-builder DSL.
With doWithSpring() gone there is no bean-builder closure left — the one
construct whose dynamic dispatch prevents @CompileStatic on descriptor
classes — so the whole class can be statically compiled, rather than
annotating individual methods the way GroovyPagesGrailsPlugin must.
The description implied the plugin replaces SiteMesh 2, but the two
integrations coexist — when grails-layout is present its decoration wins
and SiteMesh 3 stands down. Also drop the registration-site comment that
duplicated Sitemesh3RenderViewMutator's own javadoc.
The declared BeanRegistrar return type already coerces the returned
closure; the 'as BeanRegistrar' was redundant.
…n place

The upstream post-processor warns at startup when it wrapped nothing,
but with decoration applied at the bean-definition level a zero-wrap
startup is the expected healthy state. Answer the check from the target
bean's definition type so the lazy resolver is not forced into existence.
Sitemesh3ViewResolverDefinitionPostProcessor now extends upstream's
SiteMeshViewResolverPostProcessor - the class behind
sitemesh.viewResolver.wrapMode=bean-definition - and is registered under
upstream's bean name, so upstream's own @ConditionalOnMissingBean guard
defers to it. Together with the bean post-processor (which preempts the
wrap-all and bean-instance registrations by type), every upstream wrap
mode is covered by a Grails-flavoured implementation and the zero-wrap
startup warning cannot arise from a dangling mode.

The Grails implementation deliberately diverges from upstream on one
point: upstream re-registers the unwrapped resolver as a separate named
bean that stays visible to getBeansOfType sweeps - the same exposure
this PR closes - so the original definition is embedded as an anonymous
inner-bean definition instead. The wrapper definition now also honours
the inherited dispatchMode and includeErrorPages settings.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Design refinement after field testing: the definition post-processor now extends upstream's SiteMeshViewResolverPostProcessor (the class behind sitemesh.viewResolver.wrapMode=bean-definition) and registers under upstream's bean name, so every upstream wrap mode is now preempted through upstream's own @ConditionalOnMissingBean contract — bean-definition by the definition post-processor, all/bean-instance by the bean post-processor (now the explicit fallback tier). This also resolves a spurious startup warning ("SiteMesh did not wrap the target ViewResolver bean 'jspViewResolver'") that the upstream base class logs when the instance-level tier wraps nothing, which is the expected state when the definition-level wrap is active.

One deliberate divergence from upstream's bean-definition implementation, documented in the class javadoc: upstream re-registers the unwrapped resolver as a separate named bean that remains visible to getBeansOfType(ViewResolver) sweeps — the same exposure this PR closes — so the Grails implementation embeds the original definition as an anonymous inner-bean definition instead. That may be worth an upstream sitemesh issue in its own right.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.85542% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.6126%. Comparing base (81aa7c2) to head (9513a71).
⚠️ Report is 25 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...itemesh3ViewResolverDefinitionPostProcessor.groovy 53.3333% 11 Missing and 3 partials ⚠️
...GrailsSiteMeshViewResolverBeanPostProcessor.groovy 66.6667% 3 Missing and 1 partial ⚠️
...lugins/sitemesh3/Sitemesh3AutoConfiguration.groovy 81.2500% 0 Missing and 3 partials ⚠️
...sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy 84.2105% 2 Missing and 1 partial ⚠️
...ils/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy 0.0000% 3 Missing ⚠️
...ain/java/grails/gsp/boot/GspAutoConfiguration.java 0.0000% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15964        +/-   ##
==================================================
+ Coverage     49.6092%   49.6126%   +0.0034%     
- Complexity      16955      16977        +22     
==================================================
  Files            1999       2001         +2     
  Lines           93791      93835        +44     
  Branches        16421      16434        +13     
==================================================
+ Hits            46529      46554        +25     
- Misses          40090      40110        +20     
+ Partials         7172       7171         -1     
Files with missing lines Coverage Δ
...lugins/sitemesh3/Sitemesh3AutoConfiguration.groovy 81.2500% <81.2500%> (ø)
...sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy 84.2105% <84.2105%> (ø)
...ils/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy 0.0000% <0.0000%> (ø)
...ain/java/grails/gsp/boot/GspAutoConfiguration.java 0.0000% <0.0000%> (ø)
...GrailsSiteMeshViewResolverBeanPostProcessor.groovy 60.0000% <66.6667%> (ø)
...itemesh3ViewResolverDefinitionPostProcessor.groovy 53.3333% <53.3333%> (ø)

... and 5 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.

grails-gsp-spring-boot applied the SiteMesh 3 property defaults through
Sitemesh3GrailsPlugin.getDefaultPropertySource, which moved to
Sitemesh3EnvironmentPostProcessor with an environment-only signature.
The method is now public for this consumer, which covers plain Spring
Boot GSP contexts built without SpringApplication where no
EnvironmentPostProcessor runs. The explicit null default layout the old
call passed is replaced by reading the configured layout keys from the
environment, matching the Grails path.

@jdaugherty jdaugherty 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.

Have you tested this with sitemesh2? We still technically support it via grails-layout and the bean wiring now occurs at a different level.

* itself, and the source is appended with lowest precedence, so application
* configuration always wins.</p>
*/
public class Sitemesh3EnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {

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.

Why not use groovy?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deliberate: this module keeps its Spring infrastructure in Java (CaptureAwareContentProcessor, Sitemesh3LayoutFinder, GrailsSiteMeshViewResolver are all Java), these classes have no Groovy idiom to benefit from, and Java puts them under checkstyle. As a bonus it avoids the grails.util.Environment / Spring Environment name clash that forces fully-qualified references in Groovy.

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.

The groovy idiom is the code is easier to read because of property references etc

grails-sitemesh3 is a drop-in replacement for grails-layout - the two
modules are never installed together - yet the integration carried
SiteMesh 2 accommodation logic: the ApplicationListener exclusions in
both post-processors existed to detect and stand down from the SiteMesh
2 layout resolver, and the plugin javadoc promised coexistence semantics
that bean-registrar ordering had in fact already broken. All of that is
removed. Sitemesh3EnvironmentPostProcessor now fails startup with a
clear message when grails-layout is detected on the classpath, making
the mutual-exclusion contract explicit instead of cooperative.

The already-decorating check now resolves classes with the bean
factory's bean class loader (falling back to the default loader) rather
than the loader of the grails-sitemesh3 jar, so an application-defined
SiteMeshViewResolver subclass living in a child or restart class loader
(e.g. devtools) is recognised instead of being double-wrapped.

The legacy grails.views.layout.default fallback is retained - honouring
existing configuration is what makes the replacement drop-in.
@codeconsole codeconsole requested a review from jdaugherty July 11, 2026 00:27
The fail-fast on grails-layout co-presence broke real configurations:
grails-sitemesh3 arrives transitively through
grails-dependencies-starter-web, so an application that declares
grails-layout - the standard state for SiteMesh 2 applications upgrading
to Grails 8, and half a dozen apps in grails-test-examples - ends up
with both on the classpath and failed startup.

The modules remain mutually exclusive by contract, but co-presence now
degrades the way those applications already relied on: the SiteMesh 2
integration keeps decorating and this module's machinery stands down -
one classpath check gating the definition post-processor, the bean
post-processor and the render-view mutator registration (whose registrar
bean would otherwise displace SiteMesh 2's via name-conflict precedence).
Sitemesh3EnvironmentPostProcessor warns loudly with the exclusion
instructions, and the tolerance is documented as subject to removal.

Verified against grails-test-examples-spring-dependency-management (the
combined-classpath app that failed CI) and grails-test-examples-mail.
@codeconsole

Copy link
Copy Markdown
Contributor Author

The functional-test failures were caused by the mutual-exclusion fail-fast from ba2c2ec: it turns out a combined classpath is not a freak misconfiguration but the standard state for SiteMesh 2 applications — grails-sitemesh3 arrives transitively via grails-dependencies-starter-web, so declaring grails-layout puts both on the classpath (as half a dozen apps in grails-test-examples do, e.g. spring-dependency-management).

32bb997 keeps the mutual-exclusion contract but tolerates co-presence the way those applications already relied on: SiteMesh 2 keeps decorating and this module stands down — one isSiteMesh2Present() classpath check gating the definition post-processor, the bean post-processor, and the grailsRenderViewMutator registrar registration (which would otherwise displace SM2's mutator via registrar name-conflict precedence, the regression flagged earlier). Sitemesh3EnvironmentPostProcessor warns loudly with exclusion instructions, and the tolerance is documented as subject to removal.

Escalating to a hard fail-fast would need the in-tree apps fixed (exclude grails-sitemesh3 from the starter) and is really a separate policy decision — happy to prepare that as a follow-up if that's the direction you want.

Verified locally: :grails-sitemesh3 suite + checkstyle/codenarc, whole-repo compile, and --rerun-tasks integration tests for spring-dependency-management (the failing app), mail (also combined-classpath), app1, and mongodb-base.

SiteMesh 3.3.0-M2 absorbed the mechanisms this branch had to carry
itself, so the Grails layer shrinks to its genuinely Grails-specific
parts:

- Sitemesh3ViewResolverDefinitionPostProcessor delegates the rewrite to
  the upstream implementation, which now embeds the original definition
  as an anonymous inner bean and skips already-decorating targets with
  the bean class loader. The subclass keeps only the Grails guards
  (SiteMesh 2 stand-down, test-context beans, silent missing target), a
  factory-method return-type check the upstream guard cannot see, and
  preservation of the target's lazy-init flag.
- The zero-wrap warning override reduces to the SiteMesh 2 branch;
  upstream now recognises definition-level decoration itself. The
  warning specs move with the behaviour (upstream carries its own),
  which also retires the JUL capture that 3.3.0-M2's switch to Commons
  Logging would have broken.
- The upgrade notes drop the NoopSitemeshFilter placeholder, which
  3.3.0-M2 removed - the integrations are mutually exclusive by
  configuration and the 'Filter sitemesh was not registered (disabled)'
  startup line is gone.

Verified against 3.3.0-M2: module tests, checkstyle, codenarc,
whole-repo compile, and rerun integration tests for app1,
spring-dependency-management (combined SiteMesh 2/3 classpath) and
mongodb-base.
@testlens-app

This comment has been minimized.

@jdaugherty jdaugherty merged commit d3d445d into apache:8.0.x Jul 11, 2026
56 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants