Skip to content

Resolve tag calls at compile time instead of through the metaclass - #16134

Open
codeconsole wants to merge 50 commits into
apache:8.0.xfrom
codeconsole:feat/taglib-compile-time-index-8.0.x
Open

Resolve tag calls at compile time instead of through the metaclass#16134
codeconsole wants to merge 50 commits into
apache:8.0.xfrom
codeconsole:feat/taglib-compile-time-index-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

Tag libraries are described as they are compiled, and that description resolves tag calls in code compiled afterwards. A call whose namespace and tag are known becomes a direct invocation instead of being dispatched through the metaclass, and nothing is installed onto a metaclass to make dispatch work.

Defining tags

class GreetingTagLib {
    static namespace = 'greet'

    def hello(Map attrs) {
        out << "Hello ${attrs.name}"
    }

    def wrapped(Map attrs, Closure body) {
        out << '<div>' << body() << '</div>'
    }
}

Calling tags

In a tag library or a controller:

class BookController {
    def index() {
        String markup = g.createLink(controller: 'book')   // compiled into a direct invocation
        String other  = greet.hello(name: 'Grails')        // likewise
        String third  = createLink(controller: 'book')     // likewise, when nothing else answers to the name
    }
}

A call written inside a closure — a tag body, a withFormat block, anything taking a block — is compiled the same way, as is one in a constructor or a field initialiser.

The tag is still selected by name when the call runs, through the same lookup dynamic dispatch uses. A tag library that overrides another, one registered while the application is running, and the order tag libraries are registered in all decide the outcome exactly as before. Nothing is bound to a particular tag library class, so a tag declared by more than one of them, and a tag declared as a Closure field, are compiled the same way.

The attributes and body are passed straight through where the call says what they are; where it does not — a map held in a variable, a single value the tag reads under its own name — the arguments are forwarded as written and adapted by the same rules dynamic dispatch applies.

Tags in pages

A page resolves a name against the model it was rendered with before it reaches a tag library, and that model is not known when the page compiles. A page therefore keeps resolving its tags as it always has, unless it declares compileStatic:

<%@ page compileStatic="true" %>
${g.createLink(controller: 'book')}   <%-- compiled into a direct invocation --%>

Declaring it reserves the namespace names for tag libraries there. grails.views.gsp.compileStatic applies it to every page. A tag written as markup, <g:createLink controller="book"/>, already compiles into a direct call and is unchanged.

Checking tags

By default nothing is reported: a tag no compiled tag library declares is left to resolve at runtime, because a namespace can legitimately hold tag libraries carrying no description. An application whose tag libraries are all described can ask for an error instead:

grails {
    compileStatic {
        strictTags = true
        dynamicTagNamespaces = ['legacy']   // namespaces filled in while the application runs
    }
}

dynamicTagNamespaces turns compile-time resolution off for a namespace completely — calls into it are never rewritten, never reported, and dispatched exactly as before.

Strict checking applies where the source says a call is a tag: one naming its namespace, and one written as markup. A call written without a namespace is never checked, and a namespaced expression in a page is checked only where the page declares compileStatic.

Deprecation

Defining a tag as a Closure field warns at compile time. It still works and is called the same way; the form is deprecated because a closure carries no signature, so nothing about the call can be checked:

// Deprecated
Closure hello = { Map attrs -> out << "Hello ${attrs.name}" }

// Preferred
def hello(Map attrs) { out << "Hello ${attrs.name}" }

Why

Profiling a running application attributed roughly 25% of samples on a tag-heavy page to reflective and metaclass tag dispatch, and about 10% to ExpandoMetaClass read-lock contention.

Every caller used to mutate its own ExpandoMetaClass the first time it used a tag; every namespace dispatcher was built with a metaclass carrying a method per tag; and plugin bootstrap installed every tag onto every tag library. None of that remains.

Measured on a page performing 400 tag invocations, 8 concurrent, 105k warmup requests, same publish flow both sides:

ms/req
8.0.x 0.5213
this branch 0.4699

Compiling the calls is worth this much again on top, measured with metaclass removal present on both sides and only the rewriting varying:

per tag call
expression in a compile-static page −66%
call written inside a tag library −33%

Where the description comes from

Under the Grails Gradle plugin the index is written twice, because the two things reading it need different guarantees.

generateTagLibraryIndex runs before compilation, so a call to a tag the project itself declares resolves as it compiles. Reading from source it cannot describe everything — a tag library referring to a type written in Java, or generated by the build, is left out — so what it missed is recorded, and nothing in an incompletely described namespace is ever reported. It is never packaged.

packageTagLibraryIndex runs afterwards with the project's own classes on the classpath, where every tag library resolves. That index is the one pages compile against, the one packaged, and the one a project depending on this one reads. Each run replaces it, so a renamed or deleted tag library cannot survive.

A build that does not write the index — a plain groovyc, or a build without the Grails Gradle plugin — has each tag library describe itself as it compiles.

What is not rewritten

  • a namespace no compiled tag library declares, which is what keeps a tag library registered at runtime working
  • a namespace the build declared in dynamicTagNamespaces
  • a name something else in scope answers to — a local, parameter, field or getter called g is that thing
  • an unqualified call in a page, and any expression in a page that has not declared compileStatic
  • a name a page puts into its own binding with <g:set>

