Skip to content

Compile scaffold pages by template and domain, and let the resolver look them up - #16398

Merged
jdaugherty merged 51 commits into
apache:8.0.xfrom
codeconsole:feat/scaffold-precompiled-lookup-8.0.x
Sep 25, 2026
Merged

jdaugherty merged 51 commits into
apache:8.0.xfrom
codeconsole:feat/scaffold-precompiled-lookup-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

#16385 keeps precompiled scaffold pages in the controllers' view directories, so the build has to predict every precedence decision the runtime resolver makes: declared views, plugin views, namespaces, template overrides, controllers sharing a name. Each review round found another place that prediction drifted, and where it could not predict safely it gave up precompilation. Namespaced controllers, same-named controllers scaffolding different domains, and namespace-specific templates are all still expanded at runtime, which a native image cannot do.

This moves the decision back to the runtime and precompiles only the expensive part.

  • Build. generateScaffoldedViews reads the scaffolded controllers with ASM: the application's own, and those of every plugin on the runtime classpath (an archive or directory carrying META-INF/grails-plugin.xml). For each, it plans every copy of every template: the application's own, from src/main/templates/scaffolding and from what processResources packages beside its controllers (however the build puts it there), and every copy on the runtime classpath. A namespace-specific template is planned only for a controller with a namespace. The namespace can be declared on the class, supplied by a trait's static accessor, inherited from a superclass, or taken from an interface constant. The task then runs org.apache.grails.scaffolding.ScaffoldedPagesGenerator in a JVM on the application's runtime classpath. It expands each planned template with the runtime's own ModelBuilder, ScaffoldedPages and Groovy, and writes it, in compileGroovyPages' encoding, to grails-scaffolded/<domain class>/<template path>-<digest>.gsp, ending with a GSP comment that names the copy it came from (by jar name, not by path, so the page stays cacheable). compileGroovyPages compiles grails-app/views where it is, with the generated pages in the same compilation (one views.properties), so nothing is copied. The task is registered in every project: which controllers are scaffolded, a plugin's among them, is found from compiled classes rather than guessed while the build is configured, and a project that scaffolds nothing generates nothing. The digest covers the template and the values of the model names the template mentions.
  • Runtime. ScaffoldingViewResolver chooses the template exactly as before. Where it used to expand and compile it, it computes the same name and serves the compiled page when there is one. Otherwise it expands the template as before and logs it once: at INFO on the JVM, where that still works, and as a WARN in a native image, where the expansion that follows fails.

Why every copy rather than the one the resolver is expected to choose: on the JVM the resolver looks beside the controller's class first, but a native image keeps no class files and takes the first copy on the classpath it was built from. The build cannot know which copy that is, so it doesn't try to.

Consequences:

  • A compiled page cannot shadow a declared view. grails-scaffolded cannot be a controller's view directory (it has a hyphen), and a page is used only for the exact template and model it was expanded from. The plugin view-index scanning and view-directory collision handling from Preserve view precedence when precompiling scaffolds #16385 are no longer needed and are removed.
  • Namespaced controllers, same-named controllers scaffolding different domains, plugin-provided scaffolded controllers, and namespace-specific and custom-named templates are all precompiled.
  • A template of the application's own (in src/main/templates/scaffolding or packaged under META-INF/templates/scaffolding) is its code, as a view is. If it cannot be expanded for a domain class, generateScaffoldedViews fails, naming each template and domain class. If a page expanded from it does not compile, compileGroovyPages fails, as it does for a hand-written view.
  • A copy a dependency supplies may never be chosen (a stock template the application has replaced, say). If it cannot be expanded, that is a warning. Its pages are listed as optional: compileGroovyPages takes optionalPages, files listing them, passed to the forked compiler as grails.views.gsp.optionalPages. GroovyPageCompiler leaves out one of those that does not compile, and the build output names it; the page's closing comment says which copy it came from. It is then expanded when rendered, as before.
  • A controller or superclass the build cannot read is a warning, not a silent skip.
  • GroovyPageCompiler removes what an earlier run compiled for a page the new registry no longer names: its class, inner classes and data files, and nothing it did not register. Digest-named pages otherwise left a dead class behind on every template edit, packaged until clean; renamed hand-written views did too. The digest in a page's name is 8 bytes (16 hex characters), keeping class names clear of Windows path limits.
  • Still expanded at runtime, and so still needing a concrete view in a native image: controllers using the legacy static scaffold property, and pages the build left out. The scaffolding guide lists both and what to do about each.
  • RC1's stageGroovyPages task and GroovyPagePlugin.scaffoldsAnyController are removed; the upgrade guide says so.
  • The generator ships in grails-scaffolding and the task in the Gradle plugin, so a build can pair different versions. The generator declares the version of their exchange in a PROTOCOL constant, which the task reads from the class file before running it. On a mismatch, or with a generator that declares none, the task warns and compiles no scaffolded page, as when the library has no generator at all, so the views are expanded when rendered instead of the build failing.
  • The build and the resolver share one implementation, so they cannot drift. Templates are read as UTF-8 on both sides. The file separator is a task input, so a page expanded on one platform is never restored from the build cache on another. In a native image started as development from its project directory, the resolver reads the packaged templates, as the page locator uses the compiled pages there.
  • Native images: the templates are registered as resources, because the resolver reads a template to name its page. Checked against GraalVM 25 with a standalone probe: getResource returns the first copy of a template in classpath order.
  • API: GroovyPageViewResolver.createGroovyPageView is now protected. ScaffoldingViewResolver.tryGenerateScaffoldedView(viewName, controllerClass) is the resolver's own template lookup for a controller; an overload that takes the candidate paths is also protected. DefaultGroovyPageLocator.isPrecompiledAvailable() is public. GenerateScaffoldedViewsTask no longer has RC1's templateClasspath and applicationViews properties; the upgrade guide says so.

Cost: once a view is resolved, a request costs one more view-cache key computation and map lookup than with #16385 (the parent resolver caches the null result for a scaffolded view). The first request for each view reads and hashes its template. The build forks one JVM when scaffolding inputs change; the task is cacheable, with the runtime classpath as an input. Pages are compiled per copy of a template, so a template with more than one copy (an application override of a stock template, say) costs one page per copy per domain class.

Validation:

./gradlew :grails-scaffolding:test :grails-scaffolding:codeStyle :grails-scaffolding:validateDependencyVersions \
          :grails-gsp-core:test :grails-gsp-core:codeStyle :grails-web-gsp:test :grails-web-gsp:codeStyle \
          :grails-test-examples-scaffolding:test rat
cd grails-gradle
./gradlew :grails-gradle-plugins:test :grails-gradle-plugins:codeStyle :grails-gradle-plugins:validateDependencyVersions

All pass (71, 267, 22, 15 and 304 tests). Coverage added:

  • grails-test-examples-scaffolding:test gains PrecompiledScaffoldPagesSpec. It runs the real resolver, through its own template lookup, against the real templates and the pages the example's own build compiled. It checks that every view of a controller, a namespaced controller, and a same-named controller scaffolding a different domain is served from a compiled page. That includes an application template and a namespace-specific application template.
  • The Gradle functional test runs compileGroovyPages against a stand-in compiler, to show that the generated directories and the encoding reach the forked process. It also covers templates packaged by a processResources customization and by a task-generated resource directory, a dependency copy competing with the application's, and the warning for an unreadable plugin controller.
  • The resolver's log level and log-once behavior are checked by capturing the log.

Not run, and not claimed to pass: the example's Geb integration tests, the repository-wide aggregateViolations check, and a native build of a full application. Resource resolution in a native image was checked only with the standalone probe.

Known and not addressed here:

  • A build that sets normalization { runtimeClasspath { metaInf { ignoreCompletely() } } } hides template edits inside dependency jars from the task's cache key.

…late

The scaffolding resolver chooses the template a request uses exactly as before, then looks for
the page compiled from that template and the domain class's model, at
grails-scaffolded/<domain class>/<digest of both>.gsp under the views root, and expands the
template only when there is none. A page is found only for the exact template and model it was
expanded from, so it can never stand in for a different one, and a template the build did not
see is expanded on its first use as it always was.
A compiled scaffold page is found by the template it was expanded from, so the resolver reads
the template even when it does not expand it.
…ew directories

generateScaffoldedViews expands every scaffolding template, namespace-specific ones included,
for every scaffolded domain class, and writes each page where the runtime resolver looks for it:
grails-scaffolded/<domain class>/<digest of template and model>.gsp. Nothing lands where a
controller's views resolve from, so a generated page cannot shadow a view the application or a
plugin declares, and controllers that are namespaced, or that share a name while scaffolding
different domains, are precompiled too. The application-views input and the view-directory
collision handling go with the directories. Template overrides are read as a tree, so an
application's namespace-specific template is expanded as well.
…and Groovy

generateScaffoldedViews no longer models, expands and names the pages itself with the build's
Groovy 4 and a copy of the runtime's naming. It finds the scaffolded domain classes and chooses
the templates, then runs ScaffoldedPagesGenerator from grails-scaffolding in a JVM on the
application's runtime classpath, which expands and names each page with the runtime's own
ModelBuilder, ScaffoldedPages and Groovy. The build and the resolver can no longer drift apart,
and the copy in the Gradle plugin is gone. The generator's classpath is a tracked input, so the
task stays cacheable. With a grails-scaffolding that predates the generator, nothing is compiled
and the build warns.

Templates are now read as UTF-8 wherever they are expanded, rather than in the JVM's default
encoding, so the page the build compiles is the page the resolver would have expanded.
…e template mentions

A page is now written as grails-scaffolded/<domain class>/<template path>-<digest>.gsp, so a page
can be traced to its template, admin/show-3fa2...gsp for instance. The resolver passes the path of
the template it chose, which tryGenerateScaffoldedView now resolves itself from the candidate paths
a caller gives it; it is protected, so a resolver subclass can use it.

The digest covers the template and only the model names the template mentions. A name the template
never mentions cannot change what it expands to, and leaving it out keeps a page found when such a
value differs between the machine that built the application and the one running it: packagePath
follows the file separator, so an application built on Windows and run on Linux lost every page.
… used

A miss used to be logged at debug, so a build and runtime that disagreed, or a template the build
never saw, went unnoticed until a native image failed on it. Where compiled pages are in use, the
resolver now warns once per template and domain class that it expanded the template. During
development, where pages are expanded by design, it stays at debug.
A native image cannot expand a template, so a scaffolded view whose template has no page of its
own failed there. That happens when the image's classpath puts a different copy of the template
first than the build did, or with a plugin that overrides the templates. The resolver now tries
every copy of the template on the classpath, in order, and serves the page compiled from the
first copy that has one, which is the one the build chose, with a warning. On the JVM it still
expands the template it resolved and never serves another copy's page.

Checked against GraalVM 25: in a native image getResource returns the first copy in classpath
order, as on the JVM, and getResources returns every copy.
…a compiled page

The real resolver, reading the real templates, against the pages the example's own build
compiled, for each view of a controller, a namespaced controller and a same-named controller
scaffolding a different domain class, with no view expanded at runtime. It is where the build
and the resolver are held together.
8.0.x now carries apache#16385, which kept the path-based approach and hardened it. This branch replaces
that approach, so in the five files only apache#16385 touched - the generation task, the GSP plugin
wiring, their specs and the scaffolding guide - this branch's version is kept, apart from
GroovyPagePlugin.VIEWS_SERVER_PATH, which still names the views root the pages are compiled under.
The task read templates from the compile classpath, so a template from a dependency the
application only has at runtime, a theme plugin declared runtimeOnly for instance, was never
compiled: the JVM expanded it on first use and a native image could not. The templates are now
read from the runtime classpath the generator already runs on, which becomes the task's single
runtimeClasspath input.
…r is expected to choose

When a template exists more than once - an application override and the stock template, or a
plugin that overrides the templates - the task compiled only the copy it predicted the resolver
would choose. That prediction was wrong for a template-override plugin, which the resolver
consults before the classpath, and wherever a native image orders its classpath differently from
the build. Every distinct copy is now expanded, identical ones once, so whichever copy the
resolver chooses has its page; each is named for its own content, so they cannot be confused.
Where a template exists once, as it usually does, nothing changes.

The generator takes one templates directory per copy:
ScaffoldedPagesGenerator <domain class list> <output directory> <templates directory>...
…d not choose

A native image fell back to the page compiled from another copy of a template when the copy it
resolved had none, because the build compiled only the copy it predicted. Now that every copy is
compiled, the copy the resolver chooses has its own page wherever the build could see it, and the
fallback only ever served a page from a template the application had not chosen. It is removed:
the resolver serves the page compiled from the template it chose, or expands that template, in
every environment.
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.86275% with 74 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.8154%. Comparing base (4f1a5c8) to head (e61d5cb).

Files with missing lines Patch % Lines
...gin/scaffolding/GenerateScaffoldedViewsTask.groovy 81.4607% 3 Missing and 30 partials ⚠️
...grails/scaffolding/ScaffoldedPagesGenerator.groovy 80.0000% 4 Missing and 9 partials ⚠️
.../plugin/views/gsp/GroovyPageForkCompileTask.groovy 33.3333% 6 Missing ⚠️
.../org/grails/gsp/compiler/GroovyPageCompiler.groovy 86.9565% 2 Missing and 4 partials ⚠️
...g/grails/web/pages/GroovyPageForkedCompiler.groovy 73.6842% 1 Missing and 4 partials ⚠️
.../plugin/scaffolding/ScaffoldingViewResolver.groovy 88.3721% 2 Missing and 3 partials ⚠️
...org/apache/grails/scaffolding/ScaffoldedPages.java 84.6154% 4 Missing ⚠️
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 90.4762% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16398        +/-   ##
==================================================
+ Coverage     57.5998%   57.8154%   +0.2156%     
- Complexity      22697      22804       +107     
==================================================
  Files            2133       2135         +2     
  Lines          104226     104467       +241     
  Branches        18692      18750        +58     
==================================================
+ Hits            60034      60398       +364     
+ Misses          35906      35737       -169     
- Partials         8286       8332        +46     
Files with missing lines Coverage Δ
...l/src/main/groovy/grails/util/BuildSettings.groovy 19.0476% <ø> (ø)
...vy/org/grails/gsp/io/DefaultGroovyPageLocator.java 51.1962% <ø> (+0.4785%) ⬆️
...rails/web/servlet/view/GroovyPageViewResolver.java 63.6364% <ø> (+13.2231%) ⬆️
...rails/scaffolding/aot/ScaffoldingRuntimeHints.java 100.0000% <100.0000%> (+100.0000%) ⬆️
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 68.9655% <90.4762%> (+11.6738%) ⬆️
...org/apache/grails/scaffolding/ScaffoldedPages.java 84.6154% <84.6154%> (ø)
...g/grails/web/pages/GroovyPageForkedCompiler.groovy 65.0602% <73.6842%> (+65.0602%) ⬆️
.../plugin/scaffolding/ScaffoldingViewResolver.groovy 73.5537% <88.3721%> (+37.0954%) ⬆️
.../plugin/views/gsp/GroovyPageForkCompileTask.groovy 27.9412% <33.3333%> (-0.3922%) ⬇️
.../org/grails/gsp/compiler/GroovyPageCompiler.groovy 81.0127% <86.9565%> (+7.5084%) ⬆️
... and 2 more

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

…es compiled pages

The resolver judged whether compiled pages were in use from the environment, which said yes in
an application's tests. Grails keeps compiled pages off the test runtime classpath, so there
every scaffolded view was expanded and each warned that a native image could not do that. The
resolver now asks the page locator, which knows whether it looks pages up among the compiled
ones; DefaultGroovyPageLocator.isPrecompiledAvailable() is public for that.
…e classpath

Every jar on the runtime classpath was opened twice, once for templates and once for the
generator. One pass now does both. The generator is looked for in a jar, as grails-scaffolding
ships it, as well as a directory, and a spec covers the jar.
…he build

A scaffolded page is an optimisation, but one that did not compile failed compileGroovyPages and
the build with it - the stock template for a domain class named Package, for instance, whose model
directive declares a field called package. The page compiler now takes the directories of
generated pages (grails.views.gsp.generatedDirectories, set by the build for grails-scaffolded)
and leaves out such a page with a warning: without it the view is expanded when it is rendered,
as it was before, and a page nobody renders costs nothing. A written page that does not compile
still fails the build. A page's data files and registry entry are now written after its class,
so one left out leaves nothing behind; the forked compiler prints what it left out, which is
what the build shows of it.
…endered

Expansion is the same code, template and model at build time and at
runtime, so a template that cannot be expanded for a domain class in the
build cannot be expanded when its view is asked for either. The message
said it would be expanded then instead.
Every copy of every template was expanded for every scaffolded domain
class. Two kinds of those pages can never be served:

- The resolver looks beside the controller's class first, and an
  application's src/main/templates are packaged there, so for the
  application's controllers its own template is always the one chosen.
  The dependency copies it replaces were compiled anyway, adding pages to
  the artifact, and a warning for each that did not compile.
- A namespace-specific template is only looked for on behalf of a
  controller with a namespace, yet admin/show.gsp was expanded for every
  domain class.

The task now hands the generator a plan: each domain class with the
copies its controllers can choose. A namespace is read from the class
without loading it, including one inherited from a superclass or
supplied by a trait's static accessor; a superclass that cannot be read
counts as declaring none. Where the choice depends on what the build
cannot see, a plugin overriding the templates or the runtime classpath
order, every copy is still expanded.
A plugin's controller is an artefact of the application like its own, and
the resolver renders a scaffolded one the same way, but only the
application's classes were searched, so its pages were always expanded
when rendered, which a native image cannot do.

Controllers are now also read from each plugin on the runtime classpath,
a jar or a directory carrying META-INF/grails-plugin.xml; a scaffolded
class in any other library is not a controller and is left alone. The
template beside a plugin's controller is its own, so that copy is the one
expanded for it where there is one. A class that cannot be read is
reported and skipped rather than failing the build.

Whether the views are staged at all is still decided from the project's
own controller sources, since a plugin's would have to be resolved while
the build is configured; a project that scaffolds nothing itself has its
plugins' pages expanded when rendered, as before.
An application can put its scaffolding templates under
src/main/resources/META-INF/templates/scaffolding as well as in
src/main/templates/scaffolding; both are packaged beside its controllers,
where the resolver looks first. Only the second was read, so a template
kept with the resources had no page, and the dependency copy it replaces
had one that is never served.
The pages were always written as UTF-8, while compileGroovyPages reads
sources in its compileOptions.encoding. An application compiling its
views in another encoding had any character outside ASCII in a template
or a domain class name read back as something else, so the compiled page
differed from the one the resolver would have expanded.

generateScaffoldedViews takes the encoding as an input, following
compileGroovyPages', and hands it to the generator.
A template that mentions packagePath expands with the platform's file
separator, and the resolver names the page it looks for from its own
expansion. Every other input of generateScaffoldedViews is the same on
Windows and elsewhere, so a page expanded on one could be restored from
the build cache on the other and never be found there. The separator is
now an input.
The staging decision looked for the text @scaffold in the controller
sources, so a controller annotated
@grails.plugin.scaffolding.annotation.Scaffold was missed and its pages
were expanded when rendered, while an unrelated @ScaffoldingSomething
counted. The annotation is now matched by name, imported or qualified.
…ve image

