Resolve tag calls at compile time instead of through the metaclass - #16134
Resolve tag calls at compile time instead of through the metaclass#16134codeconsole wants to merge 63 commits into
Conversation
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.
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.
processResources no longer carries the index, so the import went with the configuration block that did.
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.
jdaugherty
left a comment
There was a problem hiding this comment.
Review
I checked the branch out and built/ran :grails-gsp, :grails-taglib, :grails-web-taglib, :grails-test-suite-web, :grails-test-suite-uber, :grails-fields, :grails-cache, the Gradle-plugin specs, and codeStyle / check. Everything passes. The findings below are things the test suite doesn't cover; the three blocking ones I reproduced by compiling real sources against this branch.
What's good
The TagDiscoveryRules / TagMethodView split, so AST discovery and reflective discovery cannot drift, is the right shape for this. TagMethodBinding pre-classifying parameters is a clean win over re-reading Method.getParameters() per invocation. TagLibraryIndexGenerator's SourceRootClassNodeResolver — reading a collaborator's source rather than substituting a placeholder — and the refusal to guess a namespace that isn't a constant are both correctly conservative. compiledTags.adoc is unusually thorough, and the two-index rationale in GroovyPagePlugin is well argued. The Limitations section in the description is honest; it just needs the three items below added to it.
Blocking
1. Unqualified rewriting ignores DefaultGroovyMethods and extension modules. Reproduced. Given @TagLib class DgmTagLib { static namespace = 'g'; def sleep(Map attrs) { out << 'slept' } }, a controller's sleep(100) compiles to CompiledTagInvocation.invokeArguments(lookup, "g", "sleep", 100). Before this PR that call reached DefaultGroovyMethods.sleep(Object, long) — methodMissing was never involved, because DGM methods are on the metaclass. declaresMember only inspects the ClassNode. Details inline on CompiledTagCallRewriter.java.
2. The self-describing path never fires for conventional tag libraries. Reproduced. TagLibArtefactTypeAstTransformation is a local transform bound to @TagLib; a conventional grails-app/taglib/FooTagLib.groovy carries no annotation and gets @Artefact added by GlobalGrailsClassInjectorTransformation far too late for the local transform to be collected. Compiling an unannotated tag library emits no descriptor at all. That makes the claim in compiledTags.adoc — "each tag library describes itself as it is compiled instead" — wrong for the normal case. Details inline.
3. @Artefact('Controller') classes outside grails-app/controllers are never rewritten. Reproduced: the class file shows implements grails.artefact.gsp.TagLibraryInvoker but contains no CompiledTagInvocation reference, while the same class under grails-app/controllers does. CompiledTagCallTransformation has no TransformWithPriority, and local transforms run after every global in the same phase. Details inline.
High
4. TagLibraryInvoker.methodMissing changed dispatch semantics (inline).
5. Error type changed. A tag the index knows but the runtime hasn't registered — plugin excluded, unit test, nonEnhancedTagLibClasses — now throws GrailsTagException from TagOutput.captureTagOutput:54 rather than MissingMethodException. Anything catching MissingMethodException, or probing with respondsTo, behaves differently.
6. GroovyPage.methodMissing can NPE on a null lookup (inline).
7. strictTags has no cross-jar completeness signal (inline).
Medium
- Shared AST singletons (
VariableExpression.THIS_EXPRESSION,ConstantExpression.NULL) reused as generated nodes — inline. - Dead API added:
TagLibraryIndex.lookup,getAmbiguousTagNames,getTagNamesForClass,isClassDescribed,getIncompleteNamespaces;TagLibraryAstDiscovery.findTagNames(a near-duplicate offindTags);TagLibraryIndexWriter.write(…, Collection<String>). See also theKindcomment inline. FORMAT_VERSION = 2for a format introduced by this PR.TagLibraryIndexFilesduplicates the on-disk format as string literals across a module boundary — inline.- Gradle configuration-cache risk — inline.
- The packaged index never reaches the test runtime classpath — inline.
sourceEncodingis never populated from the project's compile encoding — inline.index.propertiesmanifest read-modify-write and stale entries — inline.TagLibraryIndex.listDescriptorsperforms one full classpath scan per described tag library — inline.
Low / hygiene
- Orphaned duplicate javadoc in
TagMethodInvoker— inline. - Duplicate javadoc block on
TagLibraryAstDiscovery.findTags— inline. TagLibraryInvoker.developmentModeandNamespacedTagDispatcher.developmentModeare now written and never read. The trait field is materialised into every controller and tag library (grails_artefact_gsp_TagLibraryInvoker__developmentModeis visible in the bytecode), plus anEnvironment.isDevelopmentMode()call per construction.TagLibrary.initializeTagLibrary()is now an empty@PostConstructmethod.@Deprecatedon the whole ofTagLibraryMetaUtils— inline.- API removals with no upgrade note:
NamespacedTagDispatcher.initializeMetaClass(),NamespacedTagDispatcher.registerTagMetaMethods(ExpandoMetaClass),TemplateNamespacedTagDispatcher.registerTagMetaMethods; andGroovyPagesMetaUtils.registerMethodMissingForGSPsilently became a no-op. Neither those nor the broader "tags and namespaces are no longer installed onto metaclasses" change — which breakstagLib.metaClass.respondsTo('someTag')and similar — appear inupgrading80x.adoc.
23/24.LocalNameCollector: deprecated Groovy API, and missedcatchparameters — inline. - The docs deprecate
Closure hello = { … }, but essentially every real tag library in this monorepo writesdef hello = { attrs -> }(grails-fields, grails-cache, grails-redis, grails-spring-security). The warning does fire for that form onceTagLibraryTransformerhas forced the property type, so please show it in the upgrade guide — that is the form people have to change. TagDispatchBenchmarkSpec— inline.grails-maildrive-by — inline.TagDiscoveryRulesalso changes runtime discovery — inline.ControllerTagCallRewriteSpeccovers neither real controller shape — inline.
Scope
69 files, +7.8k, mixing metaclass removal, an index format, a compiler rewrite, a Gradle task, docs and an e2e project. The metaclass removal (0.5213 → 0.4699 ms/req) stands on its own, is independently measurable and carries almost none of the risk above. Splitting it out would make the compile-time rewrite — which carries all of it — far easier to assess and to revert independently if a regression like #1 turns up in the field.
| * Whether the class, or anything it inherits from, already answers to a name at all. An | ||
| * unqualified call reaches a tag only when nothing else does, so here a method counts too. | ||
| */ | ||
| private boolean declaresMember(String name) { |
There was a problem hiding this comment.
Blocking — unqualified rewriting ignores DefaultGroovyMethods and extension modules.
declaresMember only inspects the ClassNode's own and inherited methods/fields/properties. It cannot see anything the metaclass supplies, so DGM and extension-module methods never claim their name.
Reproduced on this branch. Tag library:
@TagLib
class DgmTagLib {
static namespace = 'g'
def sleep(Map attrs) { out << 'slept' }
}Controller, compiled with that descriptor on the classpath:
class DgmController {
def index() { sleep(100); 'done' }
}The class file contains CompiledTagInvocation.invokeArguments(lookup, "g", "sleep", 100). Before this PR the call reached DefaultGroovyMethods.sleep(Object, long) — methodMissing was never involved, because DGM methods are registered on the metaclass. So this is a silent behaviour change, not a missed optimisation.
RESERVED_NAMES is {"body", "render"}. The exposed surface is every DGM name that applies to Object: print, println, printf, sprintf, sleep, with, tap, use, dump, inspect, is, identity, each, find, every, any, collect, grep, sort, sum, min, max, getAt, putAt, asType, respondsTo, hasProperty, addShutdownHook — plus anything an extension module a project or plugin registers.
It is worse than the g namespace alone suggests: unqualifiedNamespaceOf tries callerNamespace before g, so a tag library whose own namespace declares a tag called each breaks every unqualified each { } in every other tag library of that namespace.
The upgrade guide covers doWithDynamicMethods but not this, and this one is far easier to hit by accident because it needs no dynamic registration at all — just a name collision.
Options, roughly in order of preference:
- seed
RESERVED_NAMESfromDefaultGroovyMethods/DefaultGroovyStaticMethodsplusExtensionModuleScanner, so any name the metaclass would answer is left alone; - consult
GroovySystem.getMetaClassRegistry().getMetaClass(Object)for the receiver's meta-methods; - make unqualified rewriting opt-in, and rewrite only namespaced calls by default.
Whichever way it goes, this needs a test: a tag whose name collides with a DGM method, asserting the DGM method still wins.
There was a problem hiding this comment.
Fixed. RESERVED_NAMES is now seeded from GroovySystem.getMetaClassRegistry().getMetaClass(Object), so every name the metaclass answers to for an arbitrary receiver — DGM plus any extension module on the compiling classpath — is left alone. Only unqualified calls are affected; a call naming its namespace is unchanged.
Not hypothetical, as you say: grails-fields already declares f:with. GroovyMethodNameCollisionSpec covers it, and I checked it bites — with the old two-name set both with and each fail, with the fix both pass, and the guard cases (namespaced call, non-colliding tag) pass either way.
| return null; | ||
| } | ||
| ArgumentListExpression invocationArgs = new ArgumentListExpression(); | ||
| invocationArgs.addExpression(new MethodCallExpression(VariableExpression.THIS_EXPRESSION, |
There was a problem hiding this comment.
VariableExpression.THIS_EXPRESSION (here and at outputContext()) and ConstantExpression.NULL (in attributesAndBody) are process-wide singletons, and this reuses them as generated nodes at every rewritten call site in every class in the compilation.
StaticTypeCheckingVisitor.storeType writes INFERRED_TYPE node metadata onto the expression it visits, so a @CompileStatic page or tag library will stamp inferred types onto the shared instances and the last write wins. I didn't manage to make it misbehave in a quick test, but it's a latent hazard rather than a safe idiom — note that TagLibraryTransformer, doing the same job a few files over, deliberately uses new VariableExpression("this") for exactly this reason.
Suggest new VariableExpression("this") and new ConstantExpression(null) per call site.
There was a problem hiding this comment.
Fixed — new VariableExpression("this") and new ConstantExpression(null) per call site, matching TagLibraryTransformer.
| */ | ||
| @CompileStatic | ||
| @GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) | ||
| class CompiledTagCallTransformation implements ASTTransformation { |
There was a problem hiding this comment.
Blocking — @Artefact('Controller') classes outside grails-app/controllers are never rewritten.
Reproduced on this branch:
// src/main/groovy/pkg/AnnotatedController.groovy
@Artefact('Controller')
class AnnotatedController {
def index() { g.createLink(controller: 'book') }
}javap shows implements grails.artefact.gsp.TagLibraryInvoker — the trait is there — but the class file contains no CompiledTagInvocation reference. The same class under grails-app/controllers is rewritten.
The reason is transform ordering. In ASTTransformationVisitor.addPhaseOperations, global transforms are registered as phase operations before the local-transform visitor, so every global at CANONICALIZATION runs before every local one. The convention path works because GlobalGrailsClassInjectorTransformation is itself global and has a high TransformWithPriority, whereas this transform doesn't implement TransformWithPriority at all and so defaults to 0 — last among globals. That is accidentally correct for the convention path and unavoidably wrong for locally-annotated artefacts, where trait injection happens in ArtefactTypeAstTransformation after all globals have finished.
So the class javadoc
Runs after trait injection, since whether a class can call tags is only settled once its traits have been applied.
isn't guaranteed — it holds for one of the two ways to declare a controller.
Two things to fix:
- implement
TransformWithPriorityand add an entry toorg.apache.grails.common.compiler.GroovyTransformOrder, as every other Grails global transform does. Relying on an undeclared default of0is exactly the fragility that registry exists to prevent. - decide what to do about
@Artefact-annotated artefacts. Either hook the rewrite intoArtefactTypeAstTransformationthe wayTagLibArtefactTypeAstTransformationalready does for tag libraries, or document that the fast path is convention-only.
Either way the behaviour is currently a silent, undetectable difference between two source layouts that are meant to be equivalent.
There was a problem hiding this comment.
Half fixed, half documented.
TransformWithPriority is implemented with a COMPILED_TAG_CALL_ORDER slot in GroovyTransformOrder, and CompiledTagCallTransformationOrderSpec pins the relationship to artefact trait injection rather than the number.
The @Artefact('Controller') gap I documented rather than fixed. Hooking into ArtefactTypeAstTransformation would mean grails-core referencing the GSP rewriter, and rewriting after type checking risks @CompileStatic. It degrades to dynamic dispatch, so behaviour is correct — it just misses the faster path. ControllerTagCallRewriteSpec pins the annotated case so the difference is explicit, and the guide says so.
| * <p>Failure to write is never fatal: the index is an optimisation, and a missing descriptor | ||
| * degrades to the runtime resolution that applies when a tag library is registered dynamically. | ||
| */ | ||
| protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) { |
There was a problem hiding this comment.
Blocking — this never runs for conventional tag libraries.
TagLibArtefactTypeAstTransformation is a local transform, bound to @TagLib via @GroovyASTTransformationClass on grails.gsp.TagLib. A conventional grails-app/taglib/FooTagLib.groovy carries no annotation in source: GlobalGrailsClassInjectorTransformation adds @Artefact('TagLib') during CANONICALIZATION, long after ASTTransformationCollectorCodeVisitor ran at SEMANTIC_ANALYSIS, so this transform is never collected and writeIndexEntry is never called.
Reproduced. Compiling grails-app/taglib/pkg/DgmTagLib.groovy (no annotation) with a plain CompilationUnit and a target directory emits no META-INF/grails/taglibs/ output. Adding @grails.gsp.TagLib to the same class emits the descriptor. Grails' own tag libraries all carry @TagLib, which is why the framework's own build looks like it works.
Consequences:
- Every plugin that declares tag libraries by convention and doesn't apply the Grails GSP Gradle plugin publishes no descriptors —
grails-fields,grails-cache,grails-redisandgrails-spring-securityin this repo are all in that position. buildOwnsIndex()is designed around a fallback that mostly doesn't exist.- The claim in
compiledTags.adocthat a tag library "describes itself as it is compiled" when no build writes the index is wrong for the normal case (separate comment there).
It degrades safely — a missing descriptor just means dynamic dispatch — so this isn't a correctness bug. But the design and the docs both assume a fallback that only covers annotated tag libraries, and that assumption should either be fixed (have GlobalGrailsClassInjectorTransformation drive the index write for artefacts it recognises) or stated plainly.
There was a problem hiding this comment.
Confirmed, and reworded rather than fixed. The guide now says the fallback reaches @TagLib-annotated tag libraries only, that a convention-declared one contributes no descriptor without the Gradle plugin, and that this degrades to dynamic dispatch rather than breaking. Also noted that a self-written descriptor is never removed, which you raised separately.
| exist is left out, as it would be by the compiler. | ||
|
|
||
| Where no build writes the index — a plain Groovy compilation, or one that does not apply the Grails | ||
| Gradle plugin — each tag library describes itself as it is compiled instead, which makes it resolvable |
There was a problem hiding this comment.
This claim doesn't hold for the normal case.
The self-describing path is TagLibArtefactTypeAstTransformation, a local AST transform bound to @TagLib. A conventional grails-app/taglib/FooTagLib.groovy has no annotation in source — @Artefact is added by GlobalGrailsClassInjectorTransformation at CANONICALIZATION, after local transforms were collected — so nothing describes it. I verified this: compiling an unannotated tag library emits no descriptor; adding @grails.gsp.TagLib to the same class emits one.
So "a plain Groovy compilation, or one that does not apply the Grails Gradle plugin" describes only tag libraries that were annotated by hand. Please either make the fallback cover convention-based tag libraries or reword this paragraph to say what it actually does.
There was a problem hiding this comment.
Reworded — it now says what it actually does, including that the fallback misses convention-declared tag libraries and that the result is dynamic dispatch rather than a failure.
|
|
||
| @Override | ||
| public void visitForLoop(ForStatement forLoop) { | ||
| if (forLoop.getVariable() != null) { |
There was a problem hiding this comment.
ForStatement.getVariable() is deprecated in Groovy 5 — javac reports it on every build of this module:
Note: .../LocalNameCollector.java uses or overrides a deprecated API.
The replacement is getIndexVariable() / getValueVariable() (and the classic form now carries both), so this also currently misses the index variable of a classic for (int i = 0; ...) loop.
Two other gaps in the same collector, both in the "leaves a call dynamic" direction so neither is unsound, but both cost the optimisation and are cheap to close:
catchparameters.CodeVisitorSupport.visitTryCatchFinallyvisits the catch body but not itsParameter, so acatch (SomeException message)doesn't claimmessage.- an implicit-
itclosure.visitClosureExpressiononly adds parameters whenisParameterSpecified(), soitis never collected.
Worth a visitCatchStatement override and adding "it" for the implicit case.
There was a problem hiding this comment.
All three fixed — getIndexVariable()/getValueVariable() in place of the deprecated getVariable() (which also picks up the classic loop's index), a visitCatchStatement override for catch parameters, and it for an implicit-parameter closure. The javac deprecation note this module was printing is gone.
| * the environment, where this build bridges only a few named properties into it. | ||
| */ | ||
| @Requires({ System.getenv('GRAILS_TAGLIB_BENCH') }) | ||
| class TagDispatchBenchmarkSpec extends Specification implements TagLibUnitTest<ApplicationTagLib> { |
There was a problem hiding this comment.
241 lines of hand-rolled timing harness in src/test, @Requires-gated on an environment variable so it never runs and nothing keeps it compiling-and-correct beyond compileTestGroovy.
This repo already has JMH benchmark infrastructure. A hand-rolled loop with a warmup count and a median can't control for JIT state, GC, or dead-code elimination the way JMH's blackholes and forks do — and the spec's own comment ("reported rather than asserted: a timing is evidence, not a contract") concedes that it produces no verdict.
Suggest moving it to the JMH module, where the numbers in the PR description could then be reproduced by anyone, or dropping it and keeping the measurements in the description.
There was a problem hiding this comment.
Deleted. Porting it properly would mean giving grails-benchmarks the taglib and Spring dependencies it doesn't have, which is more scope rather than less. The measurements stay in the description.
| when: 'a class carrying the tag library invoker trait, as a controller does' | ||
| byte[] compiled = compile(''' | ||
| import grails.artefact.gsp.TagLibraryInvoker | ||
| class TagCallingController implements TagLibraryInvoker { |
There was a problem hiding this comment.
This hand-writes implements TagLibraryInvoker, which is the one shape a controller never has in real code. It therefore can't catch the ordering problem described on CompiledTagCallTransformation: a real @Artefact('Controller') class gets the trait from a local transform that runs after this global one, and is not rewritten (I verified this).
Please add two cases:
- a class under a
grails-app/controllers/...path, which exercises theGlobalGrailsClassInjectorTransformationroute that works today; - a class carrying
@Artefact('Controller')outside that directory, which currently does not get rewritten.
The second one should fail as written today, which is the point.
There was a problem hiding this comment.
Both cases added, and then one removed again — worth explaining.
The @Artefact('Controller') case is there and passes, confirming what you found: the trait is present, the call is not rewritten.
The grails-app/controllers case I removed. It compiled a file into a temporary grails-app/controllers directory and relied on the artefact injector recognising it by location, which turns on where the compilation happens rather than what is compiled. It passed on macOS and failed on Ubuntu and Windows in every CI run and again on a rerun, and I couldn't reproduce it locally in any configuration. The convention path is exercised for real by every application under grails-test-examples; the spec now records the gap rather than leaving it silent.
| static namespace = 'text' | ||
|
|
||
| def newLine = { | ||
| def newLine(Map attrs) { |
There was a problem hiding this comment.
Why is this here? It's a change to a different module's published tag signature, in a PR about compile-time tag resolution, with no test and no mention in the description.
def newLine = { out << '\n' } is an Object-typed field, so it isn't picked up by TagLibraryAstDiscovery's Closure-typed field scan, and this file isn't under grails-app/taglib so TagLibraryTransformer never forced the type or generated wrappers for it. As far as I can tell it therefore wasn't in the index either before or after, and nothing in this PR requires the change.
If it's an unrelated fix for a text:newLine tag that was already broken, it deserves its own commit and a spec. If this PR does require it, that implies something about zero-parameter closure tags that should be explained — and tested — because other plugins will have the same shape.
There was a problem hiding this comment.
Fair — it had no test and no explanation. Both now.
PlainTextMailTagLibSpec covers text:newLine. I checked, and you were right that nothing required the change: the closure form dispatches fine either way. It was converted because declaring a tag as a closure is deprecated as of this PR and now warns, and the framework's own tag libraries shouldn't trip a warning the framework introduces. That reasoning is on the spec.
| /** | ||
| * Names that are Groovy or Object plumbing on any class. | ||
| */ | ||
| private static final Set<String> LANGUAGE_METHOD_NAMES = Set.of( |
There was a problem hiding this comment.
Worth calling out explicitly in the description: extracting these rules also changes runtime tag discovery, which is what DefaultGrailsTagLibClass and TagMethodInvoker.INVOKABLE_METHODS_BY_NAME are built from. Three differences from the code this replaces:
equals/hashCode/toString/getProperty/setProperty/getMetaClass/setMetaClassare now excluded by name, where beforeObjectandGroovyObjectmembers were excluded by full signature. A tag calledequals(Map attrs)was previously discoverable and no longer is.isPropertyAccessordrops the boolean-return-type check onis*, so any zero-argis*is now an accessor.- Names containing
$are newly excluded.
I worked through all three and couldn't construct a case that behaves differently in practice — (1) and (2) are unreachable because the shape check rejects them anyway, and (3) only removes synthetic trait accessors that were never tags. So I think this is safe. But it is a change to runtime behaviour riding along in a compile-time feature, and reviewers shouldn't have to derive that themselves.
Please note it in the description, and add a spec pinning the rules directly (TagDiscoveryRulesSpec covers the AST view — a reflective equivalent asserting the same verdicts for the same shapes would make the "the two cannot drift apart" claim in the class javadoc actually enforced).
There was a problem hiding this comment.
Noted in the description, and the three differences are now pinned — an Object member name, a non-boolean is* accessor, and a name containing $ — as rows in TagDiscoveryRulesSpec.
One correction: that spec isn't AST-only. Each row runs classifyFromTree (AstTagMethodView) and classifyFromClass (ReflectedTagMethodView) over the same source and asserts fromTree == fromClass before asserting the verdict, so the "cannot drift apart" claim is already enforced across the matrix — the new rows just extend it to these three.
|
I posted the AI review, I'm not sure I agree with splitting it. I think it found enough real issues that we can use this to iterate on the review. @codeconsole |
An unqualified call to a DefaultGroovyMethods method - with, each, print and the rest - reached that method directly and never went near methodMissing. Rewriting it into a tag invocation because a tag library happened to declare a tag of the same name silently sent the call somewhere it was never written to go. grails-fields already declares f:with, so the collision is not hypothetical. Reserve every name the metaclass answers to for an arbitrary receiver, which covers DefaultGroovyMethods and any extension module on the compiling classpath. A call that names its namespace is unaffected.
…name A page resolves an unqualified name through a real methodMissing rather than one installed onto its metaclass, and the field it resolves against is documented as null until the page is initialised. Reaching the lookup regardless arrives at the same missing-method answer, but only because a dynamic call on a null receiver yields no tag library; say it instead, and pin the behaviour with a spec.
The rewriting has to run after the transforms that apply the traits a class calls tags through, and did - but only because a transform that declares no priority defaults to zero, which happened to put it last among the globals. A transform added later with the same default would have displaced it silently. Give it a slot in GroovyTransformOrder, as every other Grails global transform has, and pin the relationship to artefact trait injection.
…alls onto metaclasses The guide claimed every tag library describes itself when no build writes the index. That path is a local AST transform bound to @taglib, so it reaches annotated tag libraries only; one declared by convention is recognised as an artefact too late to describe itself. Say so, along with the descriptors that path leaves behind when a tag library is renamed. Deprecate the metaclass-installing methods individually rather than the class that holds them: methodMissingForTagLib is the dynamic dispatch path and is not going anywhere. Record the removed and no-op metaclass API in the upgrade guide, along with the closure tag form tag libraries are actually written in. Also drop LocalNameCollector's use of a deprecated Groovy API, which the build was reporting, and collect the two names it was missing - a catch parameter and a closure's implicit it.
The descriptor format ships for the first time in this release, so starting it at two makes the number mean nothing later. Nothing outside these files reads the constant. Also remove the javadoc left above its replacement on FRAMEWORK_METHOD_NAMES and on findTags.
findTagNames duplicated findTags with the same two loops and the same declaring-class guard, and getIncompleteNamespaces exposed a field the isNamespaceComplete question already answers. Neither had a caller anywhere, in main code or in a test.
A test source set builds its runtime classpath from the main output rather than from the main runtime classpath, so the packaged index never reached it: a page rendered by a test resolved its tags against an index missing the application's own tag libraries. Add it to the test and integrationTest runtimes, and assert it. The generator now reads sources with the encoding the project compiles with rather than always UTF-8, and the settings file is written through the key constants rather than through literals that repeat them. Generated AST nodes are built per call site instead of reusing the process-wide THIS_EXPRESSION and NULL singletons, which carry node metadata that a statically compiled class writes to. Drop the developmentMode fields nothing reads - one of which the trait materialised into every controller and tag library - and the empty @PostConstruct that remained once tags stopped being installed onto metaclasses. Also cover the build with a configuration cache run, which the index tasks turn out to survive, and pin the on-disk format on both sides of the module boundary that has to restate it.
…re missing Descriptor URLs are resolved against the manifest that names them rather than searched for on the classpath, which turned one full classpath walk per tag library into none. Pin the three ways these discovery rules differ from the ones they replaced - an Object member name, a non boolean is accessor, and a name containing a dollar - through both the tree and the compiled class, so the claim that the two views cannot drift is enforced for them too. Document the tag call that a controller annotated outside grails-app/controllers does not get compiled, with a spec pinning both shapes, and the GrailsTagException a resolved call now reports for a tag the runtime has not registered. Cover grails-mail's text:newLine, which had no test before its signature was changed here, and say why it was changed: it works either way, but a closure tag now warns and the framework should not trip its own warning. Drop getAmbiguousTagNames, which restates isAmbiguous, and the benchmark spec that was gated off by an environment variable and so never ran.
…e review Every tag library compiled into one directory adds itself to a manifest they all share, unguarded. Writing 32 of them at once lost 29: each writer put back a copy that had never seen the others. A lost entry is silent - the descriptor is there, nothing names it, so the tag library is never discovered. Guard the read-modify-write with a monitor for threads of this JVM and a file lock for a second process, and prove it with a spec that fails without either. Pin what a class calling a tag through methodMissing now gets back: both ways of declaring a tag capture output, where a method-declared one used to return its own return value. Record that in the upgrade guide. Say plainly when strictTags can be used - a namespace only some of its jars described cannot be checked, which is the normal state of g - and correct the claim that a tag's kind decides whether a call is resolved. It does not; both forms are dispatched by name.
request.withFormat { form multipartForm { } } was compiled into a call to
the g:form tag. form there is a format in a DSL: a closure is given a
delegate when it runs, and a name the delegate answers to is the
delegate's, not a tag library's. Nothing about that is knowable when the
closure is compiled, so an unqualified call inside one is left alone. A
call naming its namespace is unaffected, which is how a tag body keeps
the faster path.
Restore TagLibrary.initializeTagLibrary. It does nothing now, but a trait
method is part of the binary contract - Groovy weaves a call to the
generated helper into every implementing class, so removing it raised
NoSuchMethodError for every tag library compiled against an earlier
release, asset-pipeline's among them.
An import left unused when the manifest read moved to a channel, a blank line left by removing a method, a groovy.lang import group that should not be separated from org.codehaus.groovy, and an import moved out of its group when @PostConstruct was restored.
It compiled a file into a temporary grails-app/controllers directory and relied on the artefact injector recognising it by location. That passed on macOS and failed on Ubuntu and Windows, in every CI run and again on a rerun, and I could not reproduce it locally in any configuration - alone, with --rerun-tasks, or with the whole module suite. The behaviour is not in doubt: every application under grails-test-examples declares its controllers that way and has its tag calls compiled. What is left here keys on the trait, which is what the rewriting actually reads, and on the annotated case that the trait reaches too late.
✅ All tests passed ✅🏷️ Commit: 30cac71 Learn more about TestLens at testlens.app. |
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
Calling tags
In a tag library or a controller:
A call that names its namespace is compiled the same way inside a closure — a tag body, a
withFormatblock, anything taking a block — as outside one, as is one in a constructor or a field initialiser. A call written without a namespace inside a closure is not: a closure is handed a delegate when it runs, and a name the delegate answers to is the delegate's rather than a tag's.request.withFormat { form multipartForm { } }is the case that settles it —formthere is a format in a DSL, not theg:formtag.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
Closurefield, 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:Declaring it reserves the namespace names for tag libraries there.
grails.views.gsp.compileStaticapplies 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 } }dynamicTagNamespacesturns 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
Closurefield 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: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
ExpandoMetaClassread-lock contention.Every caller used to mutate its own
ExpandoMetaClassthe 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:
8.0.xCompiling the calls is worth this much again on top, measured with metaclass removal present on both sides and only the rewriting varying:
Where the description comes from
Under the Grails Gradle plugin the index is written twice, because the two things reading it need different guarantees.
generateTagLibraryIndexruns 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.packageTagLibraryIndexruns 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 annotated@TagLibdescribe itself as it compiles. That fallback does not reach a tag library declared by convention: an unannotated class undergrails-app/taglibis recognised as an artefact later in the compilation than the descriptor is written, so without the Gradle plugin it contributes none. A tag with no description is dispatched dynamically, so nothing breaks; it simply does not take the faster path.What is not rewritten
dynamicTagNamespacesgis that thingcompileStatic<g:set>with,each,printand the rest ofDefaultGroovyMethods, plus any extension module on the compiling classpath. Those are real methods on every receiver, so a tag of the same name must not capture the call@Artefact('Controller')outsidegrails-app/controllers, which gains the ability to call tags later in the compilation than the rewriting runsLimitations
compileStaticpage. That is what declaring it means there. A page that has not declared it is unaffected.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 indynamicTagNamespaces, or call the tag with its namespace.tagLib.someTag(attrs, null)works. A running application does not depend on this.DefaultGrailsTagLibClassis built from. Three differences from the code it replaces:equals/hashCode/toStringand theGroovyObjectmembers are excluded by name rather than by full signature; a zero-argumentis*method is an accessor regardless of return type; and a name containing$is excluded. None is reachable by a tag that would otherwise have been discovered — the shape check rejects all three anyway — and each is pinned inTagDiscoveryRulesSpecthrough both the tree and the compiled class.GrailsTagExceptionrather thanMissingMethodException. This arises where the index knows a tag but the running application has not registered it — a plugin excluded, a tag library innonEnhancedTagLibClasses, a unit test mocking only some. Code catchingMissingMethodExceptionaround a tag call, or probing withrespondsTo, is affected. A call into an undescribed namespace still reportsMissingMethodException.