Skip to content

Stop @EnableWebMvc from being automatically added to Grails Applications#13863

Merged
codeconsole merged 16 commits into
apache:8.0.xfrom
codeconsole:7.0.x-remove-EnableWebMvc
Jul 9, 2026
Merged

Stop @EnableWebMvc from being automatically added to Grails Applications#13863
codeconsole merged 16 commits into
apache:8.0.xfrom
codeconsole:7.0.x-remove-EnableWebMvc

Conversation

@codeconsole

@codeconsole codeconsole commented Nov 20, 2024

Copy link
Copy Markdown
Contributor

Fixes #13843

This produces unknown/unexpected behavior and should not be done. @EnableWebMvc is a Spring Framework configuration annotation not to be used with Spring Boot. It produces undesired behavior by automatically creating beans that are not wanted. WebMvcAutoConfiguration performs all the behavior of this configuration because it extends it and adds conditional logic.

Fixes

Overriding bean definition for bean 'localeResolver' with a different definition: replacing [Root bean: class=null; scope=; abstract=false; lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration; factoryMethodName=localeResolver; initMethodNames=null; destroyMethodNames=[(inferred)]; defined in org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration] with [Generic bean: class=org.springframework.web.servlet.i18n.SessionLocaleResolver; scope=singleton; abstract=false; lazyInit=false; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=null; factoryMethodName=null; initMethodNames=null; destroyMethodNames=null]
Overriding bean definition for bean 'org.springframework.aop.config.internalAutoProxyCreator' with a different definition: replacing [Root bean: class=org.springframework.aop.framework.autoproxy.InfrastructureAdvisorAutoProxyCreator; scope=; abstract=false; lazyInit=null; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=null; factoryMethodName=null; initMethodNames=null; destroyMethodNames=null] with [Generic bean: class=org.grails.spring.aop.autoproxy.GroovyAwareInfrastructureAdvisorAutoProxyCreator; scope=singleton; abstract=false; lazyInit=false; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=null; factoryMethodName=null; initMethodNames=null; destroyMethodNames=null]
class WebMvcAutoConfiguration {
    static class EnableWebMvcConfiguration extends DelegatingWebMvcConfiguration { 
    } 
}

class DelegatingWebMvcConfiguration extends WebMvcConfigurationSupport {
}

class WebMvcConfigurationSupport extends WebMvcConfigurationSupport {
    @Bean public LocaleResolver localeResolver() { }
}

vs

@Import(DelegatingWebMvcConfiguration.class)
public @interface EnableWebMvc { }

With @EnableWebMvc gone, Boot's WebMvcAutoConfiguration is now active for Grails servlet web apps. This PR adapts Grails so the change is transparent:

  1. Remove the auto-injected @EnableWebMvc (ApplicationClassInjector).
  2. Default view resolver — Boot now contributes a catch-all defaultViewResolver that breaks REST/JSON-views apps with Circular view path errors. New GrailsViewResolverAutoConfiguration removes it centrally for every Grails servlet web app, controlled by grails.web.removeDefaultViewResolverBean (default true). This replaces the GSP-only spring.gsp.removeDefaultViewResolverBean; spring.gsp.replaceViewResolverBean is unchanged.
  3. Request bindingGrailsWebRequestFilter now extends RequestContextFilter and is exposed as a RequestContextFilter bean so Boot's WebMvcAutoConfiguration backs off its own OrderedRequestContextFilter, which would otherwise rebind a plain ServletRequestAttributes between Grails' filter and the Spring Security chain and cause a GrailsWebRequest ClassCastException.

Docs — upgrade-notes section "27. @EnableWebMvc No Longer Added to Grails Applications" in upgrading80x.adoc.

TestsControllersAutoConfigurationSpec verifies the filter is exposed as a RequestContextFilter at the Grails request-filter order, and that Boot's requestContextFilter actually backs off (with a contrast case proving it's present without the Grails auto-config).

Backward Compatibility

For legacy apps dependent on previous configuration, restoration is possible by simply adding @EnableWebMvc manually to Application.groovy

import org.springframework.web.servlet.config.annotation.EnableWebMvc;

