Skip to content

Compile GSP pages statically across an application - #16139

Open
codeconsole wants to merge 18 commits into
apache:8.0.xfrom
codeconsole:feature/gsp-compile-static-8.0.x
Open

Compile GSP pages statically across an application#16139
codeconsole wants to merge 18 commits into
apache:8.0.xfrom
codeconsole:feature/gsp-compile-static-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Makes GSP static compilation something an existing application can turn on. Measured on an application with 73 <g:set> across its views and a page reading Grails internals reflectively: 415 compilation errors to 0, with two of its own pages edited.

Turning it on

grails:
    views:
        gsp:
            compileStatic: true

The same thing can now be asked for from the build, beside the artefact opt-ins:

grails {
    compileStatic {
        gsp = true
    }
}

The build option states the same grails.views.gsp.compileStatic setting as a system property, so it reaches both places a page is compiled: the forked page compiler, and the JVM running the application, which compiles a page again when it changes. A page compiled one way by the build and another way while being developed would be worse than not offering the option. A page still decides for itself with <%@ page compileStatic="false" %>.

gsp is not part of compileStatic { all = true }. The artefact opt-ins fail on code that is doubtful anyway; this one fails on a page reading a model variable it has not declared, which describes most pages in an application that has never declared one.

What a page has to declare

A page that declares a model states what it is rendered with, so a name outside it is a mistake and is reported:

<%@ page model="Book book" %>
<h1>${book.title}</h1>      <%-- ${publisher} here is an error: it is not in the model --%>

A page that declares nothing has stated nothing, and reads what it is rendered with the way a page that is not compiled statically reads it.

This is the decision the rest follows from. Compiling statically is worth ~1.5x whether or not a page declares anything (below), so requiring every model to be declared first would cost most applications the benefit to gain checking they had not asked for. Declaring buys checking, compiling statically buys speed, and they can be adopted separately. To hold every page to what it declares:

grails {
    compileStatic {
        gsp = true
        strictGsp = true
    }
}

Names the framework binds

These no longer need declaring, and most carry their real types, so they are checked:

Name Type
params TypeConvertingMap, so params.id and params.int('max') both resolve
flash FlashScope
request, response, session HttpServletRequest, HttpServletResponse, HttpSession
application, servletContext ServletContext — the same object under both names
webRequest GrailsWebRequest
controllerName, actionName, namespace String

Written into the page rather than onto the class it extends: a page is compiled with the application's classpath and that class is not, so FlashScope and GrailsWebRequest, which live in a module depending on the one holding that class, can be named there and not there. It also leaves a page free to declare one of these names in its own model and get its own type.

servletContext is newly bound; only application was, which is the same object.

Being typed, they are checked: ${request.contextPathTypo} is an error, and the converters from #15715 resolve, since session.string('x') only compiles where the session carries its type.

grailsApplication and applicationContext are the only two deliberately not typed. What pages read from them is not declared anywhere: grailsApplication.controllerClasses is matched at runtime against (\w+)(Classes) and answered from the artefact handlers, an open set no interface can enumerate. Typing them only changes which type the failure names, so they are read dynamically, which lets them work.

Names a page introduces

The var and status attributes of the tags a page calls are names the page has declared as plainly as it can, and no longer need declaring again:

<%@ page compileStatic="true" %>
<g:set var="total" value="${books.size()}"/>
<g:each in="${books}" var="book" status="i">${i}. ${book.title}</g:each>
Total: ${total}

What such a name holds is decided by the tag at render time, so it is read dynamically. To apply an operator to it the page has to say what it is, with either tag:

<g:def type="int" var="total" value="${books.size()}"/>   <%-- a local --%>
<g:set type="int" var="total" value="${books.size()}"/>   <%-- a local, and written to the scope --%>
${total + 1}

g:set gained the type: the declaration is written first and the tag is then called with the declared variable as its value, so the scope write still happens and scope still decides where it goes. Untyped g:set is unchanged. A type is accepted only alongside value — with a body or bean the value is produced when the tag runs, so there is nothing to declare from, and that is rejected rather than ignored.

g:def emitted int.cast(...) for a primitive, which throws whatever it is handed, so a page using one compiled and then failed to render. Both tags cast through the wrapper now.

Each tag is used where it fits. The welcome page's counts and collections are read only by the page that declares them, so it uses g:def, which is a local and nothing more. g:set type= is for a value that does need to be in a scope, which previously could not be typed at all.

Holding an artefact type back from all

compileStatic { all = true; services = false } enabled services anyway: the shortcut was folded in as all || services, and every flag carried a convention of false, so a type set to false could not be told apart from one never set. An artefact type now falls back to all rather than being OR-ed with it.

What it is worth

One page body, compiled three ways, 200 rows, 800 renders, median of 7:

dynamic 1.00x
static, nothing declared 1.55x – 1.70x
static, model declared 1.38x – 1.62x

The gain comes from compiling the page statically at all. Declaring a model buys type checking, not speed — the two static rows are indistinguishable here, and their ordering flips between runs. Round-to-round spread is roughly 2x, so these are directional: in-process renders, no HTTP, no JMH, one machine.

Limitations

  • An operator applied to a value of no known type is reported, and has to be. ${rows[0]} where nothing said what rows holds is an error rather than being read dynamically: a subscript is written into the class as getAt, and the writer that does it fails with a GroovyBugError rather than an error when handed a receiver whose type was never established. The report says what the page can do — declare it, or give it a type with <g:def type="...">. It covers a closure parameter too, which nothing declares and nothing resolves.
  • Templates. A _foo.gsp rendered via <g:render template="foo" model="[...]"/> takes its model from the caller, so it has to declare every attribute it may be passed, and nothing checks that the caller and the template agree.

Relationship to #16134

#16134 resolves tag calls at compile time and overlaps here in one place: its change to GroovyPageTypeCheckingExtension seeds the allowed namespaces from the compiled tag library index, which makes grails.views.gsp.compileStaticConfig.taglibs and the taglibs page directive unnecessary for any tag library on the compile classpath. The section documenting them here should gain a note saying so once that lands; they remain meaningful only for namespaces filled in while the application runs.

Nothing else here changes: that PR touches tag dispatch only, and resolveProperty is untouched by it.

Two files are touched by both and will need merging whichever lands second — GrailsCompileStaticOptions, where that PR adds strictTags and dynamicTagNamespaces to the same block this adds gsp and strictGsp to, and GroovyPagePlugin.

grails.views.gsp.compileStatic makes every page compile statically without
a per-page directive, and is honoured by both the build's page precompiler
and the engine that compiles pages while the application runs. Neither the
setting nor compileStaticConfig.taglibs was documented, and no test covered
the config-driven path -- every existing case drove it from the directive.

Document both under Groovy Server Pages, and cover the path they take:
the default with no directive, a page carrying an unrelated directive, a
page opting back out, an undeclared model variable, extra tag library
namespaces given as a string and as a list, and a namespace left out.

The guide requires section names to be unique across the whole book, so the
page is gspStaticCompilation rather than staticCompilation, which JSON
views already uses.
A statically compiled page resolves a name it does not declare through
getProperty, which is typed Object, so ${params.id} fails to compile with
"No such property: id for class: java.lang.Object". The same went for
flash, controllerName, actionName, namespace, grailsApplication and
applicationContext -- names a page never declares because the framework
binds them, and which appear in ordinary correct GSP.

Declare them on the statically compiled page, typed as far as this module
can see them: params and flash as Map, which is what lets the type checker
turn params.id into a get(), and the rest as the types this module already
depends on. Each delegates to the same binding lookup dynamic resolution
uses, so a page renders identically and reads null outside a web request.

request, response, session and application are left out. They are servlet
types, this module deliberately has no servlet dependency, and a page can
still declare one in its model directive to read it -- which the tests pin,
using a test-only servlet-api dependency rather than a real one.
compileStatic { all = true; services = false } enabled services anyway.
The shortcut was folded in as `all || services`, and every flag carried a
convention of false, so an artefact type set to false could not be told
apart from one never set and the disjunction kept it enabled either way.

Leave the artefact types unset and have each fall back to `all` rather
than being OR-ed with it, so a value stated for a type is the value used.
`all` keeps its convention: it is the fallback, and still the thing that
turns everything on.
grails.views.gsp.compileStatic could only be stated in configuration, so a
project keeping its build options together had nowhere to put it. Add it to
the compileStatic block the artefact opt-ins already live in:

    grails {
        compileStatic {
            gsp = true
        }
    }

It states the setting that already exists rather than introducing a name of
its own, as a system property, which reaches both places a page is compiled:
the forked compiler the build runs, and the JVM running the application,
which compiles a page again when it changes. Reaching only the first would
mean a page compiled one way while being developed and another when
packaged, which is worse than not offering the option.

The parser reads the property after configuration and lets it replace what
configuration said, matching the order a system property takes over
application.yml in the running application; a page directive is read
afterwards and still decides for the page carrying it.

Not included in `all`. The artefact opt-ins fail on code that is doubtful
anyway; this fails on a page reading a model variable it has not declared,
which describes most pages in an application that has never declared one.
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.93023% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.6392%. Comparing base (a6f4846) to head (a8e9fb6).
⚠️ Report is 12 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...sp/compiler/GroovyPageTypeCheckingExtension.groovy 72.7273% 9 Missing and 12 partials ⚠️
...oovy/org/grails/gsp/compiler/GroovyPageParser.java 88.4210% 5 Missing and 6 partials ⚠️
.../plugin/views/gsp/GroovyPageForkCompileTask.groovy 33.3333% 4 Missing ⚠️
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% 2 Missing ⚠️
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 80.0000% 2 Missing ⚠️
.../web/taglib/WebRequestTemplateVariableBinding.java 50.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16139        +/-   ##
==================================================
+ Coverage     52.3242%   52.6392%   +0.3151%     
- Complexity      18537      18579        +42     
==================================================
  Files            2039       2038         -1     
  Lines           97498      97207       -291     
  Branches        17138      17036       -102     
==================================================
+ Hits            51015      51169       +154     
+ Misses          38998      38545       -453     
- Partials         7485       7493         +8     
Files with missing lines Coverage Δ
...l/src/main/groovy/grails/util/BuildSettings.groovy 19.0476% <ø> (ø)
...n/core/GrailsCompileStaticArtefactsProvider.groovy 100.0000% <100.0000%> (ø)
...adle/plugin/core/GrailsCompileStaticOptions.groovy 100.0000% <100.0000%> (ø)
.../plugin/core/GrailsGspCompileStaticProvider.groovy 100.0000% <100.0000%> (ø)
...oovy/org/grails/gsp/CompileStaticGroovyPage.groovy 66.6667% <ø> (ø)
...ovy/org/grails/gsp/compiler/tags/GroovyDefTag.java 66.6667% <100.0000%> (+4.1667%) ⬆️
.../web/taglib/WebRequestTemplateVariableBinding.java 56.2500% <50.0000%> (-0.2016%) ⬇️
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% <0.0000%> (ø)
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 59.2105% <80.0000%> (+3.1499%) ⬆️
.../plugin/views/gsp/GroovyPageForkCompileTask.groovy 26.6667% <33.3333%> (+0.7407%) ⬆️
... and 2 more

... and 10 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
codeconsole requested review from borinquenkid, jamesfredley, jdaugherty, matrei and sbglasius and removed request for matrei August 13, 2026 01:36
@codeconsole codeconsole added this to the grails:8.0.0-RC1 milestone Aug 13, 2026
Typing them Map compiled params.id but rejected params.int('max') with
"Cannot find matching method java.util.Map#int(java.lang.String)". The
parameters are a GrailsParameterMap, whose int/long/boolean/short/byte/char
conversions are declared on TypeConvertingMap, and a page using any of them
stopped compiling.

TypeConvertingMap is in grails-core, already an api dependency here, and
implements Map, so reading a parameter by name still compiles to a get().

A page declaring its own model variable named params must now declare it as
a type assignable to TypeConvertingMap; Map alone is rejected as an
incompatible narrowing of the inherited accessor.
A page that writes <g:set var="total"/> or <g:each var="book"> has declared
those names as plainly as it can, but nothing told the type checker, so a
statically compiled page failed on every one of them and on every property
read from them -- "The variable [total] is undeclared", then "No such
property: title for class: java.lang.Object" for each use.