Limitations

  • A model attribute named after a namespace stops winning in a compileStatic page. That is what declaring it means there. A page that has not declared it is unaffected.
  • A method added to a controller or tag library at runtime, through doWithDynamicMethods, loses to a tag of the same name when the call is written without a namespace. Declare the method on the class, name the namespace in dynamicTagNamespaces, or call the tag with its namespace.
  • Unit testing support still installs tag methods onto metaclasses, deliberately: tests call tag methods directly, and the installed methods substitute an empty body for a missing one, so tagLib.someTag(attrs, null) works. A running application does not depend on this.
  • The end-to-end figure comes from one machine that showed thermal variance during the run; the per-call figures are in-process renders excluding the HTTP stack. Treat both as indicative of direction, not precise.
  • Scope within a method body is not tracked when deciding whether an unqualified name is claimed by a local. A name declared anywhere in the body counts throughout it, which can leave a call dispatched dynamically but never sends one somewhere else.

The closure form is deprecated and carries no callable signature, so a
tag defined that way cannot be resolved when a page is compiled. This
was the last closure-based tag remaining in the repository.
Discovering which tags exist required loading every tag library and
reflecting over it, which is only possible once the application is
running. A GSP therefore had no way to know at compile time whether a
tag call would resolve.

The TagLib AST transformation now records each tag library's namespace
and tag names as it is compiled, writing one descriptor per class under
META-INF/grails/taglibs along with a manifest naming them. Descriptors
are per class so that tag libraries packaged in separate jars merge on
the classpath with no build step combining them, in the manner of
META-INF/services entries.

Deriving tag names from the AST has to agree exactly with the runtime
rules in TagMethodInvoker, since a tag recorded in the index but
rejected at runtime would resolve when a page is compiled and then fail
when it renders. The framework method exclusions are shared rather than
duplicated, and TagLibraryIndexAgreementSpec asserts the two views
match for every framework tag library.

Two cases the AST view has to account for: trait application generates
super-accessor bridges that are synthetic at runtime but not marked so
at canonicalization, and parameters with default values expand into
overloads that reflection sees but the declaration does not show.
The type checking extension answered every unresolved tag call with
makeDynamic, so compileStatic on a GSP verified model fields and left
tag calls exactly as dynamic as they were without it.

Tag calls are now checked against the tag library index. A call into a
namespace backed by a compiled tag library must name a tag that library
declares, and a misspelling is reported when the page is compiled
rather than surfacing as a missing method when it renders. Namespaces
the index does not know, as a tag library registered at runtime or
supplied by a separately compiled plugin would be, keep resolving
dynamically.

Namespaces contributed by compiled tag libraries no longer have to be
declared through the taglibs directive, because the index already
states which tags they hold.
Dispatching a tag read Method.getParameters() on every invocation to
work out which parameter takes the attribute map, which takes the body,
and which are bound from named attributes. That allocates a fresh
Parameter array and materialises reflection metadata each time, and it
showed up directly in profiles of tag-heavy pages, yet the answer is
fixed for a given method.

The classification is now computed once, when the tag library class is
first seen, and held alongside the method. Invocation walks the
precomputed plan instead of re-reading reflection metadata, and the
access check is suppressed once rather than paid per call.

Also corrects two disagreements between the compile-time index and
runtime dispatch that the framework tag libraries did not exercise:
@tag and @NotATag override the conventional signature rule at runtime
and now do so when scanning the AST, and an attributes parameter has to
be assignable to Map, so an untyped parameter is not a dispatchable tag
and is no longer recorded as one. IndexEdgeCaseTagLib covers both
directions.
The index is written per tag library class specifically so that
libraries packaged in separate jars combine on the classpath without a
build step merging them. That is the central claim of the format and
was previously only exercised indirectly, through tag libraries that
all happened to live in one module.

Builds classpaths out of temporary jars and asserts that two jars
contributing to one namespace merge, that distinct namespaces stay
distinct, that an empty classpath yields an empty index rather than
failing, and that a malformed descriptor leaves its tags unknown so
they fall back to dynamic resolution.
A design review found the compile-time index and runtime dispatch
disagreeing in ways the framework tag libraries never exercise. Each
would let a page compile and then fail as it renders.

An attributes parameter is only recognised at runtime when it is named
"attrs", unless the class was compiled without parameter names, in
which case any name is accepted. The scanner checked only the type, so
a tag written as foo(Map options) was recorded but is not dispatchable.
Whether names are retained is read from the compiler configuration and
the same rule applied, with the body parameter treated the same way.

TagMethodInvoker scans declared methods, so a tag inherited from a base
class is not dispatchable. The scanner walked inherited methods too and
is now restricted to declarations on the tag library itself. Trait
methods are woven as declarations and remain visible.

A namespace is read at runtime through the class hierarchy and after
its initialiser has run. The scanner looked only at the class itself
and treated anything other than a constant as the default namespace,
filing those tags under "g". It now walks the hierarchy, and when the
namespace cannot be known without running the code the tag library is
left out of the index rather than filed under a guess.

An unrecognised tag is now a warning rather than a compilation error.
The index describes the tag libraries compiled before a page, so a tag
added without rebuilding its library, or a library registered at
runtime, would otherwise fail a build whose pages are correct. Setting
grails.views.gsp.strictTagChecking restores the error.
When more than one tag library declares the same namespace and tag, the
one registered last wins, and registration order comes from artefact
scanning rather than from the classpath. TagPrecedenceSpec pins that
down: the winner flips purely with registration order and carries no
inherent ranking, and returnObjectForTags follows the winner rather
than accumulating.

The index cannot reproduce that ordering, so it no longer tries. A tag
declared by two tag libraries is recorded as ambiguous and is not
resolved, which leaves the choice where it is actually made. Resolving
it here would risk compiling against one implementation and dispatching
to another. The same tag library reaching the classpath twice, as a
duplicated dependency does, names one implementation and stays
resolvable.

Descriptors also carry the format version they were written with, and
one written by a different version is ignored rather than read under
rules that may since have changed.
Whether a method is a tag was decided in two places: by reflection when
an application registers its tag libraries, and over the syntax tree
when the tag library index is written. Keeping the two in step was left
to a test, and they had already drifted apart three times.

The rules now live in TagDiscoveryRules, over a TagMethodView that a
compiled method and a method being compiled each adapt to. The two
sources differ in only two respects, both confined to their adapters:
parameter defaults have already become overloads by the time a class is
reflected on, and whether parameter names survive into the class file
is a property of the compilation rather than of the method.

TagDiscoveryRulesSpec compiles one matrix of method shapes and
classifies each of them twice, from the tree and from the resulting
class, asserting the two agree as well as asserting the expected
answer. It covers the shapes that caused the earlier drift: a Map
parameter not named attrs, an untyped parameter, @tag and @NotATag, a
framework trait name, and a defaulted trailing parameter.
The index was written as each tag library compiled, which left it
unable to describe the source set as a whole. A renamed or deleted tag
library kept its descriptor, and the manifest naming it, until the
build directory was cleaned, so the index went on describing tags that
no longer existed.

TagLibraryIndexGenerator now writes it for a whole source directory at
once, and clears what was there first, so what it describes is what
exists. Sources are parsed only as far as the syntax tree, never
loaded or executed, which is covered by a tag library whose static
initialiser would throw if it ran. Regenerating unchanged sources
produces a byte-identical index.

The generateTagLibraryIndex Gradle task runs it, before page
compilation and ahead of the artifact being packaged, so a project
depending on this one can resolve its tags. The generator reads source
rather than classes, so its classpath is the compile classpath alone:
including this project's own output made it wait for the compilation it
exists to precede, which showed up as a circular dependency through
compileAstGroovy. Two tests hold that ordering in place.

The AST transformation keeps writing descriptors, which covers tag
libraries compiled outside this task.
Registering a tag library asked the class what tags it declares, which
walks its metaclass properties, reflects over its declared methods and
scans its fields. That happens for every tag library as an application
starts, and the answer was already worked out when the tag library was
compiled.

Registration now prefers the tags recorded in the index, and discovers
them from the class only when there is no record. That keeps working
unchanged for a plugin built before the index existed, for a tag
library registered while an application is being developed, and for
one registered by a test.

A tag declared by more than one tag library is deliberately absent from
the index, so a tag library holding such a tag falls back to discovery
rather than registering an incomplete set.
Resolving a tag installed it onto the caller's metaclass so that later
calls bypassed methodMissing, and every namespace dispatcher was built
with its own ExpandoMetaClass carrying a method for each tag in the
namespace. Tag dispatch was therefore a read of an initialised
ExpandoMetaClass, guarded by a read-write lock that profiles of
concurrent rendering showed to be the largest single contended cost,
and every caller mutated its own metaclass the first time it used a
tag.

Both now dispatch through the tag library lookup, which is a map read.

Removing the installed methods is not simply removing a cache: they
carried overloads that adapted a CharSequence body into a closure and
routed the call through the output capture protocol. Dispatching
straight at the tag library skipped that and broke a tag called with a
string body. The dynamic path therefore goes through
methodMissingForTagLib, which already does both, with the flag that
installs the metaclass methods turned off.

NoMetaClassMutationSpec holds the property that resolving a tag writes
to no metaclass.
Now that the index is generated from source before anything resolving
tag calls is compiled, it describes the tag libraries of this project
as well as those of its dependencies, so a tag it cannot find in a
namespace it knows is a misspelling rather than a gap in what it has
seen. Those are reported as compilation errors.

A namespace with no compiled tag library is still left to runtime
resolution, as a tag library registered while developing or supplied by
a plugin built before the index existed would be, and a tag declared by
two tag libraries stays ambiguous and unresolved. Setting
grails.views.gsp.strictTagChecking to false turns the error back into a
warning.

Generating the index no longer fails when one tag library cannot be
resolved ahead of compilation. FormFieldsTagLib refers to services in
its own project, which by design are not on the classpath the generator
runs against, and that took the whole index down with it. Sources that
fail are parsed individually and those that still fail are named and
skipped, leaving them to be described by the compiler as they are
built.
Calling a tag reaches the tag library through invokeMethod, which
leaves a dynamic call site in the caller's bytecode even when that
caller is statically compiled. Once a tag has been resolved against the
index there is nothing left to decide beyond which bean holds it, so
the call can be an ordinary method call.