A packaged application warned, once per page, about every scaffolded
view it expanded instead of serving compiled. On the JVM that is what
every such view did before any page was compiled, and still works, so a
warning on each boot for, say, a controller using static scaffold was
noise. It also said the build compiles a page for every template, which
is no longer so.

On the JVM this is now logged at INFO. In a native image, where the
expansion that follows fails because a class cannot be defined at
runtime, it stays a warning and says what to do. The tests capture the
log to show each is emitted once and at which level.
The runtime reads a controller's namespace through its metaclass, which
also sees a constant on an interface the controller implements:

    interface AdminArea { String namespace = 'admin' }
    @scaffold(Book) class BookController implements AdminArea {}

is in the admin namespace, and its views are expanded from admin/
templates. The build only looked at the class and its superclasses, took
such a controller to have no namespace, and compiled none of the
namespace-specific pages it is served. Interfaces are now walked along
with superclasses.
…ss of mode

During development the resolver reads a template from the project's
src/main/templates, so an edit shows without a rebuild. The page locator
uses the compiled pages in an ahead-of-time image even when it looks like
development - an image started with -Dgrails.env=development from the
directory it was built in - but the resolver still read the template from
disk there. A template edited since the build, or the application's copy
read on behalf of a plugin's controller, then named a page that was
never compiled, and the view failed.

The resolver now reads from the project only when it is not running from
ahead-of-time artifacts, as the locator decides.
…ir encoding

Two pieces of wiring had no test that would fail without them: the
plugin passing compileGroovyPages' encoding to generateScaffoldedViews,
and the compile task passing the generated directories to the forked
compiler. Losing the second would turn every scaffolded page that does
not compile back into a build failure; the functional test only printed
the property it is passed from.

The functional test now sets an encoding on compileGroovyPages and runs
it, against a stand-in for the forked compiler that records what it is
started with, so both reach the process that uses them.
…ot render

The guide gave one remedy, use @scaffold or write a view, for three
causes. A plugin's controller already uses @scaffold: its pages are
compiled only when the application scaffolds a controller itself. A page
the build left out needs its template fixed. And the warning it promised
comes only where the application has compiled pages of its own.
Templates and plugin controllers were read only from entries ending in
.jar, but the class loader reads any archive on the classpath, so a
template in a .zip or a .JAR entry was one the resolver could choose and
the build never expanded. Every file entry is now opened as an archive;
one that is not an archive holds nothing to read and is passed over.
The application's own templates were read from src/main/templates and
from META-INF/templates/scaffolding under each resource source
directory. That missed templates the build packages by any other route -
a processResources that copies some in, a resource directory another
task generates - and, for a generated directory, lost the task that
writes it, so the templates could be read before they existed.

They are now read, through a new packagedTemplates input, from the
output of processResources: what is packaged beside the application's
controllers, however it got there, with the task that produces it. Not
the whole source set output, which view compilers add directories to
without naming themselves as their producers.
Every copy of a template is expanded, so one template path yields a page
per copy, from the application, grails-scaffolding and any theme. When
one of them could not be expanded, or its page did not compile, the
build said only which template path and domain class, or which page, so
there was no telling which copy to fix - and the guide told the reader
to fix the template the build output names.

The task now hands the generator where each copy came from - the
application, a jar and its entry, or a classpath directory - named
without this machine's paths, since the pages are cached. The generator
names it when a template cannot be expanded, and ends every page with a
comment naming it, which renders as nothing, so a page the compiler
leaves out leads back to its template. The guide says so, and that a
copy the application has replaced is compiled too.
…uild output

The unit test showed an unreadable controller is left out while the
others are expanded, and would have passed with the report back at INFO,
which a build does not show - the defect the warning fixed. The
functional test now puts a plugin with an unreadable controller on the
runtime classpath and finds the warning in the build output, next to a
note about a classpath entry that is not an archive, which it does not
find there.

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

I ran the scaffolding, GSP and Gradle plugin specs and the scaffolding example locally at 8c955be, and they all pass. I also ran the scaffold functional test under the configuration cache with problems set to fail, and it passed. A few comments inline.

A compiled page's class name carries the fully qualified domain class
and the digest both, and with sixteen bytes of digest the example's
reached 165 characters. With a deeper package and a longer entity name,
under a typical Windows user directory, build/gsp-classes/main gets
close to the 260-character MAX_PATH that archivers, antivirus and some
IDE indexers still trip over.

The domain class and the template path are in the name already, so the
digest only has to tell apart the copies of one template and the models
it is expanded with. Eight bytes do that with room to spare and make
every name sixteen characters shorter.
The page compiler never removed anything from its target directory. A
page removed or renamed since the last compilation left its class,
inner classes and data files behind; the registry, written whole, no
longer named them, so they were never served, but every artifact built
from a workspace that had not been cleaned carried them. Scaffolded
pages made that routine: a page is named for a digest of its template,
so every edit to a template left the previous pages behind, one per
domain class.

After writing the registry, the compiler now removes what the previous
registry named and the new one does not - the class, its inner classes
and its data files. It removes nothing else, so a file it did not write
is left alone, and a page whose name happens to extend a removed one is
not taken for its inner class.
… expanded

A template that could not be expanded for a domain class was left out
with a line on stderr, whoever's it was. For a copy a dependency
supplies that is right: it may be a stock template the application has
replaced, which the resolver never chooses, and where it is chosen it is
expanded when rendered, as it was before any page was compiled. But a
template in src/main/templates/scaffolding, or one the application
packages, is its own code as much as a view is: appending an invalid
<%-- probe --%> to one left the build green and every view expanded from
it failing when rendered, in a native image certainly.

The generator now returns what it could not expand instead of printing
it, and writes it to a report. The task, which knows whose each copy
is, warns through Gradle for a dependency's and fails for the
application's, naming each template and domain class.
A generated page that did not compile was left out, with a warning,
anywhere under grails-scaffolded, so a page expanded from a template of
the application's own that did not compile left the build green and the
view failing when rendered - where a hand-written view that does not
compile fails the build.

The page compilation now takes the pages it may leave out rather than
directories: compileGroovyPages has optionalPages, files listing them,
passed to the forked compiler as grails.views.gsp.optionalPages in place
of grails.views.gsp.generatedDirectories. The generator reports every
page it writes, and generateScaffoldedViews lists as optional only those
expanded from a template a dependency supplies. A page from the
application's own template has to compile, as a view does.
…their exchange

generateScaffoldedViews runs ScaffoldedPagesGenerator from the
application's grails-scaffolding and talks to it through its command
line and the plan, origins and report files, and the two ship apart: the
generator in grails-scaffolding, the task in the Gradle plugin. A build
pairing one version with another ran a generator that did not
understand what it was handed, which stopped with a usage error and
failed the build, saying nothing about why.

The generator now declares the version of the exchange it speaks in a
PROTOCOL constant. The task reads it from the class file the
application's class loader finds first, without running it, and when it
is not the version the task speaks - or there is none, from a generator
older than the constant - warns and compiles no scaffolded page, as it
already does when the library has no generator, so the views are
expanded when rendered instead of the build failing.
A build that generates pages had to copy them into the views directory
before compiling, because a second compilation writes a second
gsp/views.properties and an archive keeps only the first.

The page compiler now also takes directories of generated pages - on the
forked compiler, as the grails.views.gsp.generatedViewDirectories system
property - and compiles their pages in the same compilation, each named
by its path under the directory holding it, as though it were among the
views. A page of the views at the same path takes precedence. A compiler
that does not know the property compiles no generated page, which is
then produced when it is first rendered.
Whether a project generated scaffolded pages was decided while the build
was configured, by looking for @scaffold in its own controller sources,
because doing so meant copying grails-app/views and the pages into
build/generated/views for compileGroovyPages - too much to put on every
project. What it could not see got no pages: an application whose only
scaffolded controllers come from its plugins, and, in a native image,
every one of their views failed.

compileGroovyPages now compiles grails-app/views where it is, with the
generated pages as generatedViews in the same compilation, so nothing is
copied and a project without scaffolding compiles exactly what it did.
That lets generateScaffoldedViews be registered in every project: it
finds the scaffolded controllers, the plugins' included, from their
compiled classes, and in a project that scaffolds nothing it writes
nothing. The generated pages are part of the compilation's source, so a
project whose only pages are generated still compiles them.

stageGroovyPages and GroovyPagePlugin.scaffoldsAnyController, both in
8.0.0-RC1, are gone; the upgrade guide says so.
@codeconsole codeconsole added this to the grails:8.0.0-RC2 milestone Sep 25, 2026

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

I re-ran the scaffolding, GSP core, grails-web-gsp and Gradle plugin tests, the scaffolding and scaffolding-fields examples, and codeStyle on the touched modules at 7ac17a4. All of them pass.

I also checked the three earlier points in the example app:

  • Appending <%-- probe --%> to summary.gsp now fails generateScaffoldedViews, naming each domain class. A literal \${ 1 + } in the same template fails compileGroovyPages.
  • After editing summary.gsp, bootJar and build/gsp-classes/main hold only the classes for the new digest.
  • Page names carry 16 hex characters, and the longest class file in the example is now 149 characters.

The new wiring works under the configuration cache: a TestKit build with --configuration-cache-problems=fail reuses the entry and hands the plugin's page to the compiler as optional. In a project that scaffolds nothing, generateScaffoldedViews took 0.047s, so registering it everywhere costs very little.

One regression from the last commit and a doc nit, both inline.

@PathSensitive(PathSensitivity.RELATIVE)
FileTree getSource() {
return super.getSource()
return super.getSource().plus(generatedViews.asFileTree)

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 problem is the srcDir property above rather than this line: it is still @InputDirectory, so the task now fails validation when grails-app/views doesn't exist but there are generated pages to compile.

Before this commit, a scaffolding project compiled from the staged copy, which always existed. A project with no views had no source, so it was skipped as NO-SOURCE before validation ran. Now the generated pages make the source non-empty, and Gradle checks srcDir first. I reproduced it with a TestKit project based on gsp-compile-classpath: a @Scaffold(Integer) controller, a template in src/main/templates/scaffolding, and no grails-app/views. compileGroovyPages fails with:

Type 'org.grails.gradle.plugin.views.gsp.GroovyPageForkCompileTask' property 'srcDir' specifies directory '.../grails-app/views' which doesn't exist

The same project compiles at 8c955be. It also fails for the case this commit is for, an application whose only scaffolded controllers come from a plugin, when that application has no views directory. The likeliest way to hit it is a plugin project that scaffolds controllers but has no views of its own.

getSource() already fingerprints the pages, so srcDir could be @Internal, or @InputFiles, which accepts a missing directory. The forked compiler already skips a directory that doesn't exist. Could the new test "a plugin's scaffolded controllers have their pages generated in an application that scaffolds none of its own" also run compileGroovyPages with no grails-app/views? Right now it stops at generateScaffoldedViews, which is why it didn't catch this.

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.

Thanks, reproduced with the extended test and fixed in b5534e3. srcDir is @Internal now: its pages are fingerprinted through getSource(), and the forked compiler already skips a directory that doesn't exist.

The plugin-controller functional test now also runs compileGroovyPages in a project with no grails-app/views. The "scaffolds nothing" test now also checks that compileGroovyPages is still NO-SOURCE there.

A CRUD interface will also be generated. To access this open `http://localhost:8080/book` in a browser.

The Gradle plugin expands scaffold templates into `build/generated/scaffolded-views` with `generateScaffoldedViews`. Then `stageGroovyPages` combines them with application views in `build/generated/views`, and `compileGroovyPages` compiles them so packaged applications and native images do not need to generate them on the first request. Running `compileGroovyPages` runs these prerequisite tasks automatically. Handwritten views in the application or supplied by a plugin still take precedence over generated scaffold views.
When the application is built, the Gradle plugin's `generateScaffoldedViews` task expands the scaffolding templates for every domain class a controller scaffolds with `@Scaffold`, the application's or a plugin's, and `compileGroovyPages` compiles the resulting pages in the same compilation as the application's views. The templates are expanded by the application's own scaffolding library and Groovy, in a JVM on its runtime classpath, exactly as they would be expanded when the view is requested. That library has to match the Grails Gradle plugin: with a `grails-scaffolding` of another version, the build warns and compiles no scaffolded page, and the views are expanded on their first use. A packaged application then renders a scaffolded view without generating it on the first request, and a native image, which cannot compile a page at runtime, can render it at all.

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.

Nit: the task compares the version of the exchange the generator declares, not the library's version, so a grails-scaffolding from another release still has its pages compiled as long as that exchange hasn't changed. "Of another version" reads as though any mismatch, a patch release included, turns compilation off. Maybe: "with a grails-scaffolding the plugin cannot work with, the build warns and compiles no scaffolded page".

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.

Agreed, reworded in 212cdd9 to "With a grails-scaffolding the Grails Gradle plugin cannot work with, the build warns and compiles no scaffolded page".

compileGroovyPages declared its views directory as an input directory,
and an input directory that does not exist fails validation. Before the
pages were compiled beside the views, a project without views had no
source and was skipped as NO-SOURCE before validation ran; now its
generated pages are source, so a scaffolding project with no
grails-app/views - most likely a plugin that scaffolds controllers and
has no views of its own, or an application whose only scaffolded
controllers come from one - failed with "property 'srcDir' specifies
directory '.../grails-app/views' which doesn't exist".

The directory is now internal: its pages are fingerprinted as part of
the source already, and the forked compiler skips a directory that does
not exist. The functional test of a plugin's scaffolded controllers now
compiles its pages in a project with no views, and the one of a project
that scaffolds nothing checks it is still skipped as NO-SOURCE.
The guide said a grails-scaffolding "of another version" leaves the
scaffolded pages to runtime, which reads as though any mismatch, a patch
release included, turned compilation off. What is compared is the
version of the exchange between the task and the generator, which does
not change with every release.
The command-line test of ScaffoldedPagesGenerator built the page paths
it expected in the report by removing the output directory and a slash
from each page file's path. On Windows that path uses backslashes, so
nothing was removed and the test failed, though the generator reports a
page's path as its URI does, with forward slashes, on every platform -
which is what the page compiler matches optional pages against. The test
now expects the path from the page's URI.
@testlens-app

testlens-app Bot commented Sep 25, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: e61d5cb
▶️ Tests: 94402 executed
⚪️ Checks: 90/90 completed


Learn more about TestLens at testlens.app/docs.

@matrei matrei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, this is a much better shape than #16385. The build no longer has to predict the resolver's precedence decisions: it compiles every candidate, and the resolver keeps choosing exactly as before and only swaps the expansion for a lookup. I read through the runtime (ScaffoldingViewResolver, ScaffoldedPages, ScaffoldedPagesGenerator), the GSP compiler changes and the Gradle side, and checked a few places where the build and the runtime could disagree:

  • model(String) in the generator and model(Class) in the resolver both end up at new ModelImpl(fullName) (the generator has no defaultPackage), and ASM's Type.className matches Class.name, nested classes included, so both sides compute the same digest.
  • The build reads domain before value, the runtime reads only domain(), and ScaffoldingControllerInjector normalises into domain, so they agree. @Scaffold is not @Inherited, so reading only the controller's own class file matches getAnnotation(Scaffold).
  • src/main/templates is packaged as META-INF/templates (GrailsGradlePlugin), so the application copies the task plans are real runtime candidates, not just dev-mode ones.
  • grails-plugin.xml is written into the compilation's classes directory, so the directory-form plugin detection in findScaffoldedControllers works for a sibling plugin project.
  • Pages compile into the default package, so the top-level targetDir.listFiles() in removeStalePages sees everything it registered, and compileWebappGroovyPages has its own destination, so the two registries don't clean up after each other.

Approving. None of the points below block the merge: 1 and 2 are about test coverage and can be done here or in a follow-up, and 3 is nits.

1. Nothing in CI renders a precompiled scaffolded page

PrecompiledScaffoldPagesSpec shows that the name the resolver computes matches a class in the compiled registry. But it stubs GroovyPagesTemplateEngine, so the compiled page is never loaded or rendered. The example's Geb tests do render the scaffolded views. However, in a Grails build the compiled pages are deliberately kept off the test class path (see the comment above compiledPages in GroovyPagePlugin), so those tests go through the runtime expansion, not the compiled pages.

Two things can go wrong unnoticed because the fallback hides them:

  • a compiled scaffolded page that fails only when it renders (its data files, static compilation of the expanded page, and so on);
  • the build and the resolver drifting apart on the page name.

I checked this by hand at e61d5cb. I built :grails-test-examples-scaffolding:bootJar and ran it with grails.env=test and DEBUG on org.grails.web.servlet.view. /book, /book/create, /book/show/1, /user, /user/create, /user/edit/1, /community/user and /community/user/create all return 200, each resolved from its compiled page. For example:

Resolved GSP view at URI [/grails-scaffolded/com.example.Book/index-d3113cfdae06fe73.gsp]
Resolved GSP view at URI [/grails-scaffolded/com.example.community.User/create-b6f0ef269f3e30f6.gsp]
Resolved GSP view at URI [/grails-scaffolded/com.example.User/edit-f032b9e247a0740c.gsp]

Nothing was expanded, and nothing was reported as not compiled. So it works today; I'd just like CI to keep it that way.

Could the spec use the real template engine and render one page per controller? Or, better, add a smoke test against the packaged application that also asserts the resolver fell back for nothing (reportedPages empty, or no "was not compiled by the build" log line)? Native images are the motivation here, so the packaged-JVM path should at least be covered by a test.

2. The spec goes through non-public API

PrecompiledScaffoldPagesSpec calls the newly protected tryGenerateScaffoldedView(viewName, controllerClass) and reads the protected reportedPages. Our guideline is to test through the API an application uses. You could bind a GrailsWebRequest with the controller class and call resolveViewName(...). That tests the same thing, and the new protected overload may then not be needed at all.

3. Nits

  • generateScaffoldedViews rewrites every page each time it runs (outputDir.deleteDir()). The pages' mtimes are then always newer than their classes, so GroovyPageCompiler's up-to-date check recompiles every scaffolded page, even when only one template changed. This only happens when the task's inputs change, so it's harmless.
  • The page digest only covers the model names a template mentions (text.contains(name)). A template that uses packagePath therefore finds no compiled page when the app is built on Windows and run on Linux, or the other way round. The fileSeparator task input protects the build cache, not this runtime lookup. On the JVM that costs one expansion, so a sentence in the guide is enough.
  • The upgrade guide names GroovyPagePlugin.scaffoldsAnyController, a protected method of the Gradle plugin. Saying that RC1's stageGroovyPages task is gone is enough for users, so I'd drop the method name.
  • The new paragraphs in scaffolding.adoc are dense (for example "A copy a dependency supplies is not, and because every copy is compiled, that includes a copy the application has replaced with its own."). A short list would be easier to scan: what gets compiled, what fails the build, what is still expanded at runtime, and what to do for a native image.

@jdaugherty
jdaugherty merged commit e4cde3c into apache:8.0.x Sep 25, 2026
93 checks passed
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.

3 participants