Collect the var and status attributes of the tags a page calls and carry
them on the type checking config the page is already annotated with, beside
the tag library namespaces, which is the same route and the same treatment:
resolved dynamically rather than reported. What such a name holds is decided
by the tag at render time and cannot be known while compiling.

Read from the page source rather than the parsed attributes, because
attributes are parsed only on the pass that writes the class, by which point
the annotation has already been written. A name matched that turns out not
to be a page scope variable costs only that it resolves dynamically.

Measured on an application with 73 <g:set> across its views: 414 errors
became 78, and what is left is model variables it never declares, the
servlet scopes, and a diagnostic page reading Grails internals reflectively.
Two things were wrong with how a statically compiled page resolved what the
framework binds into it.

grailsApplication and applicationContext were typed. What pages read from
them is not declared: controllerClasses is matched at runtime against
(\w+)(Classes) and answered from the artefact handlers, an open set no
interface enumerates, and applicationListeners belongs to an implementation
rather than the interface. Typing them only changed which type the failure
named. They are resolved dynamically now, which lets them work.

The request, response, session and servlet context were not typed, and read
as Object, so ${request.contextPath} did not compile. They are typed now,
which also reaches the converters added for them in apache#15715 -- the point of
those was that session.string('x') compiles, and it only can where the
session carries its type. The flash scope and the web request stay dynamic:
their types live in a module that depends on this one.

A page is also no longer held to names it never declared. Declaring a model
says what a page is rendered with, so a name outside it is a mistake and is
still reported; a page that declares nothing has said nothing, and reads the
way a page that is not compiled statically reads it. Applications that want
the guarantee everywhere can ask for it with
grails.views.gsp.compileStaticConfig.strict.
Strictness was reachable only through configuration. Add it to the block the
page opt-in already lives in, as strictGsp, published to both the page
compiler and the running application the same way gsp is:

    grails {
        compileStatic {
            gsp = true
            strictGsp = true
        }
    }

On its own it publishes nothing: it says how a page compiled statically is
read, so it has nothing to say about a build that does not compile pages
statically.

Documents the whole of it -- what a page declaring a model is held to and
what one declaring nothing is not, the scopes that carry their types and the
few that cannot, the names a page introduces through the tags it calls, and
both ways to ask for strictness.
@codeconsole
codeconsole marked this pull request as draft August 13, 2026 05:52
A page reading rows[0], where nothing said what rows holds, failed the
compilation with a GroovyBugError out of the class writer rather than an
error: a subscript is written into the class as getAt, and the writer that
does it cannot be handed a receiver whose type was never established.
Arithmetic on such a value did the same.

Leave the receiver of an operator alone rather than resolving it
dynamically, and report it. The report has to outrank the exemptions that
let a page read what it never declared, since those are exactly the values
this arises for -- a name a tag introduced, or one the framework binds --
and it says what the page can do about it rather than asking for a bug
report against Groovy.
Holding back the receiver of every binary operator was far more than the
problem needed. A comparison or a logical operator reports a type error of
its own, which is an answer a page can act on, so its receiver can be
resolved the way anything else is. Only the operators the class writer
emits directly -- a subscript and the arithmetic -- cannot be handed a
receiver of no known type.
A new application gets a page that reads a good deal of what Grails knows
about itself, and none of it declares a type, so turning static compilation
on was the application's problem to fix before it could use the feature at
all.

Give the page the types it needs: g:def rather than g:set for the counts and
collections that feed arithmetic, a Class where one is read back out of an
accumulator, String[] for the tokens of a URL mapping, get() rather than a
subscript where the receiver is only known to be a map at runtime, and a
cast on the index a withIndex() closure hands back untyped.

The Spring Security version now comes off the package the class was loaded
from, the way the banner reads it. That resolves statically, and drops the
caller-sensitive getMethod/invoke pair a native image rejects.

The profile skeleton and the forge resource are the same page and stay
identical. Verified by compiling both against grails.views.gsp.compileStatic.
… class

Three things followed from declaring them on the class a page extends.

The flash scope and the web request could not be declared at all: their
types are in a module that depends on the one holding that class, so naming
them there is a dependency cycle. They were read dynamically instead, which
worked but was never checked.

A page could not declare one of those names in its own model. The field it
generated returned a type the inherited accessor did not, so `model="Map
params"` failed as an incompatible override rather than meaning what it said.

