| name | hearth-standard-extensions |
| description | Loading Hearth standard extensions exactly once per macro bundle. ensureStandardExtensionsLoaded() pattern, LoadStandardExtensionsOnce trait, what extensions provide (IsCollection, IsMap, IsValueType, IsOption).
|
| paths | ["**/compiletime/**Macros*.scala"] |
| user-invocable | false |
Loading standard extensions exactly once
The rule
Environment.loadStandardExtensions() must be invoked at most once per macro bundle
instance (i.e. once per top-level macro expansion).
Hearth deduplicates already-applied extensions internally, so a redundant call does not
re-register providers — but it still:
- pays the
ServiceLoader scan cost,
- emits a
"Extension … was already loaded in this macro expansion, skipping re-initialization" info log for every previously loaded extension,
- pollutes the Hearth flame graph (
hearth.mioBenchmarkScopes=true) with redundant
loadMacroExtensionsFrom rule self-time.
See kubuszok/kindlings#65 for the user-visible noise that motivates this rule.
Where redundant loads creep in
- A "codec" entry point that internally calls both an "encoder" entry point and a
"decoder" entry point — each of which loads independently.
Example fixed:
yaml-derivation, circe-derivation codec wrappers.
- A type class with multiple methods generated by separate context-providing
closures, where each closure independently runs
loadStandardExtensions inside its
own runSafe.
Example fixed: cats-derivation HashMacrosImpl, MonoidKMacrosImpl.
- Inline / type-class entry-point pairs that share an impl trait but each call the load
themselves at the top of their derivation flow.
Example fixed:
avro-derivation EncoderMacrosImpl + SchemaForMacrosImpl chain.
Pattern: per-module LoadStandardExtensionsOnce trait
Create a small trait in the module's internal/compiletime/ package:
trait LoadStandardExtensionsOnce { this: MacroCommons & StdExtensions =>
private var standardExtensionsLoaded: Boolean = false
protected def ensureStandardExtensionsLoaded(): MIO[Unit] =
if (standardExtensionsLoaded) MIO.pure(())
else
Environment.loadStandardExtensions().toMIO(allowFailures = false).map { _ =>
standardExtensionsLoaded = true
()
}
}
Mix it into the final macro bundle classes for both Scala 2 and Scala 3:
// scala-2
final private[mymodule] class CodecMacros(val c: blackbox.Context)
extends MacroCommonsScala2
with AnnotationSupportScala2
with LoadStandardExtensionsOnce // <-- here
with EncoderMacrosImpl
with DecoderMacrosImpl
with CodecMacrosImpl
// scala-3
final private[mymodule] class EncoderMacros(q: Quotes)
extends MacroCommonsScala3(using q),
AnnotationSupportScala3,
LoadStandardExtensionsOnce, // <-- here
EncoderMacrosImpl
Add it to the self-types of every *MacrosImpl trait that needs to load extensions:
trait EncoderMacrosImpl extends … {
this: MacroCommons & StdExtensions & AnnotationSupport & LoadStandardExtensionsOnce =>
Then replace every direct Environment.loadStandardExtensions().toMIO(allowFailures = false) call with ensureStandardExtensionsLoaded().
Rules to follow
- One trait per module. The
var lives on the trait that is mixed once into the
final class — this is what makes it per-bundle.
- Never call
Environment.loadStandardExtensions() directly from any *MacrosImpl
trait. Always go through ensureStandardExtensionsLoaded().
- Never load extensions inside a
LambdaBuilder.traverse callback or inside an inner
Expr.quote { … } block — load once, before any quotes are constructed, in the same
MIO.scoped { runSafe => … } block.
- It is fine for the call to live inside the
for { … } yield chain that builds a body —
the guard makes the second-onwards calls free.
Verifying
- Compile the module clean (
module/clean ; module3/clean ; module/test ; module3/test).
- Search the build output for the
"already loaded" info log — there should be zero
occurrences for any standard extension provider during a clean build of the module's
tests.
- Optional: enable Hearth's flame graph (
-Xmacro-settings:hearth.mioBenchmarkScopes=true -Xmacro-settings:hearth.mioBenchmarkFlameGraphDir=/tmp/flame) and confirm
loadStandardExtensions self-time is ~0 after the first call per expansion.
Modules currently using this pattern
yaml-derivation
avro-derivation
circe-derivation
jsoniter-derivation has an equivalent inline guard
(CodecMacrosImpl.scala:944-950); it predates the shared trait. Migrating it is purely
cosmetic.
xml-derivation, ubjson-derivation, tapir-schema-derivation do not currently chain
multiple entry points so they pass rule 1 without the trait. Add the trait if you ever
introduce a codec / chained entry point.
cats-derivation does not need the trait because each cat type-class derivation is its
own independent macro bundle (one entry point per file). The two exceptions
(HashMacrosImpl, MonoidKMacrosImpl) instead hoist Environment.loadStandardExtensions()
out of their multi-closure setup so it is invoked exactly once per bundle.
Related skills