|
| 1 | +# `@MixWidget` variant constructors — design decisions |
| 2 | + |
| 3 | +Status of record: §1–§4 are settled and describe shipped behavior. §5 is decided |
| 4 | +but not yet implemented — one input from the Remix team is outstanding. |
| 5 | + |
| 6 | +This document exists so a future simplification pass does not re-derive — and |
| 7 | +re-litigate — choices that were made deliberately. Each rejected alternative |
| 8 | +lists *why* it was rejected, not just *that* it was. |
| 9 | + |
| 10 | +## 1. The shipped behavior |
| 11 | + |
| 12 | +Introduced by PR #968 (`2524b74ad`, 2026-07-11). Published in |
| 13 | +**`mix_generator 2.2.0-beta.2`**. |
| 14 | + |
| 15 | +A recipe **function** that declares an enum parameter named `variant` generates |
| 16 | +one named constructor per enum value, **in addition to** the unnamed |
| 17 | +constructor. |
| 18 | + |
| 19 | +```dart |
| 20 | +enum FortalButtonVariant { solid, soft, outline } |
| 21 | +
|
| 22 | +@MixWidget(target: RemixButton.new) |
| 23 | +ButtonStyler fortalButtonStyle({ |
| 24 | + FortalButtonVariant variant = .solid, |
| 25 | + FortalButtonSize size = .size2, |
| 26 | +}) => switch (variant) { .solid => ..., .soft => ..., .outline => ... }; |
| 27 | +``` |
| 28 | + |
| 29 | +Generates all of: |
| 30 | + |
| 31 | +```dart |
| 32 | +FortalButton.solid(size: .size3, label: 'Save') // one per enum value |
| 33 | +FortalButton.soft(label: 'Save') |
| 34 | +FortalButton(variant: someKnob, label: 'Save') // dynamic selection |
| 35 | +FortalButton(label: 'Save') // recipe default (.solid) |
| 36 | +``` |
| 37 | + |
| 38 | +### Conditions for generation |
| 39 | + |
| 40 | +All five must hold: |
| 41 | + |
| 42 | +1. The recipe is a **function** — a top-level variable has no parameters, so it |
| 43 | + generates no variant constructors. |
| 44 | +2. The parameter is named **exactly `variant`**. |
| 45 | +3. The parameter is **named**, not positional. |
| 46 | +4. The parameter type is a **non-nullable enum**. |
| 47 | +5. The parameter survives `factoryParameters` curation (see §5 — this condition |
| 48 | + is proposed for removal). |
| 49 | + |
| 50 | +Explicitly **not** required: |
| 51 | + |
| 52 | +- The parameter need **not** be `required`. Optional and defaulted both work, |
| 53 | + and detection does not branch on it. |
| 54 | +- The enum need **not** mix in `EnumVariant`, `NamedVariant`, or anything else. |
| 55 | + A plain Dart enum is correct. **Do not add an `EnumVariant` check here.** On |
| 56 | + this path the enum is only a `switch` key inside the recipe; the value never |
| 57 | + enters Mix's variant system. The `EnumVariant` requirement in the rejected |
| 58 | + design (§3) existed solely because that design applied the value through |
| 59 | + `applyVariant`, which needs a `NamedVariant`. |
| 60 | + |
| 61 | +### Which enum values become constructors |
| 62 | + |
| 63 | +Every constant that is public, or declared in the same library as the recipe. |
| 64 | +There is no per-value opt-in — **the enum's membership is the selection list**. |
| 65 | +Additionally: |
| 66 | + |
| 67 | +- `///` doc comments on constants are copied onto the matching constructor. |
| 68 | +- `@Deprecated('...')` is copied onto the constructor (still generated, marked). |
| 69 | +- If any value's name collides with a generated widget type parameter, **all** |
| 70 | + variant constructors are suppressed. This silent degradation is deliberate: |
| 71 | + emitting them would produce invalid Dart, and failing would break a |
| 72 | + previously-working build. |
| 73 | + |
| 74 | +Only the parameter named `variant` drives constructors. Other enum parameters |
| 75 | +(`size`, etc.) stay ordinary parameters. There is no cross-product |
| 76 | +(`.solidSize2()`) — six variants times three sizes is eighteen constructors. |
| 77 | + |
| 78 | +## 2. Decision: the unnamed constructor stays |
| 79 | + |
| 80 | +Issue #998 asked for named-*only* widgets — no unnamed constructor, no public |
| 81 | +`variant:` argument, no public `variant` field. **Declined.** |
| 82 | + |
| 83 | +Reasons: |
| 84 | + |
| 85 | +- PR #968 preserved the unnamed constructor as an explicit non-breaking |
| 86 | + guarantee. That reason still holds. |
| 87 | +- **Dynamic selection needs it.** When the variant comes from state, config, or |
| 88 | + a UI control, no named constructor can be chosen at compile time. Without the |
| 89 | + unnamed constructor the caller must abandon the generated wrapper and drop to |
| 90 | + the raw target widget with a hand-built style. Remix measured this at **16 |
| 91 | + call sites** in their own migration report — it is not hypothetical. |
| 92 | +- Both spellings coexist without cost. Nothing forces a call site to use the |
| 93 | + unnamed form. |
| 94 | + |
| 95 | +What would reopen this: a concrete case where the unnamed constructor's |
| 96 | +*existence* causes a real problem, rather than a preference for a smaller |
| 97 | +public surface. |
| 98 | + |
| 99 | +## 3. Decision: no runtime variant API in `packages/mix` |
| 100 | + |
| 101 | +PR #999's first implementation added `@MixWidget(variants:)`, plus |
| 102 | +`variantsFromEnum` and `applyVariant` in `packages/mix`. **Rejected.** Neither |
| 103 | +was ever published; `packages/mix` requires no change for variant constructors. |
| 104 | + |
| 105 | +The defect was an inverted data flow: |
| 106 | + |
| 107 | +- **Parameter form (kept):** variant goes *in* → one style comes *out*. Builds 1. |
| 108 | +- **Registration form (rejected):** a style comes out carrying *all N* variants |
| 109 | + → the widget narrows to one. Builds N, discards N−1. |
| 110 | + |
| 111 | +A six-variant button rebuilt six stylers and discarded five. The proposed fix |
| 112 | +was a lazy, memoizing `VariantStyle`, which required a determinism contract on |
| 113 | +the builder and a documented caveat for `inheritable: true` styles. That is a |
| 114 | +patch on the inversion, not a fix for it — and unnecessary, since the parameter |
| 115 | +form already builds exactly one. |
| 116 | + |
| 117 | +Two secondary advantages of the parameter form: |
| 118 | + |
| 119 | +- **Exhaustiveness is compile-time.** `switch (variant)` over an enum with no |
| 120 | + `default` is analyzer-enforced. `applyVariant`'s runtime `StateError` is |
| 121 | + strictly weaker. |
| 122 | +- The enum needs no mixin (see §1). |
| 123 | + |
| 124 | +### Frequency, for the record |
| 125 | + |
| 126 | +The rebuild cost above is **per parent rebuild, not per frame**. Widget-state |
| 127 | +changes (hover, press) rebuild *inside* `StyleBuilder` via `WidgetStateProvider` |
| 128 | +and re-run `style.build(context)` on the existing style object; they do not |
| 129 | +re-invoke the recipe. The recipe re-runs only when the generated widget's own |
| 130 | +`build()` runs. |
| 131 | + |
| 132 | +Style equality — which would force materialization of a lazy variant — is |
| 133 | +reached from exactly one place in `packages/mix/lib/src`: |
| 134 | +`StyleProvider.updateShouldNotify`, and only for `inheritable: true`. |
| 135 | + |
| 136 | +## 4. Rejected: convention-based and marker-based discovery |
| 137 | + |
| 138 | +- **Discover the enum by naming convention** (`<WidgetName>Variant` in the same |
| 139 | + library), as #998 proposed: rejected. It is not opt-in — an existing user with |
| 140 | + a matching enum beside their recipe would silently lose the unnamed |
| 141 | + constructor on upgrade, with no source change on their side. |
| 142 | +- **An explicit `@MixWidgetVariant` marker** on the parameter: deferred, not |
| 143 | + refused. It would close a real gap — the name `variant` is magic, so `kind`, |
| 144 | + `appearance`, or a typo produces no constructors and no diagnostic. Deferred |
| 145 | + because no consumer needs a different name today, and adding it now means two |
| 146 | + discovery paths for one mechanism. If added later, it should be a *discovery |
| 147 | + override on the same mechanism* (like `MixWidget.name` overrides the derived |
| 148 | + class name), never a parallel path. |
| 149 | +- **Per-value constructor selection** (`.only({'solid', 'soft'})`): rejected as |
| 150 | + premature. If a value should not be constructible, the question is why it is |
| 151 | + in this widget's variant enum — the recipe's `switch` must handle it anyway. |
| 152 | + One enum per widget's variant domain is the intended shape. Revisit only if a |
| 153 | + single enum is genuinely shared across widgets with differing subsets, and |
| 154 | + validate names against the enum so a typo fails loudly. |
| 155 | + |
| 156 | +## 5. DECIDED, not yet implemented: remove `factoryParameters` |
| 157 | + |
| 158 | +Breaking changes are acceptable on the 2.2.0-beta line. Target release: |
| 159 | +`mix_generator 2.2.0-beta.3` / `mix_annotations 2.2.0-beta.2`. |
| 160 | + |
| 161 | +One input outstanding before implementing — see "Outstanding input" below. |
| 162 | + |
| 163 | +### The decision |
| 164 | + |
| 165 | +`@MixWidget` currently has two curation knobs: |
| 166 | + |
| 167 | +| Knob | Curates | Introduced | |
| 168 | +|---|---|---| |
| 169 | +| `widgetParameters` | the styler `call()` / target constructor parameters | PR #974 (`8c26ad6ce`) | |
| 170 | +| `factoryParameters` | the **recipe function's own** parameters | PR #997 (`726d85e84`) | |
| 171 | + |
| 172 | +**Remove `factoryParameters`**, and **rename `widgetParameters` to |
| 173 | +`targetParameters`**. |
| 174 | + |
| 175 | +The rationale: a recipe function's parameters *are* the recipe's API, authored by |
| 176 | +the same person writing the annotation. If a parameter should not reach the |
| 177 | +widget, it should not be a parameter. Curation earns its place on the surface the |
| 178 | +author does **not** own — the target widget's constructor. That was the original |
| 179 | +design intent; `factoryParameters` arrived later, bundled into a different |
| 180 | +feature. |
| 181 | + |
| 182 | +The rename follows from the removal. While two knobs exist, `widgetParameters` is |
| 183 | +a reasonable name. Once it is the only one, it is misleading — recipe parameters |
| 184 | +also become widget parameters yet are not curated by it. `targetParameters` names |
| 185 | +the surface it actually curates and pairs with the `target:` field. |
| 186 | + |
| 187 | +Resulting rules: |
| 188 | + |
| 189 | +- All recipe function parameters are always exposed on the generated widget. |
| 190 | +- `variant` is always handled automatically: pinned by (and omitted from) each |
| 191 | + named constructor, exposed on the unnamed constructor. It never needs to be |
| 192 | + listed anywhere, and condition 5 in §1 disappears. |
| 193 | +- Only target-constructor / `call()` parameters can be curated. |
| 194 | + |
| 195 | +### What it fixes |
| 196 | + |
| 197 | +The silent-failure gotcha: today, `factoryParameters: .only({'size'})` that |
| 198 | +omits `'variant'` produces **zero variant constructors with no error**. Remix hit |
| 199 | +exactly this on one component. Removing the knob removes the failure mode by |
| 200 | +construction rather than by documentation. |
| 201 | + |
| 202 | +### What it costs — accepted knowingly |
| 203 | + |
| 204 | +- **A breaking change** to an API published in `mix_generator 2.2.0-beta.2`. |
| 205 | + Accepted: the 2.2.0-beta line is opt-in and pre-stable. |
| 206 | +- **It removes one escape hatch for name collisions.** When a recipe parameter |
| 207 | + and a target parameter share a name with incompatible types, the generator |
| 208 | + today says *"exclude the parameter from either `factoryParameters` or |
| 209 | + `widgetParameters`"*. Afterwards only the target side can be excluded. |
| 210 | + Accepted deliberately: the author controls the recipe's parameter names and can |
| 211 | + rename to resolve the collision; they do not control the target widget's. The |
| 212 | + remaining hatch is on the surface that needs one. |
| 213 | +- Recipes that legitimately take more parameters when called directly than they |
| 214 | + want to expose as widget fields lose that flexibility. Accepted: split the |
| 215 | + recipe, or compute the extra input internally. |
| 216 | + |
| 217 | +Note that the generator **already refuses** to curate away a *required* factory |
| 218 | +parameter. Only optional ones can be hidden today, so this removes less than it |
| 219 | +appears to. |
| 220 | + |
| 221 | +### Outstanding input |
| 222 | + |
| 223 | +**Remix's Avatar** uses `factoryParameters: .only({...})` to keep an internal |
| 224 | +`fallbackLength` parameter off the widget. Removing the knob forces it onto the |
| 225 | +public widget surface, or forces a recipe-signature change. Their answer is the |
| 226 | +one input that could still alter this design — implement after it arrives. |
| 227 | + |
| 228 | +### Recorded reading of "always omit the variant" |
| 229 | + |
| 230 | +*The named constructors omit `variant` because they pin it; the unnamed |
| 231 | +constructor still exposes it; it is never subject to curation.* That is |
| 232 | +consistent with §2. It does **not** mean removing `variant` from the widget's |
| 233 | +public surface — that is the named-only design declined in §2. |
| 234 | + |
| 235 | +## 6. History |
| 236 | + |
| 237 | +| Ref | What | |
| 238 | +|---|---| |
| 239 | +| #968 `2524b74ad` | Variant constructors from a `variant` enum parameter. Unnamed constructor preserved. | |
| 240 | +| #974 `8c26ad6ce` | `widgetParameters` curation. | |
| 241 | +| #997 `726d85e84` | Direct `target:` support; added `factoryParameters`. | |
| 242 | +| #998 | Requested named-only widgets + convention discovery. Closed; core ask declined (§2). | |
| 243 | +| #999 | Two implementations, neither merged: first the registration design (§3), then, after a force-push, a named-only parameter-based design (§2). Closed unmerged. | |
0 commit comments