Skip to content

Add @GrailsCompileStatic support for controllers that call tag libs#15669

Open
jdaugherty wants to merge 1 commit into
feature/taglib-method-actionsfrom
feature/compileStaticTagLibs
Open

Add @GrailsCompileStatic support for controllers that call tag libs#15669
jdaugherty wants to merge 1 commit into
feature/taglib-method-actionsfrom
feature/compileStaticTagLibs

Conversation

@jdaugherty
Copy link
Copy Markdown
Contributor

Fixes #14023

@jdaugherty jdaugherty marked this pull request as ready for review May 18, 2026 05:09
@jdaugherty jdaugherty force-pushed the feature/compileStaticTagLibs branch 2 times, most recently from 14b0664 to 436b082 Compare May 18, 2026 14:03
@jdaugherty jdaugherty force-pushed the feature/compileStaticTagLibs branch from 436b082 to e5d44d2 Compare May 18, 2026 14:45
@testlens-app
Copy link
Copy Markdown

testlens-app Bot commented May 18, 2026

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

Check Project/Task Test Runs
CI / Functional Tests (Java 21, indy=false) :grails-test-examples-scaffolding:integrationTest UserControllerSpec > User list ❌ ✅

🏷️ Commit: e5d44d2
▶️ Tests: 1783 executed
⚪️ Checks: 31/31 completed


Learn more about TestLens at testlens.app.

Copy link
Copy Markdown
Contributor

@jamesfredley jamesfredley left a comment

Choose a reason for hiding this comment

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

Nice work on this - it tackles a real long-standing pain point and the architectural choice (a TypeCheckingExtension hooking the methodMissing / propertyMissing weave from TagLibraryInvoker) looks right. CI is green across all 31 jobs.

Left some inline notes below - mostly minor style/consistency things, plus a couple of behavior cases that might be worth a second look (the broadness of unresolvedVariable and the tag-as-property closure form). None are dealbreakers, just thoughts.

Comment on lines +67 to +73
unresolvedVariable { VariableExpression ve ->
if (currentScope?.isController) {
currentScope.dynamicNamespaceProperties << ve
return makeDynamic(ve)
}
null
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This part might be a bit broader than necessary - marking every unresolved variable in a controller as dynamic could end up silencing genuine typos. For example:

@GrailsCompileStatic
class BookController {
    BookService bookSvc
    def index() {
        bookSvce.list()  // typo - 'bookSvce' is unresolved
    }
}

Here bookSvce would be silently made dynamic (and added to dynamicNamespaceProperties, so the subsequent .list() call is also silenced) instead of producing the compile error @GrailsCompileStatic users probably expect.

Could it maybe be worth narrowing this? One option would be to defer the dynamic mark until the variable is actually used as the receiver of a method call (i.e. only silence the namespace-dispatcher access pattern <ident>.<method>(...)), so standalone typos still surface. Just a thought - happy to be wrong if there's a reason you've gone broader.

Comment on lines +75 to +81
unresolvedProperty { PropertyExpression pe ->
if (currentScope?.isController && isThisReceiver(pe)) {
currentScope.dynamicNamespaceProperties << pe
return makeDynamic(pe)
}
null
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just flagging a subtle case here: this hook silences def t = link at compile time, but at runtime TagLibraryInvoker.propertyMissing only returns a NamespacedTagDispatcher for namespace names - it doesn't return a Closure for tag-property access the way TagLibrary.propertyMissing does inside a tag library itself. So a controller with def t = link would compile cleanly but throw MissingPropertyException at runtime.

Probably fine as a known limitation, but maybe worth a Javadoc note here (or a line in the user docs) so the boundary is explicit?

* {@code g.message(code: 'key')}, {@code my.customTag(attr: 'val')}</li>
* </ul>
*
* @since 7.0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tiny thing - since this PR targets 8.0.0-SNAPSHOT and whatsNew.adoc describes the change under "introduced in Grails 8", @since 8.0 would maybe be more accurate here?

Comment on lines +56 to +65
beforeVisitClass { ClassNode classNode ->
newScope {
isController = classNode.name.endsWith(ControllerArtefactHandler.TYPE)
dynamicNamespaceProperties = [] as Set
}
}

afterVisitClass { ClassNode classNode ->
scopeExit()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor consistency note: the other class-scoped extensions in this package (ValidateableTypeCheckingExtension, DomainMappingTypeCheckingExtension, NamedQueryTypeCheckingExtension) pair beforeVisitClass / afterVisitClass with an outer setup { newScope() } / finish { scopeExit() } guard:

setup { newScope() }
finish { scopeExit() }

The currentScope? null-safe accesses below make this functionally safe without them, but could you maybe add the outer pair to keep this in lockstep with the rest of the package?

null
}

methodNotFound { receiver, name, argList, argTypes, call ->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you maybe add parameter types here for consistency with the other extensions in this package? They all use the fully typed form:

methodNotFound { ClassNode receiver, String name, ArgumentListExpression argList, ClassNode[] argTypes, MethodCall call ->

if (call instanceof MethodCallExpression && call.objectExpression in currentScope.dynamicNamespaceProperties) return makeDynamic(call)
null
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The other extensions in this package all end run() with an explicit null. Could you maybe add one here for consistency? Just a style nit.

}
}

private boolean isThisReceiver(expr) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you maybe type this parameter (perhaps Expression expr)? Helps line up with the rest of the codebase's static-typing lean.

Object run() {
beforeVisitClass { ClassNode classNode ->
newScope {
isController = classNode.name.endsWith(ControllerArtefactHandler.TYPE)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since detection is purely by name suffix, an inner class called something like FooController declared inside, say, a service would also receive the silencing treatment. Probably rare in practice - but might be worth a Javadoc note so the behavior is documented?

@Tag('http-client')
class CompileStaticControllerSpec extends Specification implements HttpClientSupport {

void 'controller with @GrailsCompileStatic can call a default-namespace tag directly'() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tiny convenience nit: this integration spec and the unit spec at src/test/groovy/demo/CompileStaticControllerSpec.groovy share the same fully qualified name demo.CompileStaticControllerSpec. Different source sets so the build is fine, but --tests "demo.CompileStaticControllerSpec" becomes ambiguous when running them individually.

Could you maybe rename one to something like CompileStaticControllerIntegrationSpec (or CompileStaticControllerHttpSpec)?

import spock.lang.Specification

class ControllerCompileStaticTagLibSpec extends Specification implements ControllerUnitTest<CompileStaticTagController> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could it maybe be worth adding a negative test alongside these positive ones? Something pinning down the boundary - e.g. a spec asserting that a genuinely undefined reference inside an @GrailsCompileStatic controller still fails to compile - would help guard against the extension drifting to silence more than intended in future changes.

Not a blocker, just a thought.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants