Skip to content

Commit 91199a8

Browse files
authored
docs(mix_generator): record @MixWidget variant constructor decisions (#1000)
1 parent 726d85e commit 91199a8

1 file changed

Lines changed: 243 additions & 0 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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

Comments
 (0)