@EnableWebMvc
class Application extends GrailsAutoConfiguration {
// ...

@codeconsole

Copy link
Copy Markdown
Contributor Author

#13843

@jeffscottbrown

Copy link
Copy Markdown
Contributor

This pull request is not finished

I have converted this PR to Draft for now.

@jdaugherty

Copy link
Copy Markdown
Contributor

This is now been here for almost a year and half with no progress. I'm going to close the PR for now - please reopen when you feel it's ready for review.

@jdaugherty jdaugherty closed this May 4, 2026
@codeconsole codeconsole reopened this Jun 22, 2026
@codeconsole codeconsole changed the base branch from 7.0.x to 8.0.x June 22, 2026 06:39
@apache apache deleted a comment from matrei Jun 22, 2026
@apache apache deleted a comment from matrei Jun 22, 2026
@codeconsole codeconsole force-pushed the 7.0.x-remove-EnableWebMvc branch from 809b7f6 to b05894e Compare June 22, 2026 07:02
@codeconsole codeconsole marked this pull request as ready for review June 22, 2026 23:09
@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 60.00000% with 36 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.3636%. Comparing base (05f71e6) to head (4e458f6).

Files with missing lines Patch % Lines
...lugins/testing/GrailsMockHttpServletRequest.groovy 15.3846% 20 Missing and 2 partials ⚠️
...ns/i18n/GrailsLocaleResolverAutoConfiguration.java 66.6667% 4 Missing and 3 partials ⚠️
...org/grails/plugins/i18n/I18nAutoConfiguration.java 76.4706% 1 Missing and 3 partials ⚠️
...web/i18n/ParamsAwareLocaleChangeInterceptor.groovy 0.0000% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@               Coverage Diff                @@
##             8.0.x     #13863         +/-   ##
================================================
+ Coverage         0   49.3636%   +49.3636%     
- Complexity       0      16789      +16789     
================================================
  Files            0       1990       +1990     
  Lines            0      93413      +93413     
  Branches         0      16351      +16351     
================================================
+ Hits             0      46112      +46112     
- Misses           0      40174      +40174     
- Partials         0       7127       +7127     
Files with missing lines Coverage Δ
.../web/controllers/ControllersAutoConfiguration.java 94.6429% <ø> (ø)
...lers/GrailsFormContentFilterAutoConfiguration.java 100.0000% <100.0000%> (ø)
...rs/GrailsWebResourcesEnvironmentPostProcessor.java 100.0000% <100.0000%> (ø)
...ontrollers/GrailsWelcomePageAutoConfiguration.java 100.0000% <100.0000%> (ø)
...core/src/main/groovy/grails/config/Settings.groovy 100.0000% <ø> (ø)
...ovy/grails/web/servlet/mvc/GrailsParameterMap.java 74.1007% <ø> (ø)
...web/i18n/ParamsAwareLocaleChangeInterceptor.groovy 34.6154% <0.0000%> (ø)
...org/grails/plugins/i18n/I18nAutoConfiguration.java 75.0000% <76.4706%> (ø)
...ns/i18n/GrailsLocaleResolverAutoConfiguration.java 66.6667% <66.6667%> (ø)
...lugins/testing/GrailsMockHttpServletRequest.groovy 10.1852% <15.3846%> (ø)

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

codeconsole added a commit to codeconsole/grails-core that referenced this pull request Jun 23, 2026
Addresses the base (apache#13863) review gaps:
- GrailsViewResolverAutoConfiguration now honours the legacy spring.gsp.removeDefaultViewResolverBean
  property (which exists in apache/8.0.x) as a deprecated fallback for grails.web.removeDefaultViewResolverBean,
  logging a deprecation warning when the old name is used — instead of silently ignoring it.
- Adds upgrading80x.adoc section 27 documenting that Boot's WebMvcAutoConfiguration is now active
  (no auto-injected @EnableWebMvc), what Grails still owns, the defaultViewResolver removal + opt-out
  flag, and that no action is required for typical apps.

The JSON-views path the removal is justified by is already covered by the views-functional-tests
example app (JSON/HAL render assertions + CircularSpec for the circular-view-path scenario), which
passes on this branch — verified by running :grails-test-examples-views-functional-tests:integrationTest.

Note: these harden content from base PR apache#13863; if apache#13863 merges standalone, cherry-pick them there.

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

I am not sure, I understand the full impact of this PR; but the code looks sound and if tests passes, I'm good.

I would however, like to see it documented in the upgrade notes, so that users can figure out, why their application might fail, if that would ever happen?

@codeconsole

Copy link
Copy Markdown
Contributor Author

@sbglasius thanks for the review — I've documented this so users can see why an app might be affected and how to control it:

  • Upgrade notes: new section "27. @EnableWebMvc No Longer Added to Grails Applications" in grails-doc (upgrading80x.adoc). It covers the two user-visible behaviors and their opt-outs — the Circular view path symptom from Boot's defaultViewResolver (grails.web.removeDefaultViewResolverBean, default true) and the GrailsWebRequest binding.
  • PR description: updated to spell out the full scope (the defaultViewResolver consolidation and the GrailsWebRequestFilterRequestContextFilter change), since the original only mentioned the @EnableWebMvc removal — which I think is what made the impact hard to gauge.
  • Tests: added ControllersAutoConfigurationSpec, which proves Boot's requestContextFilter actually backs off (with a contrast case showing it's present without the Grails auto-config), so the binding behavior is regression-guarded.

@codeconsole codeconsole requested a review from sbglasius June 24, 2026 18:32
@codeconsole codeconsole marked this pull request as draft June 24, 2026 19:25
@codeconsole

Copy link
Copy Markdown
Contributor Author

Thanks @jdaugherty (and Fabled) — both are spot on, and I've updated this PR to address them.

Quick context on the diff: the original @EnableWebMvc removal already landed on 8.0.x when #15916 merged this branch, so the diff GitHub renders here (computed against the old merge base) overstates what's actually new. I've merged current 8.0.x back in and added the two follow-ups on top, so the real delta this PR now carries is just those fixes.

1. Undocumented WebMvcAutoConfiguration side effects — documented. Added a new "31.3 Other Boot MVC features now active" subsection to the upgrade notes covering:

