Skip to content

Filterless Sitemesh 3#15585

Open
codeconsole wants to merge 27 commits intoapache:7.2.xfrom
codeconsole:feature/sitemesh3-viewresolver
Open

Filterless Sitemesh 3#15585
codeconsole wants to merge 27 commits intoapache:7.2.xfrom
codeconsole:feature/sitemesh3-viewresolver

Conversation

@codeconsole
Copy link
Copy Markdown
Contributor

Sitemesh 3 Filterless implementation

  • performance updates
  • filterless
  • prep for Grails 8
  • based on 3.2.3-SNAPSHOT which will be release to final after this merge. Once testing is complete, a subsequent PR with 3.2.3 final will be released

Introduce Sitemesh3CapturedPage, a SiteMesh 3 Content implementation
backed by StreamCharBuffers that the GSP compile-time capture taglib
populates directly. Adds Sitemesh3LayoutTagLib under the grailsLayout
namespace to mirror the existing GrailsLayoutPreprocessor rewrites and
skip SiteMesh's HTML parser.
CaptureAwareContentProcessor skips SiteMesh 3's HTML parse when a
Sitemesh3CapturedPage is already populated on the request (the normal
path for GSP-rendered responses), and delegates to a TagBasedContent-
Processor with the SM2 bundle when it is not.
GrailsViewDispatchContext extends WebAppContext and overrides dispatch()
to resolve a decorator path through a Spring ViewResolver and call
View.render() directly. This avoids RequestDispatcher.include() so
layout GSPs run through the same Grails view pipeline as the main view.

GrailsContentBufferingResponse wraps the servlet response with SiteMesh
3's HttpServletResponseBuffer, forces HTML buffering, and exposes a
Content that short-circuits parsing when the GSP capture taglib has
already populated a Sitemesh3CapturedPage on the request.
Sitemesh3LayoutView buffers an inner Spring View, obtains a SiteMesh 3
Content, and drives decoration through a GrailsViewDispatchContext.
INCLUDE dispatches are passed straight through; other dispatch types
go through decorator selection.

Sitemesh3LayoutViewResolver wraps each resolved view in a layout view,
bypassing layout-path names and redirect views.

Sitemesh3LayoutViewResolverPostProcessor renames the primary GSP view
resolver bean to gspViewResolverInner and registers the wrapping layout
resolver under the original name, mirroring grails-layout's approach
for integrating at the Spring MVC level.
Sitemesh3LayoutFinder ports GroovyPageLayoutFinder's resolution order
to the SiteMesh 3 DecoratorSelector interface: request attribute,
meta layout, controller static property, action-specific layout,
controller-specific layout, then configured default. Caches results
outside of the DEVELOPMENT environment.
Sitemesh3GrailsPlugin now registers the view-resolver beans
(sitemesh3ContentProcessor, sitemesh3DecoratorSelector,
sitemesh3LayoutViewResolverPostProcessor) and replaces the upstream
SiteMeshAutoConfiguration 'sitemesh' filter with a disabled no-op
FilterRegistrationBean. SiteMeshAutoConfiguration's bean is
@ConditionalOnMissingBean(name = "sitemesh") so defining this bean
disables the upstream filter integration entirely.

Drop the grails.gsp.sitemesh=false and grails.gsp.view.layoutViewResolver
=false overrides so GSP compile-time capture stays enabled and our
view resolver can take over. Remove the sitemesh.filter.order property
since there is no longer a filter.

RenderSitemeshTagLib is rewired via @Autowired against the
ContentProcessor and DecoratorSelector beans rather than reaching into
the filter registration bean.

GspAutoConfiguration drops the GrailsLayoutHandlerMapping bean and the
AutoConfigureBefore reference to SiteMeshAutoConfiguration.

GrailsLayoutHandlerMapping is deleted.
Sitemesh3LayoutFinderSpec covers all six layout resolution paths:
request attribute, meta layout, controller static property, action
convention, controller convention, and configured default (including
the no-match-no-default empty result).

CaptureAwareContentProcessorSpec verifies the capture short-circuit
and fallback-processor behavior.

GrailsViewDispatchContextSpec verifies dispatch resolves views via
the Spring ViewResolver and calls View.render() rather than forwarding.

