Skip to content

8.x Add statically-resolvable message(Map) for controllers#15733

Closed
codeconsole wants to merge 3 commits into
apache:8.0.xfrom
codeconsole:feat/static-controller-message
Closed

8.x Add statically-resolvable message(Map) for controllers#15733
codeconsole wants to merge 3 commits into
apache:8.0.xfrom
codeconsole:feat/static-controller-message

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

Summary

message(code: ..., args: [...]) is by far the most common tag invoked as a method from controllers — it is what scaffolding generates for every flash message:

flash.message = message(code: 'default.created.message', args: [...])
redirect book

Today that call only resolves via TagLibraryInvoker.methodMissing, so under @CompileStatic / @GrailsCompileStatic it fails to compile and forces @CompileDynamic onto exactly the controller actions that are otherwise fully statically compilable (the same pain #15715 addressed for attribute access). This PR makes the canonical idiom compile statically, verbatim.

Design

A new trait grails.artefact.gsp.ControllerTagLibraryInvoker extends TagLibraryInvoker declares a real Object message(Map attrs), and ControllerTagLibraryTraitInjector now injects it (controllers keep the full inherited TagLibraryInvoker API).

  • Dispatch parity. The method resolves the tag through the same lookup order as methodMissing (declared taglibNamespace first, then the default namespace) and invokes it through the same TagOutput.captureTagOutput(..., OutputContextLookupHelper.lookupOutputContext()) machinery that GSP-compiled code and the TagLibraryMetaUtils-registered meta-methods use — so attribute handling (code, args, default, error, message, locale, encodeAs), encoding, and return semantics are identical to the dynamic path. When no tag library provides the tag, it throws MissingMethodException, matching methodMissing's failure mode.
  • Why a subtrait instead of TagLibraryInvoker itself: tag libraries also implement TagLibraryInvoker (via grails.artefact.TagLibrary), and a real message method inherited by every tag library is picked up by TagMethodInvoker as if the library declared a message tag — recursing infinitely for libraries that don't (caught by grails-test-suite-uber during development). Scoping the method to the controller-injected trait avoids leaking it into the tag-dispatch path. The javadoc documents this constraint.

Tests

New ControllerTagLibraryInvokerMessageSpec:

  • dispatches to the message tag of the default namespace with attrs passed through
  • prefers the declared taglibNamespace, falling back to the default — mirroring methodMissing order
  • throws MissingMethodException when no tag library provides the tag
  • a @CompileStatic controller-style class invoking the canonical idiom compiles and resolves (this guard fails test compilation without the trait method)

:grails-web-taglib:test, :grails-gsp:test, :grails-test-suite-web:test, and :grails-test-suite-uber:test all pass.

@codecov

codecov Bot commented Jun 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 48.4136%. Comparing base (1c2d8a6) to head (0f802b2).
⚠️ Report is 24 commits behind head on 8.0.x.

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15733        +/-   ##
==================================================
+ Coverage     48.3255%   48.4136%   +0.0880%     
- Complexity      15216      15257        +41     
==================================================
  Files            1875       1875                
  Lines           85818      85852        +34     
  Branches        14969      14972         +3     
==================================================
+ Hits            41472      41564        +92     
+ Misses          37975      37883        -92     
- Partials         6371       6405        +34     
Files with missing lines Coverage Δ
...er/traits/ControllerTagLibraryTraitInjector.groovy 100.0000% <100.0000%> (ø)

... and 15 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codeconsole codeconsole changed the title Add statically-resolvable message(Map) for controllers 8.x Add statically-resolvable message(Map) for controllers Jun 12, 2026
@codeconsole codeconsole force-pushed the feat/static-controller-message branch from fd477a0 to 0f802b2 Compare June 12, 2026 16:21
@testlens-app

testlens-app Bot commented Jun 12, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 0f802b2
▶️ Tests: 38821 executed
⚪️ Checks: 43/43 completed


Learn more about TestLens at testlens.app.

* {@code message}, {@code locale}, {@code encodeAs})
* @return the resolved message
*/
Object message(Map attrs) {

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 is the wrong solution: we basically are copying/moving the functionality for a specific tag. You can inject the taglib directly to get static support. We should consider the registry solution I proposed last week or the static solution that defers to dynamic dispatch.

@bito-code-review

Copy link
Copy Markdown

The current implementation in ControllerTagLibraryInvoker.groovy provides a statically-resolvable message(Map) method to allow message tag calls to resolve under @CompileStatic in controllers, avoiding dynamic dispatch.

Regarding the suggestion to use a registry or a static solution that defers to dynamic dispatch:

  • Registry Solution: A registry-based approach would decouple the tag invocation logic from the trait, potentially allowing for more flexible registration of statically-resolvable tags beyond just message.
  • Static-to-Dynamic Dispatch: A static method that internally calls the existing dynamic dispatch mechanism (similar to what is currently implemented) is already being used to ensure identical behavior. If the goal is to avoid duplicating this logic across multiple traits, moving the implementation to a shared utility or a registry would be the appropriate architectural step.

Since the current implementation is already a static method that defers to the dynamic TagOutput.captureTagOutput machinery, it effectively achieves the goal of static compilation support while maintaining identical runtime behavior.

grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/ControllerTagLibraryInvoker.groovy

Object message(Map attrs) {
        TagLibraryLookup lookup = getTagLibraryLookup()
        if (lookup) {
            String namespace = getTaglibNamespace()
            GroovyObject tagLibrary = lookup.lookupTagLibrary(namespace, 'message')
            if (tagLibrary == null && namespace != TagOutput.DEFAULT_NAMESPACE) {
                namespace = TagOutput.DEFAULT_NAMESPACE
                tagLibrary = lookup.lookupTagLibrary(namespace, 'message')
            }
            if (tagLibrary != null) {
                return TagOutput.captureTagOutput(lookup, namespace, 'message', attrs, null,
                        OutputContextLookupHelper.lookupOutputContext())
            }
        }
        throw new MissingMethodException('message', this.getClass(), [attrs] as Object[])
    }

@codeconsole

codeconsole commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

in favor of #15669

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

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants