---
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

1. 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._
2. 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`._
3. 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:

```scala
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
// 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:

```scala
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

1. Compile the module clean (`module/clean ; module3/clean ; module/test ;
   module3/test`).
2. 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.
3. 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

- [`../hearth-api-reference/`](../hearth-api-reference/SKILL.md) — Environment API signatures
- [`../hearth-documentation/`](../hearth-documentation/SKILL.md) — finding Hearth docs and verifying APIs via MCP
- [`../hearth-collection-map/`](../hearth-collection-map/SKILL.md) — IsCollection/IsMap providers registered via standard extensions
- [`../hearth-value-types/`](../hearth-value-types/SKILL.md) — IsValueType providers registered via standard extensions