Pulls in byte-buddy and objenesis so Spock can mock the concrete
GrailsConventionGroovyPageLocator.
…ion, multi-decorator resolution

Three defects uncovered by end-to-end validation against grails-test-examples-gsp-sitemesh3:

1. CaptureAwareContentProcessor short-circuited during the decoration
   second pass, returning the inner's captured page instead of parsing
   the layout's output. This left <sitemesh:write property="..."/>
   placeholders unexpanded in the final response. Now checks
   context.getContentToMerge() — non-null means we're in the decorate
   phase and must run the fallback parser so SiteMeshWriteRule fires.

2. Added DecoratorTagRuleBundle to the fallback TagBasedContentProcessor.
   Without it <sitemesh:write> has no tag rule registered and passes
   through as literal text.

3. GrailsViewDispatchContext.dispatch now clears and restores the
   captured-page request attribute around the layout render, so the
   layout's own preprocessed <head>/<body>/<title> captures don't
   clobber the inner page's buffers that <sitemesh:write> reads from.

4. Sitemesh3LayoutFinder now splits comma-separated layout names from
   the request attribute or meta tag, enabling decorator chaining
   (<meta name="layout" content="a, b"/>).

Updated CaptureAwareContentProcessorSpec to stub getContentToMerge()
explicitly (Spock's smart defaults return a non-null Content). Added
a spec for the decoration-phase fallback path.

All 13 integration tests in grails-test-examples-gsp-sitemesh3 pass,
15 unit tests pass.
Two changes remove the parse pass that ran on every decorated response:

1. RenderSitemeshTagLib.layoutBody / layoutHead / layoutTitle inline-expand
   at tag-render time by pulling the property from WebAppContext.CONTENT_KEY
   (the Content being merged, same mechanism <g:pageProperty> already uses)
   and writing directly to out, instead of emitting <sitemesh:write
   property="..."/> placeholders that would need SiteMeshWriteRule to
   expand them in a second HTML parse.

2. GrailsViewDispatchContext.dispatch pushes a fresh Sitemesh3CapturedPage
   on the request before the layout render (replacing the previous
   "clear the attribute" logic). The layout's own capture taglibs now
   populate this page, giving us head/body/title extraction for chained
   decoration without re-parsing.

CaptureAwareContentProcessor uses the layout's captured page directly
during decoration: attaches the raw rendered output via a new
setRenderedContent() on Sitemesh3CapturedPage, then returns the page
as the decorated Content. Falls back to the parser only if no capture
happened (layouts without <head>/<body> skeleton).

Sitemesh3CapturedPage.getData() returns a RawDataChunk when renderedContent
is attached. Needed because InMemoryContent's default data chunk writes
the property tree via writeValueTo, not the underlying data value — so
setValue() on the delegate's chunk doesn't produce the expected output.
RawDataChunk.writeValueTo appends the raw string verbatim.

Net: grails-sitemesh3 now matches grails-layout's decoration cost —
zero HTML parses on the hot path for GSP responses. The fallback
parser still runs for non-GSP responses that don't populate capture
taglibs.

Tests: 16 unit + 13 integration, all pass.
<g:applyLayout> in RenderSitemeshTagLib was instantiating a plain
upstream WebAppContext, whose default dispatch does
RequestDispatcher.forward(). That re-enters the servlet pipeline on
every apply. In isolation this still worked (a handler resolved
/layouts/<name>). But nested applyLayout — applyLayout inside
applyLayout inside an outer Sitemesh3LayoutView render — tore down
the outer request scope during the inner forward, and the outer
render then hit 'Cannot set request attribute - request is not
active anymore!' when GSP's WebOutputContextLookup tried to rebind.

Swap the context for our GrailsViewDispatchContext so dispatch goes
through Spring's ViewResolver + View.render(). No forward, same
request scope throughout, nesting now works.

Requires autowiring ViewResolver into RenderSitemeshTagLib. The
primary bean is our Sitemesh3LayoutViewResolver, which correctly
passes through /layouts/** view names without re-wrapping them in
Sitemesh3LayoutView (avoiding double decoration).

Removes the @PendingFeature annotation from EndToEndSpec's
'multiple levels of layouts' — it passes now.

Tests: 16 unit + 14 integration, all pass.
Closes the remaining allocation delta vs grails-layout on the decoration
hot path. Both changes leverage the fact that sitemesh 3's 3.2.x branch
already accepts CharSequence for ContentProperty.setValue and that
StreamCharBuffer implements CharSequence.

Sitemesh3CapturedPage.materializeProperties stops calling .toString()
on bodyBuffer, titleBuffer, pageBuffer, and each content-tag buffer.
setValue(CharSequence) now gets a direct reference, and sitemesh 3's
InMemoryContentChunk iterates via Appendable.append / CharSequenceBuffer
fast path. One String allocation per capture is eliminated, roughly
response-body-size bytes per decorated request.

extractHead() still materializes as String because the <title> strip is
regex-driven — CharSequence regex support in the JDK copies anyway, so
there's no win there.

RawDataChunk (the getData() shortcut used during decoration) now stores
CharSequence and takes a fast path through Writer.write(char[], int, int)
when the value is a CharBuffer with a backing array (the common case
since BaseSiteMeshContext hands us a CharBuffer wrapping a CharArrayWriter).
Falls back to Appendable.append for other shapes. Also drops the dead
delegate.getData().setValue(renderedContent) call — getData() returns
RawDataChunk directly so the delegate path is never read.

Tests: 16 unit + 14 integration, all pass.
…t, specs

Addresses high-priority issues from code review.

1. RenderSitemeshTagLib.applyLayout: null-guard on context.decorate()
   return value. The sitemesh 3 contract allows decorate() to return
   null (no decoration possible). The loop was overwriting content
   unconditionally, then calling content.getData() — NPE when the
   decorator selector resolved a layout name but the dispatch couldn't
   produce content. Also swap setAttribute(key, null) for removeAttribute
   on the LAYOUT_ATTRIBUTE restore path, matching the pattern used in
   Sitemesh3LayoutView.render().

2. Sitemesh3LayoutViewResolver.isLayoutPath: tighten the pass-through
   prefix match. The previous startsWith("/layouts") also matched
   sibling paths like /layoutsManagement/index, silently disabling
   decoration for any controller whose view name shared the prefix.
   Now matches only the folder itself ("/layouts") or paths under it
   ("/layouts/..."). Added Sitemesh3LayoutViewResolverSpec covering
   the four code paths (null / redirect / already-wrapped / wrapped)
   and both halves of the prefix-match decision.

3. Sitemesh3LayoutViewResolverPostProcessor: drop unused
   MutablePropertyValues import (would fail codeStyle check).

Advisory items from the review (extractHead String allocation,
unbounded Sitemesh3LayoutFinder caches, missing specs for
Sitemesh3CapturedPage.materializeProperties and Sitemesh3LayoutView.render
internals) are noted but not addressed in this commit — not blocking
for merge.

Tests: 24 unit + 14 integration, all pass.
Upstream sitemesh3 adds a SiteMeshViewResolverBeanPostProcessor variant
that wraps the target ViewResolver bean after it is instantiated (instead
of rewriting the bean definition pre-instantiation). Grails needs the
live-bean variant because gspViewResolver is not registered at the time
BeanDefinitionRegistryPostProcessors fire. Bump the starter version to
pull in the new type, and add an explicit spring-webmvc-sitemesh
dependency entry so the module's types are visible on the Grails plugin
compile classpath (for the GrailsSiteMeshViewResolver subclass).
Replace the locally-duplicated SiteMeshView / SiteMeshViewResolver /
SiteMeshViewResolverPostProcessor / WebAppContext-subclass /
HttpServletResponseBuffer-subclass with slim Grails-specific subclasses
of the upstream spring-webmvc-sitemesh types:

- GrailsSiteMeshViewContext extends SiteMeshViewContext — pushes a
  fresh Sitemesh3CapturedPage on the request per decorator dispatch so
  each layout level captures into its own page (without popping, so
  chained decoration can re-read the just-filled page).
- GrailsSiteMeshView extends SiteMeshView — overrides preRender /
  postRender to push/pop a Sitemesh3CapturedPage around the main view
  render, and overrides createContext to return the Grails context.
- GrailsSiteMeshViewResolver extends SiteMeshViewResolver — overrides
  createSiteMeshView to return GrailsSiteMeshView.
- GrailsSiteMeshViewResolverBeanPostProcessor extends the upstream
  SiteMeshViewResolverBeanPostProcessor, preconfigured to target
  "jspViewResolver" (the bean name Grails' GroovyPagesPostProcessor
  and GspAutoConfiguration register the GSP view resolver under) and
  to wrap with GrailsSiteMeshViewResolver.

Wiring:

- Sitemesh3EnvironmentPostProcessor seeds sitemesh.integration=view-resolver
  and sitemesh.viewResolver.wrapMode=bean-instance (both required to
  make the upstream auto-config pick the new BPP variant instead of the
  BDRP variant — the BDRP variant cannot find Grails' view resolver
  because its bean definition is registered after BDRPs fire).
- Sitemesh3AutoConfiguration registers GrailsSiteMeshViewResolverBeanPostProcessor
  with @AutoConfigureBefore the upstream auto-config so its
  @ConditionalOnMissingBean guard suppresses upstream's default.
- Sitemesh3GrailsPlugin renames sitemesh3ContentProcessor /
  sitemesh3DecoratorSelector to contentProcessor / decoratorSelector
  so upstream's @ConditionalOnMissingBean(name = "contentProcessor" / "decoratorSelector")
  guards pick up our implementations; drops the now-unused
  Sitemesh3LayoutViewResolverPostProcessor registration.
- RenderSitemeshTagLib consumes the renamed beans and injects
  gspViewResolver with @lazy + @qualifier("jspViewResolver") to break
  the RenderSitemeshTagLib -> ViewResolver -> groovyPagesTemplateEngine
  -> gspTagLibraryLookup -> RenderSitemeshTagLib cycle.
Replaces the deleted GrailsViewDispatchContextSpec / Sitemesh3LayoutViewResolverSpec
(those test subjects moved upstream) with specs for the new Grails subclasses:

- GrailsSiteMeshViewContextSpec — covers the dispatch push semantics,
  the no-pop-after-dispatch contract (required for chained decoration),
  and view-resolver delegation.
- GrailsSiteMeshViewResolverSpec — covers wrapping decisions: wrap for
  normal paths, pass-through for layout paths, pass-through for
  RedirectView, null-safe on missing inner view.
- GrailsSiteMeshViewResolverBeanPostProcessorSpec — covers the
  preconfigured target bean name / wrapper class and wrap-vs-passthrough.
- Sitemesh3EnvironmentPostProcessorSpec — covers default property
  seeding and the "respect existing user values" contract.
…inear scan, EPP forward-compat, applyLayout cleanup

Addresses findings from post-refactor code review.

1. Sitemesh3CapturedPage.propertiesMaterialized is now volatile. Captured
   pages can escape to async-dispatch threads in Grails 7 (async controller
   returns, Callable actions), and the JMM gives no happens-before
   guarantee on a plain boolean across threads. Two threads could race to
   materialize the property tree and corrupt the delegate. Volatile is
   cheap insurance.

2. extractHead() replaced its regex-on-toString path with a linear
   CharSequence scan. StreamCharBuffer implements CharSequence, so the
   scan operates on the buffer directly — no intermediate String
   allocation when no <title> is present, and a single StringBuilder
   when there is. Return type widened from String to CharSequence so
   ContentProperty.setValue(CharSequence) can hold the sequence without
   materialization. Saves ~head-size bytes per decorated request with a
   title (the common case).

3. Added META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports
   alongside the existing spring.factories. Spring Boot 3.x prefers the
   imports-file mechanism for EnvironmentPostProcessor registration;
   spring.factories is still read for back-compat but may be dropped in
   a future release. Listing in both keeps us forward-compatible.

4. RenderSitemeshTagLib.applyLayout now does the initial
   contentProcessor.build() and the LAYOUT_ATTRIBUTE assignment inside
   the try block. The previous layout had the attribute assignment
   outside the try — robust in current code but fragile against future
   reordering. Moving it inside the try means the finally's
   save/restore correctly covers any exception path including during
   content building.

5. RenderSitemeshTagLib.content taglib streams directly to out instead
   of building a StringBuilder and emitting it as one write. Matches the
   streaming style used everywhere else in the taglib and eliminates a
   per-invocation String allocation.

Tests: 26 unit + 13 integration, all pass.
Upstream now exposes protected getContentProcessor/getServletContext/
getViewResolver on SiteMeshView (sitemesh3 9a99354). Use those getters
in createContext() instead of mirroring the collaborators as local
fields. Eliminates three redundant fields and the constructor
assignments that populated them. Behavior unchanged.
Extends the existing sonatype snapshots repo entry (previously scoped
to cloud.wondrify.*) with an org.sitemesh.* regex match so the
3.2.3-SNAPSHOT artifacts published there resolve without requiring
GRAILS_INCLUDE_MAVEN_LOCAL=1 / a local publishToMavenLocal step.

Applied to both pluginManagement and dependencyResolutionManagement
repository blocks in GrailsRepoSettingsPlugin so every subproject picks
up the configuration consistently.
@jamesfredley
Copy link
Copy Markdown
Contributor

Let's do this against 8.0.x, 7.1.x is shipped and we have to get 8.0.x shipping ASAP to beat the Spring Boot 3.5.x June 30th EOL.

@codeconsole
Copy link
Copy Markdown
Contributor Author

Let's do this against 8.0.x, 7.1.x is shipped and we have to get 8.0.x shipping ASAP to beat the Spring Boot 3.5.x June 30th EOL.

@jamesfredley this won't work in 8.0.x This is an incremental pathway to 8.0.x and it isn't enabled by default.
As some as we get this out, I can get 8.0.x updated quickly.

@codeconsole
Copy link
Copy Markdown
Contributor Author

Let's do this against 8.0.x, 7.1.x is shipped and we have to get 8.0.x shipping ASAP to beat the Spring Boot 3.5.x June 30th EOL.

@jamesfredley could also do a 7.2.x branch, merge and ship 7.2 after 8.0

@jdaugherty
Copy link
Copy Markdown
Contributor

@jamesfredley given that sitemesh3 has known problems, I view this as a bug fix. @codeconsole does this fix the async & forwarding workflows? (there are ignored tests in the associated test-examples project).

I'm ok doing a 7.2 and having this change, especially because this is the non-default path.

@bito-code-review
Copy link
Copy Markdown

Your suggestion to update '7.1.0' to an arbitrary large value (like '99.0.0') instead of changing the expected result to true makes sense if the test needs a clear false case for unsupported versions. This keeps the test's intent while using a more obvious version.

grails-datamapping-support/src/test/groovy/org/grails/datastore/mapping/core/grailsversion/GrailsVersionSpec.groovy

        "99.0.0"         | false

@jdaugherty
Copy link
Copy Markdown
Contributor

I've created a 7.2.x branch after discussion with the team. Can you please set the base of this PR to 7.2 & update your branch?

@jamesfredley jamesfredley changed the base branch from 7.1.x to 7.2.x April 22, 2026 19:24
Copy link
Copy Markdown
Contributor

@jdaugherty jdaugherty left a comment

Choose a reason for hiding this comment

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

It's great that the async, etc seems solved now. I think it's very likely we can switch to sitemesh3 for Grails 8 as a defualt with these changes. In addition to my other comments, we should enhance the grails-docs for this change:

  1. add upgrading72x.adoc to for 7.2.x instructions and document the sitemesh changes
  2. update any other grails docs specific to sitemesh3

@jdaugherty
Copy link
Copy Markdown
Contributor

@codeconsole is this ready to re-review?

@codeconsole
Copy link
Copy Markdown
Contributor Author

codeconsole commented Apr 27, 2026

@codeconsole is this ready to re-review?

@jdaugherty sure, unless you want me to bound the cache. I updated the doc, just didn't bound the cache yet.

Copy link
Copy Markdown
Contributor

@jdaugherty jdaugherty left a comment

Choose a reason for hiding this comment

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

I took another pass, I think other than the license & sitemesh 2 clarifications, this looks good.

Comment thread grails-doc/src/en/guide/theWebLayer/gsp/layouts.adoc
Comment thread grails-doc/src/en/guide/upgrading/upgrading72x.adoc Outdated
@codeconsole codeconsole requested a review from jdaugherty May 1, 2026 01:20
…viewresolver

# Conflicts:
#	dependencies.gradle
@testlens-app
Copy link
Copy Markdown

testlens-app Bot commented May 1, 2026

✅ All tests passed ✅

🏷️ Commit: 35d1d4d
▶️ Tests: 17718 executed
⚪️ Checks: 37/37 completed


Learn more about TestLens at testlens.app.

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