CompiledTagInvocation is that call. It takes the namespace and name as
arguments and ends at TagOutput.captureTagOutput, which is where the
dynamic path ends too, so attribute and body handling, output capture,
encoding and return-object behaviour are the same either way.
TagLibNamespaceMethodDispatcher, which is how a statically compiled
page reaches a tag, now goes through it.

This is the target a rewritten call site needs. Rewriting the call
sites themselves is not part of this commit.
Every tag library had every tag in every namespace installed onto its
metaclass as it was constructed, and again for the whole application at
plugin bootstrap, so that a tag library calling another tag found a
method rather than falling through to methodMissing. A namespace
resolved through propertyMissing was installed as a property too.

None of that is needed now that tags are resolved through the tag
library lookup and invoked through CompiledTagInvocation, so it is
gone. Registering a tag library with the lookup is all bootstrap does.

TagLibraryMetaUtils is deprecated. What remains of it is the dynamic
dispatch a tag library registered at runtime still relies on, reached
with metaclass installation switched off.

The compile-time warning for a closure-based tag now says what the
consequence is, that calls to it stay dynamic because it cannot be
resolved when a page is compiled, and shows the method form to use
instead.
Writing g.link(controller: 'book') reaches the tag library through
propertyMissing to find the namespace and invokeMethod to find the tag,
which leaves a dynamic call site in the bytecode of a tag library even
when it is statically compiled. Both names are fixed in the source and
the index says whether that tag exists, so the call is replaced with a
call to CompiledTagInvocation.

Only calls whose shape is evident from the source are rewritten: a tag
takes attributes, a body, both or neither, written as literals. A call
whose attributes are assembled at runtime, a namespace no compiled tag
library declares, a tag declared by more than one of them, and a
namespace shadowed by a field of the same name are all left to resolve
as they did before.

CompiledTagCallRewriterSpec renders through each of those shapes, since
a rewrite that changed behaviour is the failure that matters.
Behaviour alone cannot show that anything was rewritten, because the
dynamic route produces the same output, so CompiledTagCallBytecodeSpec
compiles a tag library and looks for the invocation in the class file,
and for its absence where nothing should have been rewritten.
A review of the stack found the strict check and the explicit
invocation path each breaking cases the dynamic path handled.

An unrecognised tag is a warning again rather than an error. Knowing
that a namespace holds some compiled tag libraries is not knowing that
it holds all of them: a plugin built before the index existed
contributes tags to g without a descriptor, a tag library registered
while an application runs contributes more, and the index generator
skips a source it cannot resolve ahead of compilation. In each case the
namespace is known but incomplete, so a tag missing from it is not
necessarily a misspelling. Failing the build needs a namespace able to
state that it is complete, which the descriptors cannot yet do.
grails.views.gsp.strictTagChecking opts in to the error.

A tag declared by more than one tag library was reported as no such
tag. The index deliberately leaves it unresolved so that runtime
precedence decides, which the checker read as absent. It now asks
whether the tag is ambiguous before reporting it.

A tag body given as text threw a GroovyCastException. The dynamic path
accepted text through overloads that wrapped it in a closure, and the
explicit API narrowed the body to Closure, which a namespaced
dispatcher call with a string body could not satisfy. The API takes the
body as it is given and wraps text, as before.

Registering a tag library after startup, as reloading a changed class
during development and registering one from a test both do, uses the
descriptor supplied rather than the one recorded when the class was
compiled, which no longer describes what is being registered.
A tag library rewrote its own tag calls as it compiled, but a
controller can call tags as well. It gains that from the tag library
invoker trait rather than from being a tag library, so nothing rewrote
its calls and they stayed dynamic.

A global transformation now rewrites tag calls in any class carrying
that trait, which covers controllers without naming them and without a
second copy of the rules. It runs after trait injection, since whether
a class can call tags is only settled once its traits are applied, and
it does nothing at all when no compiled tag library is on the
classpath.

ControllerTagCallRewriteSpec compiles a class with the trait and one
without, and looks in the class files for the invocation, since a
rewritten call and a dynamic one produce the same output.
Describes how tag libraries are described when compiled and how that
resolves tag calls, what is compiled into a direct invocation and what
stays dynamic, how an unrecognised tag is reported and how to turn that
into an error, why a closure-based tag cannot be resolved, and where
the description is written and packaged.

Adds the corresponding what's new entry and an upgrade note covering
the two things an existing application notices: the warning for an
unrecognised tag, and the warning for a closure-based tag with the
method form to replace it.
The pre-compilation task scanned only grails-app/taglib, so a project
keeping tag libraries elsewhere had them described as they compiled
rather than beforehand, which is later than anything resolving them in
the same compilation needs.

The task now takes a collection of directories, defaulting to the one
it scanned before, and the generator can add to an index rather than
always replacing it, so several directories contribute to one index
instead of each erasing the last.
Three places were still installing methods onto metaclasses, so the
earlier claim that dispatching a tag writes to none of them was wider
than what had actually been done.

A page had methodMissing installed onto its metaclass as it compiled,
along with a method for every tag and a property for every namespace.
GroovyPage declares methodMissing itself now and already resolved a
namespace through getProperty, so a page reaches the same tags without
any of those writes.

The template namespace installed a method for each template name the
first time it was used. Rendering goes through the render tag either
way, so the name is resolved rather than installed.

The unit testing support keeps installing tag methods, deliberately.
Tests call tag methods directly, and the installed methods substitute
an empty body for a missing one, so tagLib.someTag(attrs, null) works.
Removing it broke twelve FormTagLibTests cases that rely on that
calling convention. A running application does not depend on it.

NoMetaClassMutationSpec now covers the template namespace and the page,
alongside the namespace dispatcher it already covered.
The index said only that a tag existed, which is enough to tell a
misspelling from a real tag but not enough to decide whether a call to
it can be bound. A tag defined as a Closure field carries no signature,
so a call to it cannot become a direct invocation, and nothing in the
index said which tags those were.

Each tag is now recorded with its kind, and a call is only compiled
into a direct invocation when the tag is a method. A closure-based tag
stays known, so it is never reported as a misspelling, and stays
dynamically dispatched. This showed up immediately: g.link is a Closure
field, so calls to it are correctly left alone.

The descriptor format is version 2 as a result. A descriptor written by
another version is ignored rather than read under the wrong rules, and
a kind that cannot be recognised is treated as the dynamic one so that
a newer descriptor can never cause a call to be bound wrongly.
A namespace is not declared anywhere: it is reached because nothing
else answers to the name. The rewriter took any receiver that was not
this or super as a namespace, checking only for a field of that name on
the class itself, so a local variable, a parameter, an inherited field
or a getter-only property called g had calls on it rewritten into tag
invocations. The object the author wrote was then never called, and the
code still compiled, which is the worst way for this to go wrong.

A receiver that resolves to anything - a local, a parameter, a field, a
property - is that thing, and the field and property checks now walk
the hierarchy and consider getters.

Rewriting is also confined to methods declared by the class being
transformed. getMethods() reaches inherited methods, whose bodies
belong to the class that declared them, so a subclass able to call tags
could otherwise change a superclass that cannot.

TagCallShadowingSpec covers a local, a parameter, a typed local, a
field, an inherited method, and the unshadowed case that must still be
rewritten.
The index a project generates from its own sources reached page
compilation and the packaged artifact, but not the compilation of its
own controllers and tag libraries. Those could resolve tags from
dependencies while a call to a tag declared in the same project stayed
dynamic, which is not what the documentation described.

The generated directory now joins the compile classpath, and
compileGroovy waits for it. It goes onto the classpath rather than into
the source set output, which would make the index wait for the
compilation it exists to precede.

The documentation is also narrowed to what is actually rewritten.
Expressions in a GSP page are checked against the descriptions but are
not rewritten: a page selects the tag by name as it renders, through
the namespace dispatcher, which no longer touches a metaclass but is
still a runtime choice. An unqualified call such as message(code: 'x')
is likewise left alone, since whether that name is a tag or a method of
the calling class is decided where it is called. The examples now use a
method-based tag, since the closure-based g.link they used is one of
the calls that is deliberately not rewritten.
Groovy reads a property from getX() and, when the return type is
boolean, from isX() as well. Only the first was checked, so a class
declaring boolean isG() had this.g treated as a tag library namespace
and calls on it rewritten, sending them to a tag library instead of the
property the author wrote.

Both forms now claim the name, with the isX form requiring a boolean
return type as Groovy does. TagCallShadowingSpec covers each getter
form and an inherited getter.

Also proves the resolution the previous commit exists to enable.
Generating an index from a tag library source, putting it on a compile
classpath and compiling a controller that calls that namespace shows
the call becoming an invocation, and shows it staying dynamic without
the index. The build wiring is asserted separately; what was missing
was evidence that the wiring is sufficient for the compiler to resolve
the call.
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.52966% with 231 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.8813%. Comparing base (fa1e147) to head (91f8d3d).

Files with missing lines Patch % Lines
...s/gsp/taglib/compiler/CompiledTagCallRewriter.java 74.4681% 17 Missing and 31 partials ⚠️
...roovy/org/grails/taglib/index/TagLibraryIndex.java 74.8299% 21 Missing and 16 partials ⚠️
.../grails/taglib/index/TagLibraryIndexGenerator.java 77.6978% 20 Missing and 11 partials ⚠️
...rails/taglib/discovery/TagLibraryAstDiscovery.java 41.6667% 15 Missing and 6 partials ⚠️
...org/grails/taglib/index/TagLibraryIndexWriter.java 71.8750% 11 Missing and 7 partials ⚠️
...lugin/views/gsp/GenerateTagLibraryIndexTask.groovy 44.8276% 14 Missing and 2 partials ⚠️
.../compiler/TagLibArtefactTypeAstTransformation.java 67.7419% 6 Missing and 4 partials ⚠️
...grails/gsp/taglib/compiler/LocalNameCollector.java 70.9677% 6 Missing and 3 partials ⚠️
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 84.3137% 6 Missing and 2 partials ⚠️
...ails/gsp/taglib/compiler/PageBindingCollector.java 73.0769% 0 Missing and 7 partials ⚠️
... and 7 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16134        +/-   ##
==================================================
+ Coverage     52.5503%   52.8813%   +0.3310%     
- Complexity      18391      18703       +312     
==================================================
  Files            2037       2052        +15     
  Lines           96498      97352       +854     
  Branches        16860      17058       +198     
==================================================
+ Hits            50710      51481       +771     
+ Misses          38353      38343        -10     
- Partials         7435       7528        +93     
Files with missing lines Coverage Δ
...iler/TagLibraryInvokerTypeCheckingExtension.groovy 59.4595% <ø> (ø)
...adle/plugin/core/GrailsCompileStaticOptions.groovy 100.0000% <100.0000%> (ø)
...ore/src/main/groovy/org/grails/gsp/GroovyPage.java 77.4059% <100.0000%> (+0.1907%) ⬆️
.../groovy/org/grails/gsp/GroovyPagesMetaUtils.groovy 100.0000% <ø> (+18.1818%) ⬆️
...sp/compiler/GroovyPageTypeCheckingExtension.groovy 65.0794% <100.0000%> (+2.7843%) ⬆️
...y/org/grails/taglib/NamespacedTagDispatcher.groovy 100.0000% <100.0000%> (+12.5000%) ⬆️
...ails/taglib/TagLibNamespaceMethodDispatcher.groovy 76.4706% <100.0000%> (+5.8824%) ⬆️
...roovy/org/grails/taglib/TagLibraryMetaUtils.groovy 54.6053% <ø> (ø)
...ails/taglib/TemplateNamespacedTagDispatcher.groovy 9.0909% <ø> (-6.2937%) ⬇️
.../org/grails/taglib/index/TagLibraryIndexEntry.java 100.0000% <100.0000%> (ø)
... and 20 more

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

Reading walks every jar on the classpath. A compiler that consulted the
index for each source file walked it once per file, while the one caller
that did cache it held the result in a static field, which carried one
project's tag libraries into the next compilation in the same Gradle
daemon and read them from the wrong class loader. It is read once per
class loader instead, which is once per compilation and no longer.

Asking what a single tag library declares is answered from its own
descriptor. It used to be answered by scanning every namespace and then
discarding the answer entirely if any namespace anywhere held a tag two
libraries declared, so one overridden tag left every tag library
undescribed.

A tag two libraries declare is now reported as known even though it
cannot say which of them will answer to it, so that it is never mistaken
for a misspelling, and the settings a build states about its tag
libraries are read alongside the descriptors.
Registration preferred the tags recorded when a tag library was compiled
over the tags the class has, to save discovering them by reflection as
the application starts. It saved nothing: the tag library class is
constructed before it is registered, and constructing it already reads
every tag by reflection and through the metaclass.

What it did add was a way for a descriptor left behind by an earlier
build to decide what a running application believes a tag library
declares. Reflection is authoritative at runtime; the index describes
what was true when the tag library was compiled and is used where that
is the question being asked.
A tag call takes more shapes than attributes and a body: nothing at all,
a body alone, a single value the tag reads under its own name, or
attributes only known once they have been evaluated. Only the shapes
evident in the source could be expressed as an invocation, so the rest
stayed dynamic.

The arguments are forwarded as written and adapted the same way, and in
the same order, that dynamic dispatch adapts them, including how it
treats an argument list matching none of the shapes it knows.
A tag call written inside a closure was never resolved. Groovy's
expression transformer does not descend into closures and documents the
override that reaches them, which was missing, so a tag called in a tag
body, in a withFormat block, or in anything else taking a block stayed
dynamic. That is where most tag calls in a real tag library are. Calls
in a constructor and in a field initialiser were not reached either.

A call written without a namespace is resolved too. It reaches a tag
only when nothing nearer answers to the name - not a method of the
class, not one it inherits, not a field, property, parameter or local -
and is then offered to the calling tag library's own namespace before
the default one, which is the order dispatch uses at runtime. Scope is
not tracked within a body: a name declared anywhere in one is treated as
claimed throughout it, which can leave a call dispatched dynamically but
never sends one somewhere the author did not write.

A tag two libraries declare, and a tag declared as a closure field, are
resolved as well. Neither can say which implementation will run, but
neither has to: the invocation selects the tag by name at runtime
through the same lookup, so registration order and overriding decide it
exactly as they did.

A namespace the build has declared as filled in while the application
runs is left alone entirely, which is the escape hatch for tags decided
at runtime rather than described at compile time.
A page reached every tag through the namespace dispatcher, selecting it
by name each time it rendered. Where the page names both the namespace
and the tag, and the index knows them, there is nothing left to decide,
so the expression is compiled into an invocation against the page's own
output context.

Only in a page that declares compileStatic. A page resolves a name
against the model it was rendered with before it reaches a tag library,
and that model is not visible when the page compiles, so a model
attribute named after a namespace would silently stop winning.
Declaring compileStatic is a page giving up dynamic resolution, and it
is what reserves the namespace names. A page that has not declared it
resolves its tags exactly as before.

Two things hold either way. A call written without a namespace is left
alone, for the same reason. And a name the page puts into its own
binding with g:set is that variable rather than a namespace, which the
page does say when it compiles.
Two things wrote descriptors: the build, generating the index from
source before compiling, and each tag library describing itself as it
compiled. Both reached the compile classpath and both were packaged, so
a tag library renamed or deleted between builds could keep being
described by the copy written class by class, which nothing cleans.

A tag library now describes itself only when nothing already has. That
is asked per tag library rather than per build, because generating the
index ahead of compilation cannot always describe every one of them: a
tag library referring to a class of the same project cannot be resolved
before that project is compiled and is skipped there. Treating the build
as authoritative for all of them would leave such a tag library
described by nothing at all.
Reporting one by default meant complaining about correct code: a
namespace holding some compiled tag libraries is not one holding all of
them, and a plugin built before descriptors existed contributes tags to
g without one. This framework calls such a tag itself, and warned about
it on every build. Nothing is reported unless the build states that its
tag libraries are all described.

What is checked is a call the source shows to be a tag: one naming its
namespace, and one written as markup. A call written without a namespace
is never checked, here or in the type checking extension, because such a
name may equally be a dynamic finder, an injected service method or
anything else contributed while the application runs; in a page it may
be part of the model. A namespaced expression in a page is checked only
where it is resolved, in a page declaring compileStatic.
Strictness was a JVM system property, where every other setting
governing how pages compile is read from the build. It is declared in
the grails extension instead, alongside the namespaces an application
fills in while it runs, and reaches the compiler as a resource written
next to the index: a declared input, so changing either recompiles what
depends on it, and not packaged, since it says how this project compiles
rather than what its tag libraries declare.

The index is also put on the page compilation classpath, without which a
page could not resolve a tag its own project declares - the source set's
class directories do not carry it, and waiting for processResources
would mean waiting for the compilation it exists to precede. It is
generated in one process with the Java the project is built with, rather
than one process per source directory, only the first of which cleared
what the last had written.
Covers what is resolved and what is left to dispatch, the precedence an
unqualified call follows, why a page is only resolved where it declares
compileStatic, the strictTags and dynamicTagNamespaces settings and what
each is for, and which tag libraries the build describes and which
describe themselves.
Whether a tag library had already been described was decided from the
tags read for it, so one declaring none looked undescribed and was
described a second time by the compiler, putting a duplicate descriptor
and a competing manifest into the class output. It is recorded from the
descriptor itself.
The guide said both forms were checked in every page, which the boundary
the previous change drew makes untrue of expressions: an expression is
checked only where it is resolved, in a page declaring compileStatic,
because elsewhere the receiver may come from the model. Markup is
unambiguously a tag whatever the page does and is checked everywhere.
Dropping SkipWhenEmpty, so that a build's settings are recorded whether
or not a project declares tag libraries, made the task fork a process
whenever grails-app/taglib merely existed. A project with no tag
libraries has no reason to carry the generator on its compile classpath,
so the fork failed the build outright.

Adds the functional test that found it, which also holds the plugin to
where the index is wired: on the classpath of this project's own
compilation, on the classpath of its pages, and travelling with the
artifact.
The only figure this work had was end to end, and covered removing the
metaclass writes and compiling calls into invocations together, so what
either was worth separately was unknown. That matters now that a page is
only resolved where it declares compileStatic: an application that has
not enabled it gets nothing from the page side, and what is left is the
calls written in its tag libraries and controllers.

Both sides of each comparison run against this branch, so the metaclass
writes are already gone from both, and only whether a call was compiled
into an invocation differs - which a build can still turn off per
namespace. Pages are compiled once and rendered repeatedly, since
compiling them per render measures the compiler instead.

Off unless GRAILS_TAGLIB_BENCH is set: a timing run is evidence, not a
pass or fail, and is no use running alongside other tests.
A tag library commonly refers to something the same project declares - a
service it injects, a base class it extends, a trait it carries - and
none of those exist as classes when the index is generated before
compilation. Such a tag library could not be read, so it was skipped and
left to describe itself as it compiled, into the class output, where a
second index competed with this one for the same path when packaged and
nothing removed it once the tag library was renamed or deleted: an
incremental compilation does not revisit a source that has not changed.

The generator now resolves a type this project declares to its own
source and compiles it alongside, which is what the compiler itself does
for types within one compilation. Nothing is skipped for that reason, so
the build describes every tag library and writes the index in one place,
which it rewrites in full each time.

Deliberately not answered with a stand-in class node. What is missing is
exactly what decides the description: a base class carries the
namespace, so a stand-in would file the tag library under g; a trait
carries tags, which would then be absent; and a parameter type decides
whether a method is a tag at all, which runtime asks by assignability. A
resolver sees a name and not the context it appears in, so it cannot
tell which of those it is being asked about. It would also answer for
the first candidate a star import offers, before the real one was tried,
and would invent a misspelled type rather than let it fail. Each of
those makes the index disagree with what the application does, which is
the one thing it must never do. A type not found in source is left
unresolved, and the tag library referring to it is skipped as before.
The index was generated once, before compilation, and that one artifact
had to serve two purposes it cannot both serve. Read from source, it
cannot describe a tag library referring to a type written in another
language or generated by the build, so packaging it handed a consumer a
partial description, and compiling pages against it hid those tags from
every page. A namespace missing some of its tags also made strict
checking report calls to tags that do exist.

There are two now, each with one job. The first is written before
compilation and used only to compile this project, so a call to a tag
the project declares still resolves as it compiles; it is neither
packaged nor used to compile pages. What it could not read is recorded
against the namespaces affected, and nothing in a namespace known to be
incomplete is ever reported, so strict checking cannot fail a build over
correct code. Where what was missed cannot even be attributed to a
namespace, none is treated as complete.

The second is written afterwards with this project's own classes on the
classpath, where every tag library resolves. That one is authoritative:
pages compile against it, it is packaged, and a project depending on
this one reads it. Each run replaces the directory.

It waits for the classes task rather than for the compile tasks, so that
it also waits for anything else writing into the class output, and
reaches the artifact and the runtime classpath directly rather than
through processResources, which the classes task waits for.

Resolving a type this project declares from its Groovy source stays, as
what it is: a way to describe more before compilation, not the thing
correctness rests on.
The index is regenerated in full each build and nothing writes
descriptors into the class output, so a renamed or deleted tag library
should not be able to linger. Nothing showed that: the core build builds
its test projects once, and what is at stake is what a second build
does, since Gradle does not recompile a source that has not changed and
so would never revisit anything written per class.

Adds an end-to-end project that builds an application twice without a
clean, against the artifacts this repository publishes, and asserts that
a deleted tag library, a renamed one and a removed tag are each gone
from the index beside it, from the manifest naming it, and from the jar
- and that nothing has written a second index into the class output.

It drives the nested build through the repository's own wrapper rather
than Gradle TestKit, which puts Gradle's Groovy 4 on the test classpath
and cannot compile against the Groovy 5 Spock this repository builds
against.

The audit also stopped short of this build's output directories, as it
already does for every other build in the repository that has its own.
The settings shared a directory with the descriptors, and that directory
is on the runtime classpath so that a page compiled while the
application runs can resolve its tags. An executable archive is built
from the runtime classpath by copying whole directories, so excluding
the settings on the archive tasks could not reach them: a boot jar or a
war carried them regardless, and a consumer would inherit settings
describing how this project is compiled.

They are written to a directory of their own now, which is on the
classpaths that compile this project and its pages and on nothing else.
There is nothing left for an exclusion to have to catch. The end-to-end
test asserts it against a real archive, and fails against the previous
arrangement.

Both indexes now take everything they must agree on from one
configuration by task type. Configuring them one at a time would let the
index this project compiles against describe a different set of tag
libraries from the one it publishes, with nothing to say so.
The namespace of a tag library that could not be described was matched
out of the raw source, so one named in a comment or a string was taken
for the declaration. That recorded the wrong namespace as incomplete and
left the real one looking complete - which is exactly when a call to a
tag that does exist is reported as one that does not.

Parsed to the conversion phase instead, which builds the tree and stops
before resolving anything, so a type this project has not compiled yet
cannot make it fail. Only a namespace the class states itself is
trusted: one inherited from a base class cannot be read when whether
that base class resolved is the very thing in doubt, and a file
declaring more than one claims neither. Anything else leaves every
namespace incomplete, which costs a diagnostic rather than inventing an
error.
The page comparison put a statically compiled page against a dynamic
one, so the difference included compiling the page statically and not
only rewriting its tag calls. Both sides are statically compiled now and
only the rewriting varies, turned off through the namespace declaration
a build can make - which is what the tag library comparison already did.

The page figure moves from -70% to -66% per tag call, so a few points of
it were never the rewriting.
Which namespace is missing tags was read from whichever class in the
file declared one, tag library or not. A helper class beside a tag
library could therefore supply the name, recording a namespace nothing
was missing from and leaving the one the tag library is really in
looking complete - which is when a call to a tag that does exist gets
reported as one that does not.

A namespace is claimed only where the source leaves no room for doubt:
one tag library in the file, declaring its own namespace as a constant.
A namespace field on another class, a second tag library, an inherited
namespace whose base class may not have resolved, and none stated at all
each yield nothing, and every namespace is then treated as incomplete.
A war and an executable archive are built from the runtime classpath,
which already carries the descriptors into wherever that archive puts
classes. Adding them to every archive task as well put a second copy at
the archive root, where nothing reads it and where it would disagree
with the first as soon as one was rebuilt. A plain jar is not built from
the runtime classpath, so it is the one that needs them added.

Covered by building a war, which is the shape that exposed it: the
descriptors are in WEB-INF/classes where a page compiled at runtime
reads them, the settings are nowhere in it, and no descriptor is carried
twice.
processResources no longer carries the index, so the import went with
the configuration block that did.
@codeconsole
codeconsole requested review from borinquenkid, jdaugherty, matrei and sbglasius and removed request for jdaugherty August 11, 2026 22:54
The index written after compilation reads this project's classes so that
a tag library referring to a service, base class or trait of the same
project can be described. It took the whole source set output to do
that, which also waits for anything else writing into it.

A view compiler registers its own output directory into that output and
runs after the classes task, so it cannot name the classes task as its
producer without a cycle - which leaves anything reading the whole
output consuming a directory nothing declares it produces, and Gradle
rejects that. It failed every project that compiles both pages and JSON
or markup views.

The class directories are taken instead, with a dependency on the
classes task so that everything writing into those - the ast classes are
copied in after compiling, for one - is still waited for. Compiled views
are no use in resolving what a tag library declares.
@testlens-app

testlens-app Bot commented Aug 12, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

CI / Functional Tests (Java 25, indy=false, shard 1) > :grails-test-examples-app1:integrationTest

Test Runs Flakiness
AsyncPromiseSpec > async task handles success without error ❌ ✅ 1% 🟡

🏷️ Commit: 91f8d3d
▶️ Tests: 29495 executed
⚪️ Checks: 77/77 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.

1 participant