  • OrderedFormContentFilter (default-on via spring.mvc.formcontent.filter.enabled) — PUT/PATCH/DELETE form-encoded bodies now surface as request parameters.
  • spring.mvc.* / spring.web.* properties now taking effect (spring.web.locale, spring.mvc.format.*, spring.web.resources.*, …).
  • Boot's /** static-resource handler + WelcomePageHandlerMapping, and their effect on 404 / catch-all URL-mapping behavior (with the spring.web.resources.add-mappings opt-out).
  • viewResolver now ContentNegotiatingViewResolver for non-GSP apps (GSP unchanged).
  • mvcConversionService now ApplicationConversionService, plus Boot's message-converter customization.

2. grailsWebRequest double-registration — fixed. Gated the raw grailsWebRequest bean on @ConditionalOnMissingBean(value = GrailsWebRequestFilter.class, name = "grailsWebRequestFilter"). When an application overrides only the registration bean, the raw filter now backs off with it rather than lingering unwrapped for Boot to auto-register on /* at LOWEST_PRECEDENCE. In the default case the raw bean stays a top-level RequestContextFilter, so Boot's own RequestContextFilter still backs off exactly as before.

And you're right that the old override test only checked bean identity — I reworked it to assemble the real servlet chain via ServletContextInitializerBeans and assert against it: the override case verifies no stray GrailsWebRequestFilter lands on the chain, and the default case verifies exactly one does. Both new assertions fail without the gating fix (confirmed by reverting it).

- Remove the empty configuration closure and comment on the spring-test
  compileOnly dependency
- Condense the grailsWebRequest bean comment to the two essential points
  (RequestContextFilter back-off and the registration-bean-name gate)
@codeconsole

Copy link
Copy Markdown
Contributor Author

Acknowledged on sequencing. Worth noting the approach here registers Grails' MVC beans ahead of WebMvcAutoConfiguration and lets Boot's @ConditionalOnMissingBean-guarded beans defer to them, rather than disabling WebMvcAutoConfiguration — so it's designed to coexist with code that relies on it being active, not to break it. Happy to verify this branch against the security project before either merges and order them however you'd like — what sequence do you want?

@jdaugherty

Copy link
Copy Markdown
Contributor

I missed the dependency when the other PR was merged. I don't think we want these values registered and instead of docuemnting we need to disable them.

@jdaugherty

jdaugherty commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@codeconsole I pushed the test showing the material differences. In summary:

4 failures — each one a Grails 8 behavior change due to removing the annotation:

  1. DELETE form body — json.title expected null, got The Stand ([method:DELETE, title:The Stand, pages:1153]): Boot's OrderedFormContentFilter now parses the DELETE body into params.
  2. Static resource — /boot-default-mapping.txt expected 404, got 200: Boot's catch-all resource handler now serves classpath:/public at the root.
  3. Welcome page — / expected 404, got 200: Boot's WelcomePageHandlerMapping now serves the static index.html.
  4. Locale — expected fr_FR (Accept-Language), got de: grails-i18n's SessionLocaleResolver is active, so ?lang=de works.

I think #4 is expected and a better behavior, so I'm ok with that. But 1-3 are definitely behavior differences that are material. We shouldn't be serving assets that Grails doesn't know about nor should we have other mappings.

PUT/PATCH form fields were already exposed through Grails' params
(GrailsParameterMap); the filter's observable addition for controller
code is DELETE, and it also makes all three visible via the standard
request.getParameter(...) API. Disabling the filter reverts DELETE to
unparsed while PUT/PATCH continue to work.
With @EnableWebMvc no longer auto-injected, Spring Boot's
WebMvcAutoConfiguration is active and registers a catch-all (/**)
static-resource handler over classpath:/public (etc.) plus welcome-page
mappings that serve a static index.html at '/'. Both overlap with Grails'
own /static/** handler and UrlMappings-based root handling, and change the
404 semantics of unmapped URLs.

Default spring.web.resources.add-mappings to false (overridable) via a new
EnvironmentPostProcessor so Boot's catch-all handler is not registered, and
remove the welcomePageHandlerMapping / welcomePageNotAcceptableHandlerMapping
beans via GrailsWelcomePageAutoConfiguration (opt-out
grails.web.removeWelcomePageMapping) so Grails' UrlMappings own '/'.
Spring's FormContentFilter (registered by Boot's WebMvcAutoConfiguration) parses
form-encoded PUT/PATCH/DELETE bodies at the servlet layer for every Grails
servlet web application, so GrailsParameterMap no longer needs to parse the body
itself — it reads the request parameter map, as it already does for POST. This
removes a duplicate parser that in practice only ran when no filter was present.

- GrailsParameterMap: drop the manual PUT/PATCH form-body parse and its now-unused
  imports/logger; retain the REQUEST_BODY_PARSED constant for binary compatibility.
- GrailsMockHttpServletRequest: parse form-encoded PUT/PATCH/DELETE bodies into the
  parameter map so unit-test mocks reflect a post-filter request (preserves the
  raw-body -> params ergonomic and adds DELETE for parity with runtime).
- GrailsParameterMapTests: exercise the real FormContentFilter (PUT/PATCH/DELETE)
  rather than the removed fallback; GrailsMockHttpServletRequestSpec covers the
  mock's form-body parsing.

With no in-map fallback, spring.mvc.formcontent.filter.enabled=false now fully
disables non-POST form-parameter parsing (single-switch semantics).
The previous commit made Spring's FormContentFilter the single non-POST body
parser, but the filter is only registered by Boot's WebMvcAutoConfiguration —
which backs off when an application declares @EnableWebMvc, leaving PUT/PATCH/
DELETE form bodies unparsed.

GrailsFormContentFilterAutoConfiguration contributes an OrderedFormContentFilter
so a filter is always present. Ordered after WebMvcAutoConfiguration and gated on
@ConditionalOnMissingBean(FormContentFilter) so Boot's takes precedence in a
default app; it only fills the gap when Boot's is absent (the @EnableWebMvc case).
It honours spring.mvc.formcontent.filter.enabled=false so the explicit opt-out
stays a real off-switch. Result: PUT/PATCH/DELETE form parsing is uniform in every
Grails servlet app unless explicitly disabled.

Also:
- enable-mvc-check: the Application class imported @EnableWebMvc but never applied
  it, so the app silently ran as a normal Grails app. Apply the annotation, and
  update WebMvcDefaultsFunctionalSpec to expect DELETE parsed (Grails supplies the
  filter even under @EnableWebMvc) alongside PUT/PATCH.
- upgrading80x.adoc: note that Grails supplies the filter for @EnableWebMvc apps
  and that disabling it turns off PUT/PATCH/DELETE form parsing entirely.
Add grails.i18n.localeResolver (session [default], cookie, acceptHeader,
fixed) so applications can choose how the locale is resolved. The session
and cookie resolvers are mutable and keep the ?lang= LocaleChangeInterceptor;
acceptHeader (the locale follows the Accept-Language header) and fixed are
read-only, so the interceptor is not registered for them. The fixed resolver
uses grails.i18n.default.locale, falling back to the JVM default.

The default remains SessionLocaleResolver, so existing ?lang= switching and
the language selector are unchanged.
…nly resolvers

Spring 7's WebMvcConfigurationSupport contributes its own localeResolver (an
AcceptHeaderLocaleResolver). Under @EnableWebMvc that bean pre-exists, so
I18nAutoConfiguration's @ConditionalOnMissingBean backed off and Grails' session
resolver never registered, silently disabling ?lang= switching — a regression
from Grails 7, where the i18n resolver always took precedence.

GrailsLocaleResolverAutoConfiguration now removes the WebMvcConfigurationSupport-
contributed localeResolver (identified by its factory type) before
I18nAutoConfiguration is evaluated, so the Grails-configured resolver registers
instead. An application-defined localeResolver is left untouched.

ParamsAwareLocaleChangeInterceptor now handles a read-only LocaleResolver
(AcceptHeaderLocaleResolver/FixedLocaleResolver) gracefully: it logs the
unsupported setLocale at debug rather than an error on every ?lang= request. The
interceptor stays registered so urlMappingsHandlerMapping still has a handler
interceptor; it simply has no effect with a read-only resolver.

The enable-mvc-check app opts into grails.i18n.localeResolver=acceptHeader to
demonstrate header-based resolution cleanly.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Form-content parsing: consolidating on FormContentFilter

Following up on @jdaugherty's first point (the newly-active OrderedFormContentFilter), I've made the filter the single mechanism for parsing non-POST form bodies, rather than having it coexist with GrailsParameterMap's own PUT/PATCH parsing.

What changed

  • GrailsParameterMap no longer parses PUT/PATCH bodies itself. At runtime the request has already passed through OrderedFormContentFilter (order -9500, ahead of the Grails and Spring Security filters), so its inline if (requestMap.isEmpty()) … parse never actually ran — it only fired in unit tests and filter-absent configs. It's removed; GrailsMockHttpServletRequest now simulates the filter so the unit-test ergonomic (set a form body, read params) is preserved.
  • New GrailsFormContentFilterAutoConfiguration guarantees a FormContentFilter for every Grails servlet app. Boot registers one for a default app; this only fills the gap when Boot's WebMvcAutoConfiguration backs off (i.e. an @EnableWebMvc app). It's ordered after WebMvcAutoConfiguration and gated on @ConditionalOnMissingBean(FormContentFilter), so Boot's takes precedence by default and Grails only supplies one when it's absent. spring.mvc.formcontent.filter.enabled=false still disables it — that stays a real off-switch.

Net: PUT/PATCH/DELETE form bodies parse uniformly in every Grails servlet app unless explicitly disabled.

On DELETE — and a heads-up on the enable-mvc-check test

The enable-mvc-check Application class imported @EnableWebMvc but never actually applied the annotation (the class only had @CompileStatic), so the app was silently running as a normal Grails app — which is why its DELETE and locale assertions were failing on the branch. I applied the annotation.

With the annotation applied and the fallback filter in place, DELETE form bodies are now parsed even under @EnableWebMvc, so I flipped the DELETE spec from "not parsed" to "parsed." This intentionally reverses the Grails-7 parity that test asserted, and I want to flag it explicitly.

The reasoning: parsing a DELETE form body is benign and consistent — Boot does it by default, grails-spring-security has registered a FormContentFilter (which handles DELETE) for years, and the old "PUT/PATCH yes, DELETE no" split was a historical accident in GrailsParameterMap, not a deliberate rule. The only argument for not parsing it is byte-for-byte Grails-7 parity for the @EnableWebMvc escape hatch — i.e. preserving a silently-ignored DELETE body that essentially no application relies on. The deliberate change here is specifically that form-content parsing (PUT/PATCH/DELETE) is always on — even under @EnableWebMvc — unless disabled via spring.mvc.formcontent.filter.enabled=false.

If the consensus is that @EnableWebMvc should be exactly Grails 7 on this point too, I'm happy to switch the fallback to a FormContentFilter subclass that skips DELETE (via shouldNotFilter) — but I think uniform parsing is the better default.

Verification: enable-mvc-check functional spec (7/7), new GrailsFormContentFilterAutoConfigurationSpec (5/5), plus grails-controllers / grails-web-common / grails-test-suite-uber / grails-test-suite-web green; Checkstyle / CodeNarc / PMD / SpotBugs clean.

@codeconsole

Copy link
Copy Markdown
Contributor Author

Follow-on changes: neutralizing the Boot MVC defaults exposed by removing @EnableWebMvc

Removing the auto-injected @EnableWebMvc means Spring Boot's WebMvcAutoConfiguration is now active (and Spring's WebMvcConfigurationSupport no longer owns MVC config). That's the goal — Grails participates in the Boot auto-configuration ecosystem — but it also switches on a handful of Boot / Spring MVC defaults that overlap with, or silently change, long-standing Grails behavior. These commits keep the pieces Grails should own while letting the rest of WebMvcAutoConfiguration through.

1. Static resources — don't serve classpath:/public at the context root

WebMvcAutoConfiguration registers a catch‑all /** resource handler over classpath:/META-INF/resources/, classpath:/resources/, classpath:/static/ and classpath:/public/. Grails already serves those exact locations under /static/** (ControllersAutoConfiguration.GrailsWebMvcConfigurer), so Boot's /** is both redundant and a behavior change: any unmapped URL can now be shadowed by a file under classpath:/public where it previously 404'd.

  • GrailsWebResourcesEnvironmentPostProcessor contributes spring.web.resources.add-mappings=false as an overridable default, so Boot's catch‑all is off while Grails' own /static/** serving (gated by grails.resources.enabled) is unaffected. Apps that want Boot's behavior set spring.web.resources.add-mappings=true.

2. Welcome page — Grails' UrlMappings own /

WebMvcAutoConfiguration also registers welcomePageHandlerMapping / welcomePageNotAcceptableHandlerMapping, which serve a static index.html at /. That overlaps with Grails' UrlMappings-based root handling and can shadow an application's own "/" mapping whenever such an index.html happens to be on the classpath.

  • GrailsWelcomePageAutoConfiguration removes both welcome-page mappings so the root path falls through to UrlMappings. Opt out with grails.web.removeWelcomePageMapping=false.

3. Locale resolution — ?lang= regressed silently under @EnableWebMvc

The most subtle one. In Spring Framework 7, WebMvcConfigurationSupport now contributes its own localeResolver bean (an AcceptHeaderLocaleResolver). Because @EnableWebMvc imports WebMvcConfigurationSupport, that bean exists before I18nAutoConfiguration is evaluated, so grails-i18n's @ConditionalOnMissingBean(name = "localeResolver") backed off — Grails' SessionLocaleResolver never registered and ?lang= locale switching silently stopped working. Worse, ParamsAwareLocaleChangeInterceptor still ran and logged an UnsupportedOperationException from AcceptHeaderLocaleResolver.setLocale on every ?lang= request.

This is a regression from Grails 7: on 7.2.x I18nAutoConfiguration.localeResolver() registered the SessionLocaleResolver unconditionally, so it always took precedence even with the auto-injected @EnableWebMvc. (Confirmed empirically: dropping the @ConditionalOnMissingBean restores ?lang=dede; keeping it yields the Accept-Language header value.)

Fixes:

  • GrailsLocaleResolverAutoConfiguration (ordered before I18nAutoConfiguration) removes only the WebMvcConfigurationSupport-contributed localeResolver, identified by its factory-bean type, so grails-i18n's configured resolver registers instead. An application-defined localeResolver (from resources.groovy or a user @Configuration) is left untouched, so an explicit app resolver still wins.
  • grails.i18n.localeResolver is now configurablesession (default), cookie, acceptHeader, or fixed. Header-based resolution is a clean opt‑in for apps that prefer it; acceptHeader/fixed are read-only, so ?lang= simply has no effect with them.
  • ParamsAwareLocaleChangeInterceptor now handles a read-only resolver gracefully — it logs the unsupported setLocale at debug rather than erroring on every request. The interceptor stays registered (so urlMappingsHandlerMapping keeps its required HandlerInterceptor); it just no-ops when the resolver can't change.

Tests & docs

  • Unit coverage: GrailsWelcomePageAutoConfigurationSpec, GrailsWebResourcesEnvironmentPostProcessorSpec, GrailsLocaleResolverAutoConfigurationSpec, updated I18nAutoConfigurationSpec, and a read-only-resolver case in ParamsAwareLocaleChangeInterceptorTests.
  • The enable-mvc-check integration app exercises all of the above end-to-end (classpath:/public 404, welcome-page 404, form-content parsing, and — via grails.i18n.localeResolver=acceptHeader — header-based locale resolution with no error spam).
  • changingLocales.adoc documents the new grails.i18n.localeResolver property.

Comment thread grails-core/src/main/groovy/grails/config/Settings.groovy
Comment thread grails-doc/src/en/guide/upgrading/upgrading80x.adoc Outdated
Comment thread grails-doc/src/en/guide/i18n/changingLocales.adoc Outdated
Comment thread grails-test-examples/enable-mvc-check/README.md Outdated
…ing is disabled

Now that Grails guarantees a servlet-level FormContentFilter for every servlet
web application (Boot's OrderedFormContentFilter by default, the Grails fallback
under @EnableWebMvc), it runs at order -9500 — ahead of the whole Spring Security
chain (-100) — and wraps the request before the chain starts. So the plugin's own
in-chain FormContentFilter (inserted at EXCEPTION_TRANSLATION_FILTER.order - 10)
is redundant: the security filters already see the parsed parameters. It was only
needed historically, when Grails 7 auto-injected @EnableWebMvc and no servlet-level
form filter existed.

- SpringSecurityCoreGrailsPlugin: drop the formContentFilter bean registration and
  its import (also removes a bean that Boot could otherwise auto-register on /*).
- SpringSecurityUtils: drop the chain insertion.
- SpringSecurityUtilsIntegrationSpec: update the chain assertions accordingly.

The one config where nothing would then parse non-POST form bodies is an explicit
spring.mvc.formcontent.filter.enabled=false. Rather than have the plugin silently
re-add a filter there (ignoring the opt-out), GrailsFormContentFilterAutoConfiguration
now logs a startup warning when the filter is disabled and none is present, so the
consequence (PUT/PATCH/DELETE form bodies not parsed) is visible.
…rm parsing

Responds to review feedback on the @EnableWebMvc removal:

- Docs (upgrading80x.adoc): the upgrade notes were out of date. Boot's static-
  resource handler and welcome page are now removed by default (new section 31.3),
  not "active"; renumber the remaining Boot-MVC notes to 31.4; add 31.5 for locale
  resolution. Clarify that the form-content filter only affects
  application/x-www-form-urlencoded bodies (also in the auto-config javadoc).
- Docs (changingLocales.adoc): spell out precedence — with session/cookie the
  Accept-Language header sets the initial locale and ?lang= overrides and persists
  it; with acceptHeader/fixed the resolver is read-only and ?lang= is ignored.
- Config metadata: add grails.web.removeDefaultViewResolverBean,
  grails.web.removeWelcomePageMapping and grails.i18n.localeResolver (with value
  hints) to additional-spring-configuration-metadata.json.
- Tests: since GrailsParameterMap no longer parses PUT/PATCH bodies itself, add a
  functional test in app1 (a default, non-@EnableWebMvc app) asserting form-encoded
  PUT/PATCH/DELETE/POST bodies are parsed into params via Boot's OrderedFormContentFilter.
- Remove the enable-mvc-check README (it only existed to explain the test).
The disabled-filter warning message split its concatenation with a leading +,
which trips Checkstyle's OperatorWrap rule (:grails-controllers:checkstyleMain).
@codeconsole codeconsole requested a review from jdaugherty July 9, 2026 20:23
@testlens-app

testlens-app Bot commented Jul 9, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 4e458f6
▶️ Tests: 9544 executed
⚪️ Checks: 61/61 completed


Learn more about TestLens at testlens.app.

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

My only remaining concern is the spring security impacts since it's not clear if the form filter is now applying before or after the spring security filters.

@codeconsole codeconsole merged commit 1ede3f3 into apache:8.0.x Jul 9, 2026
110 of 115 checks passed
jdaugherty pushed a commit that referenced this pull request Jul 11, 2026
Spring Boot's ContentNegotiatingViewResolver — active for Grails servlet
apps since @EnableWebMvc stopped being auto-added (#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Remove or Register via AutoConfiguration duplicate registrations

4 participants