Skip to content

types: PatchField impls for domain newtypes — retire the reactive-store primitive carve-outs #587

Description

@mdorman

Problem

reactive_stores store rows (#[derive(Patch)]) can only hold field types that
implement PatchField, and the orphan rule means web — which owns neither the
trait nor the newtypes — cannot write those impls. The result is documented
carve-outs where validated domain values are erased to primitives inside web
store DTOs:

  • web/src/audiences/api.rs:100-103AudienceSummary.name: String, holding a
    value that is an AudienceName at construction, edge-converted with a comment
    explaining the PatchField gap.
  • The types: newtype AudienceId and thread it through storage/web #475 keyed-store carve-out — AudienceSummary.audience_id: i64 instead of
    AudienceId, same root cause.

The trait itself is not the obstacle: in the pinned reactive_stores 0.4.3,
PatchField is public and unsealed, with one method
(patch_field(&mut self, new: Self, path: &StorePath, notify: &mut dyn FnMut(&StorePath), _keys: Option<&KeyMap>)), and the crate's own leaf impls for
String/i64/etc. are just compare-assign-notify — trivially writable for any
PartialEq type, which every newtype trailer already provides. Upstream can't
offer a blanket impl<T: PartialEq> (it would conflict with their struct
impls), which is why the leaf set is a closed enumeration.

Direction

The orphan-rule-legal home is common: add reactive_stores as an optional,
feature-gated
dependency (it is already in the workspace lock at 0.4.3, so no
vendor churn) and provide the leaf PatchField impls there — either hand-written
for the store-facing newtypes, or folded into the StrNewtype/IdNewtype
derive trailer behind the feature so the carve-out pattern is retired, not one
instance.

This changes common's dependency posture (version-coupling to the leptos
release train), which is a design decision worth recording — an ADR (or an
ADR-0063 addendum) before the mechanical work.

Then:

Alternatives considered and rejected in the audit discussion: a web-local
wrapper type (legal, but every field becomes Wrapper<T>), and upstreaming a
leaf-impl macro to leptos-rs (timeline outside our control).

Acceptance

  • The dependency decision recorded (ADR or addendum) before implementation.
  • Feature-gated PatchField support in common (derive-trailer or explicit
    impls), with the impl behavior covered (macros crate is coverage-measured if
    the derive route is taken).
  • AudienceSummary.name typed AudienceName; the types: newtype AudienceId and thread it through storage/web #475 key decision re-examined
    and either flipped or re-documented with the concrete blocker.
  • cargo xtask validate --no-e2e clean.

Related

Family: #475 (AudienceId + the keyed carve-out), #350 (AudienceName), #503
(render-prop newtypes), ADR-0063. From the 2026-07-21 post-#560 newtype audit.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Status
    Todo

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions