Skip to content

feat(mix_generator): generate named-only MixWidget constructors from enum variants#999

Open
leoafarias wants to merge 4 commits into
mainfrom
fix/998
Open

feat(mix_generator): generate named-only MixWidget constructors from enum variants#999
leoafarias wants to merge 4 commits into
mainfrom
fix/998

Conversation

@leoafarias

@leoafarias leoafarias commented Jul 24, 2026

Copy link
Copy Markdown
Member

Closes #998.

Summary

Adds @MixWidget(variants:), which derives a widget's constructors from an enum-backed NamedVariant domain instead of a factory parameter. The generated wrapper has no unnamed constructor, no public variant: argument, and no public variant field.

enum FortalButtonVariant with EnumVariant { solid, soft, outline }

@MixWidget(target: RemixButton.new, variants: FortalButtonVariant)
BoxStyler fortalButtonStyle({FortalButtonSize size = .size2}) {
  return _base(size).variantsFromEnum(
    FortalButtonVariant.values,
    (variant) => switch (variant) {
      .solid => _solidStyle(),
      .soft => _softStyle(),
      .outline => _outlineStyle(),
    },
  );
}

FortalButton.solid(size: .size2, label: 'Save');
FortalButton.soft(size: .size3, label: 'Save');

Generated output (verified against the real packages, not stubs):

class FortalButton extends StatelessWidget {
  const FortalButton.solid({super.key, this.size = FortalButtonSize.size2, required this.label})
    : _variant = FortalButtonVariant.solid;
  // ...one per value

  final FortalButtonVariant _variant;
  final FortalButtonSize size;
  final String label;

  @override
  Widget build(BuildContext context) => RemixButton(
        key: this.key,
        style: fortalButtonStyle(size: this.size).applyVariant(this._variant),
        label: this.label,
      );
}

The value is stored privately because Dart forbids privately named parameters — that is precisely what makes the widget selectable only through its named constructors.

Design note: explicit annotation instead of a naming convention

The issue proposed discovering the enum by convention (same library, named <WidgetName>Variant). I implemented an explicit variants: field instead, because the convention is not actually opt-in: any existing user who happens to have enum CardVariant with EnumVariant beside cardStyle() would silently lose Card(...) on upgrade — a breaking public-API change with no source change on their side. The convention also forces the enum into the same library as the recipe, which fights the usual separate variants.dart split.

The explicit field costs one annotation parameter and makes "unchanged when it does not apply" true by construction rather than by luck.

Runtime API in mix

  • variantsFromEnum(values, builder) — registers a VariantStyle per enum value. The builder must return a style for every value, so a switch expression makes coverage exhaustive at compile time.
  • applyVariant(NamedVariant) — applies one variant and throws when none is registered. applyVariants (plural) keeps its silent no-op semantics.

The throw is the load-bearing half of "missing variant-style mappings produce a clear failure" — previously unreachable, since applyVariants returns the base style unchanged when nothing matches. Paired with variantsFromEnum, "unregistered" reliably means "genuinely forgotten". A deliberately unstyled variant registers an empty styler.

Validation

variants: is validated rather than silently skipped. The type must be an enum, mix in EnumVariant, be visible from the annotated library, have at least one reachable value, avoid colliding with generated widget type parameters, and not be combined with a variant factory parameter.

This differs deliberately from the legacy convention, which degrades silently on a type-parameter collision to stay non-breaking. Here, degrading would emit an unnamed-constructor widget — the opposite of what was asked for — so it fails.

Backwards compatibility

The existing variant factory-parameter convention is untouched: unnamed constructor, public field, forwarding to the recipe. Both modes are tested and coexist in one library. A byte-identical-output regression test guards the no-variant path.

Consolidation

  • Collapsed the coupled variantParamName + variantConstructors model fields into one nullable WidgetVariantDomain, so the "name is set iff constructors exist" invariant holds by construction. Net field count unchanged; the two modes differ by exactly one documented bit.
  • Split discovery from enumeration so both modes share _variantConstructorsFor (two real consumers), while callers decide what a collision means.
  • The builder needed only three one-line conditionals.

Verification

  • 2760 mix / 354 mix_generator / 14 mix_annotations tests pass; packages/mix analyzes clean.
  • Beyond the stub-based generator tests, a throwaway package was built against the real packages and run through build_runner: generated output matched the target exactly, flutter analyze was clean, and widget tests confirmed .solid/.soft render different variants and that a missing mapping throws StateError at build time.

Incidental fix

The shared widget test stub's framework.dart re-exported key.dart without importing it, so Widget.key resolved to InvalidType and every super.key chained through it did too. Existing tests missed this because their stylers declared Key? key directly. It blocked all direct-target fixtures built on the shared stub; the fix is one import.

Note on branch name

The branch is fix/998 as requested, though the change is additive and all three changelog entries are tagged FEATfeat/998 would match the repo convention. Flagging in case the branch name matters to release tooling.

…enum variants

Add `@MixWidget(variants:)`, which derives a widget's constructors from an
`EnumVariant` enum instead of a factory parameter. The generated wrapper has
no unnamed constructor, no public `variant:` argument, and no public variant
field: each named constructor pins one enum value into a private field, and
`build()` applies it to the recipe result with `applyVariant` before the style
reaches the target widget.

Supporting runtime API in `mix`:

- `variantsFromEnum` registers a `VariantStyle` for every enum value. Its
  builder must return a style for each value, so a `switch` expression makes
  variant coverage exhaustive at compile time.
- `applyVariant` applies a single `NamedVariant` and throws when none is
  registered, so a forgotten mapping fails loudly instead of silently
  resolving to the base style. `applyVariants` keeps its no-op semantics.

`variants:` is validated rather than silently skipped: the type must be an
enum, mix in `EnumVariant`, be visible from the annotated library, have at
least one reachable value, avoid colliding with generated widget type
parameters, and not be combined with a `variant` factory parameter.

The existing `variant` factory-parameter convention is unchanged, including
its unnamed constructor and public field. Both modes are covered by tests and
can coexist in one library.

Consolidate the coupled `variantParamName`/`variantConstructors` model fields
into a single nullable `WidgetVariantDomain`, so the "name is set iff
constructors exist" invariant holds by construction, and share enum-constant
enumeration between the two discovery paths.

Also fix the shared widget test stub, whose `framework.dart` re-exported
`key.dart` without importing it. `Widget.key` therefore resolved to
InvalidType and every `super.key` chained through it did too, which blocked
direct-target fixtures built on the shared stub.
Apply `dart format` to three files flagged by `format:check`, and make the
`MixWidget.variants` test fixture enum public so its constants are not
reported as unused private fields.
Address findings from an independent review of the `variants:` feature.

Model the variant domain as a sealed hierarchy instead of one class whose
mode was inferred from `declaredTypeCode != null`. `ForwardedVariantDomain`
and `OwnedVariantDomain` state their shape directly, so the builder switches
on type rather than re-deriving a boolean at four call sites, and an
inconsistent combination is no longer constructible.

Reject a styler that does not declare `applyVariant`. It comes from
`VariantStyleMixin`, not from `Style<S>`, so a hand-rolled styler previously
satisfied `@MixWidget` and then failed to compile inside the generated part
instead of reporting against the annotation.

Compare domains rather than parameter names when detecting a conflict with
the `variant` factory-parameter convention. A parameter merely named
`variant` that is positional, nullable, or non-enum never backs a domain, so
it was being rejected with an inaccurate message; it now forwards normally
alongside the private variant field.

Reject a parameter named `_variant`, which would declare the private field a
second time. Dart only forbids privately named parameters when they are
named, so a positional one reached the generated part.

Cover the paths that previously had only builder-level unit tests:
variable-backed factories, the styler `call()` path without `target:`, and
each new validation.
…ection

Address an adversarial review of the `variants:` feature.

Move the changelog entries off already-published versions. `mix` and
`mix_annotations` 2.2.0-beta.1 and `mix_generator` 2.2.0-beta.2 are on
pub.dev and cannot be republished, so their artifacts would never contain
these APIs. Each entry moves to the next unpublished version and its pubspec
is bumped to match. Raise `mix_generator`'s `mix_annotations` lower bound to
the release declaring `MixWidget.variants`, and document that generated code
needs `mix` 2.2.0-beta.2 for `applyVariant`.

Detect a conflicting `variant` factory parameter before curation.
`factoryParameters: .only({})` hid the parameter from the curated list, so
the conflict went unreported: the recipe resolved its own default variant and
the generated widget applied a second one on top of it. Detection now reads
the function's declared parameters, while the forwarded domain still requires
the parameter to survive curation.

Match `applyVariant` on `Variant.key` rather than `==`. Dart forbids enums
from overriding `==`, so `NamedVariant('solid') == MyVariant.solid` was true
while the reverse was false, making a strict lookup depend on operand order.

Qualify the exhaustiveness claim: `variantsFromEnum` covers the whole enum
only when passed `E.values`, and a subset leaves the rest to `applyVariant`.
@leoafarias

Copy link
Copy Markdown
Member Author

Validated this against the real Remix recipes it's meant to unblock — 25 @MixWidget functions, 17 of them variant-bearing. The generated shape is exactly right: named-only constructors, private _variant, applyVariant with a loud StateError, and key-based matching (thanks for 161214a6 — the == asymmetry would have bitten us). The variants: + variant-parameter conflict error is also exactly what we need; our migration removes variant from all 17 signatures, so we'd have hit it.

Two things before we can migrate.

1. variantsFromEnum is eager, and the recipe runs on every rebuild

variant_style_mixin.dart:75-77 materialises every value up front:

return variants([
  for (final value in values) VariantStyle<S>(value, styleBuilder(value)),
]);

The generated build() calls fortalButtonStyle(...).applyVariant(_variant) per frame. So FortalButton (6 variants) would construct 6 full ButtonStylers per rebuild where today's switch (variant) recipe constructs 1. Same for FortalIconButton; 2–4× for the rest of the 17. That's a per-frame allocation regression we'd rather not ship into a design system's most-used widgets.

Could VariantStyle take an optional thunk and materialise on first value access? From the outside this looks contained:

  • VariantStyle.value is already a getter (core/style.dart:269), so no caller changes.
  • Nothing on the hot path reads .value for inactive variants — mergeActiveVariants filters on variantAttr.variant (core/style.dart:92-100), widgetStates reads only .variant (core/style.dart:68-73), and applyVariant compares registered.key before touching .value (variant_style_mixin.dart:99).

The hazard we can see is props => [variant, _style] (core/style.dart:301) — equality would force materialisation of every variant and defeat the whole thing. Is there a way to key VariantStyle equality that avoids that, or is memoising the materialised style acceptable given Mix's immutability rules? We're happy to take whatever shape you prefer, and happy to send a PR if you'd rather review than write it. We just don't want the named-constructor win to cost per-frame allocations.

2. We do not need the Styler suffix handled in the generator

The name-inference rule requiring ...Style is fine. We're renaming Remix's fortalButtonStylerfortalButtonStyle on our side, which lets us drop name: from all 25 annotations. Flagging only so a second suffix isn't added on your side — fooStyle and fooStyler both deriving Foo would be an ambiguity for no gain.

On variants: being explicit

#998 originally proposed inferring the enum by a <WidgetName>Variant naming convention. Having now worked through it: explicit is better and we'd like to keep what you built. It's one line, it's compiler-checked, it works when the enum lives elsewhere, and a convention would silently downgrade to an unnamed constructor on a typo. Please consider that part of #998 withdrawn.

What we verified locally at 161214a6

With lazy registration resolved, we'll bump Remix to mix 2.2.0-beta.2 / mix_annotations 2.2.0-beta.2 / mix_generator 2.2.0-beta.3 and migrate all 25 wrappers. Our end-state annotation, for reference — 24 of 25 need nothing but target: and variants::

enum FortalButtonVariant with EnumVariant { classic, solid, soft, surface, outline, ghost }

@MixWidget(target: RemixButton.new, variants: FortalButtonVariant)
ButtonStyler fortalButtonStyle({
  FortalButtonSize size = .size2,
  bool highContrast = false,
}) {
  return ButtonStyler().variantsFromEnum(
    FortalButtonVariant.values,
    (variant) => _fortalButtonVariantStyle(variant, size, highContrast),
  );
}

@leoafarias

Copy link
Copy Markdown
Member Author

Refining the props hazard I raised above, having traced it properly — it's narrower than I made it sound.

Style equality is only forced on one path: StyleProvider.updateShouldNotify (core/providers/style_provider.dart:20) does style != oldWidget.style, and StyleProvider is constructed only when inheritable is true (core/style_builder.dart:158-160). Everything on the normal build path — mergeActiveVariants, widgetStates, applyVariant — reads .variant and never .value for inactive variants.

So a memoising thunk would deliver the win on the common path, and degrade to today's behaviour (materialise all, once) only for inheritable: true styles. That seems like an acceptable trade rather than a blocker, and it makes the change smaller than my first comment implied.

Sorry for the two-parter — wanted the correction on the record before anyone acts on the broader version.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate named-only @MixWidget constructors from enum-backed NamedVariants

1 participant