And a page reading servletContext got nothing, because nothing bound the
name -- only `application`, which is the same object.

Write the accessors into the page instead, for each name the page has not
declared itself. A page is compiled with the application's classpath, so
every type is nameable there, and a name the page speaks for is simply left
alone. Bind servletContext alongside application while here.

An operator applied to a value of no known type is now reported wherever it
came from, including a closure parameter, which nothing declared and nothing
resolved -- type checking merely inferred Object for it, so it passed the
check and failed in the class writer.
@codeconsole
codeconsole marked this pull request as ready for review August 13, 2026 07:03
[code: 'welcome.binding.listeners', beans: applicationContext.getBeansOfType(grails.databinding.events.DataBindingListener)]]}"/>
<g:set var="numBindingBeans" value="${bindingGroups.sum { g -> g.beans.size() } ?: 0}"/>
<g:set var="mimeTypeProviders"
<g:def type="int" var="numBindingBeans" value="${(int) (bindingGroups.sum { g -> g.beans.size() } ?: 0)}"/>

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.

Can't we fix the set tag to handle types? We can always set it to Object and it should still work, no? Why the def?

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.

@codeconsole I think we need to work on the syntax here before proceeding. The set tag could take a type so the compiler knows the type or the def tag could be improved to autocast.

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.

g:set takes a type now (a794e2a): the declaration is written first and the tag is still called, so the scope write happens, scope/bean are unaffected, and untyped g:set is unchanged. type is only valid alongside value — with a body or bean there's no expression to declare from.

g:def needed the autocast too: it emitted int.cast(...) for a primitive, which throws whatever it's handed, so a page compiled and then failed to render. Casts through the wrapper now (43dcdc4).

The welcome page stays on g:def — those twelve values are read only by the page that declares them, so the scope write is unused. Say the word if you'd rather it demonstrate g:set type= instead.

@bito-code-review

Copy link
Copy Markdown

The requested file index.gsp is not present in the provided pull request diff. As a result, I cannot analyze the set tag or its implementation details in that file. Please ensure the file is part of the PR or provide the relevant code snippet if you would like me to review it.

<g:def type="int" var="n" value="${...}"/> wrote int.cast(...) into the
page, and Class.cast on a primitive class throws whatever it is handed, so
the page compiled and then failed the moment it rendered. Cast through the
wrapper instead and let the result unbox into the declared type.

The tests rendered nothing before, only compiled, which is why the welcome
page reached CI with this in it. They render now.
A page could type a variable only by declaring a local with the def tag,
which is not what the set tag does: set writes into a scope, and what it
writes is readable from a page rendered inside this one. Typing meant giving
that up, which is a semantic change to make for a compiler's benefit.

Give set a type instead. The declaration is written first and the tag is
then called with the declared variable as its value, so the write into the
scope still happens, scope still decides where it goes, and an untyped set
is untouched. What the type adds is that the page reads the variable rather
than looking it up -- which is what lets an operator be applied to it.

Only a value can be typed: where the value is the tag's body or a bean there
is no expression to declare from, so a type there is rejected rather than
quietly ignored.

The welcome page goes back to set, which is what it used before and what it
means; it keeps the types it needs.
Declaring a model also states that the model is complete, so reading a name outside it is reported rather than left to the render:

----
The variable [autor] is undeclared.

@matrei matrei Aug 13, 2026

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 is [autor] a typo. It should be reported anyway if spelled correctly as it was not declared as part of the model, right? So using a typo in this example is redundant?

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.

Any name outside the model is reported whether it's spelled correctly or not. Changed the example to [publisher], which is simply not in the model.

The welcome page's counts and collections are read only by the page that
declares them. def declares a local and is what that is; set additionally
calls into the tag library to write the value into a scope, which nothing
here reads. So the page uses def and pays for what it uses.

The set tag keeps its type, which is worth having on its own: before it,
a value that did need to be in a scope could not be typed at all.

Also drops an inner (int) from six values. Both tags cast through the
wrapper already, so the cast was saying the same thing twice; it was never
required by either.
@testlens-app

testlens-app Bot commented Aug 15, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: a8e9fb6
▶️ Tests: 69200 executed
⚪️ Checks: 80/80 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

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants