From 6447c47e91eccdf9fb27fe287da5b4258084eaac Mon Sep 17 00:00:00 2001 From: Dillon Kearns Date: Sun, 22 Mar 2026 21:42:06 -0700 Subject: [PATCH 1/2] Fix wire encoder+decoder for type aliases with extensible records through alias chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a type alias like Color = ColorValue { red, green, blue, alpha } references an extensible record alias (ColorValue compatible = { compatible | value : String, color : Compatible }), the encoder and decoder's TAlias Holey branch only checked for a direct TRecord inner type to resolve extensible records. When the inner type is a TAlias chain (not a direct TRecord), both encoder and decoder fell through to normalEncoder/normalDecoder, which passed only the extension fields to the extensible record codec — losing the base fields (value, color). This caused a type mismatch when the codec produced a 4-field record instead of the full 6-field record. Fix: in both Decoder.hs and Encoder.hs, when the Holey inner type is not a direct TRecord, use resolveTvar to resolve through alias chains. If this produces a Filled TRecord, inline the fully merged record encoder/decoder. Test: Added Wire_Union_ForeignRecordAlias.elm test fixture with ExternalExtensibleBase/ExternalRecordViaExtensible types in External.elm. Verified red→green with lamdera make on a project using rtfeldman/elm-css. Runtime roundtrip verified for Css.Color and Css.Px across 12 test cases (DirectWrap, RecordWrap, ListWrap, Mixed unions, Complex nesting, etc). Co-Authored-By: Claude Opus 4.6 (1M context) --- extra/Lamdera/Wire3/Decoder.hs | 9 +- extra/Lamdera/Wire3/Encoder.hs | 9 +- test/Test/Wire.hs | 1 + test/scenario-alltypes/src/Test/External.elm | 35 ++++++++ .../Test/Wire_Union_ForeignRecordAlias.elm | 85 +++++++++++++++++++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 test/scenario-alltypes/src/Test/Wire_Union_ForeignRecordAlias.elm diff --git a/extra/Lamdera/Wire3/Decoder.hs b/extra/Lamdera/Wire3/Decoder.hs index b7d056b75..228efa902 100644 --- a/extra/Lamdera/Wire3/Decoder.hs +++ b/extra/Lamdera/Wire3/Decoder.hs @@ -416,7 +416,14 @@ decoderForType ifaces cname tipe = let extendedRecord = TRecord resolved Nothing & resolveTvar tvars_ in decoderForType ifaces cname extendedRecord Nothing -> normalDecoder - otherTypes -> normalDecoder + _ -> + -- Resolve extensible records through TAlias chains, + -- e.g. Color = ColorValue { red, green, blue, alpha } + case resolveTvar tvars_ tipe of + TAlias _ _ _ (Filled (TRecord fieldMap Nothing)) -> + let fields = fieldMap & fieldsToList & List.sortOn (\(name, field) -> name) + in decodeRecord ifaces cname fields + _ -> normalDecoder Filled tipe -> case tipe of TRecord fieldMap extensibleName -> diff --git a/extra/Lamdera/Wire3/Encoder.hs b/extra/Lamdera/Wire3/Encoder.hs index 8786f4bc8..e53084fcb 100644 --- a/extra/Lamdera/Wire3/Encoder.hs +++ b/extra/Lamdera/Wire3/Encoder.hs @@ -365,7 +365,14 @@ inlineIfRecordOrCall depth ifaces cname tipe tvars aType = in deepEncoderForType depth ifaces cname extendedRecord Nothing -> normalEncoder - otherTypes -> normalEncoder + _ -> + -- Resolve extensible records through TAlias chains, + -- e.g. Color = ColorValue { red, green, blue, alpha } + case resolveTvar tvars tipe of + TAlias _ _ _ (Filled (TRecord fieldMap Nothing)) -> + let extendedRecord = TRecord fieldMap Nothing + in deepEncoderForType depth ifaces cname extendedRecord + _ -> normalEncoder Filled _ -> normalEncoder {-| Called for encoding tvar type values, i.e. diff --git a/test/Test/Wire.hs b/test/Test/Wire.hs index b2ca119f4..67e4c9d06 100644 --- a/test/Test/Wire.hs +++ b/test/Test/Wire.hs @@ -130,6 +130,7 @@ wire = do , "src/Test/Wire_Tvar_Recursive_Reference.elm" , "src/Test/Wire_Unsupported.elm" , "src/Test/Wire_Unconstructable.elm" + , "src/Test/Wire_Union_ForeignRecordAlias.elm" ] let diff --git a/test/scenario-alltypes/src/Test/External.elm b/test/scenario-alltypes/src/Test/External.elm index 19e63d5ad..e52cd50ad 100644 --- a/test/scenario-alltypes/src/Test/External.elm +++ b/test/scenario-alltypes/src/Test/External.elm @@ -30,6 +30,14 @@ type alias SubSubRecordAlias threadedTvar = } +type alias ExternalExtensibleBase compatible = + { compatible | base : String } + + +type alias ExternalRecordViaExtensible = + ExternalExtensibleBase { red : Int, green : Int } + + expected_w3_encode_ExternalRecordBasic : ExternalRecordBasic -> Lamdera.Wire3.Encoder expected_w3_encode_ExternalRecordBasic = \w3_rec_var0 -> Lamdera.Wire3.encodeSequenceWithoutLength [ Lamdera.Wire3.encodeInt w3_rec_var0.int ] @@ -113,3 +121,30 @@ expected_w3_decode_ExternalCustomThreaded w3_x_c_threadedTvar w3_x_c_threadedTva _ -> Lamdera.Wire3.failDecode ) + + +expected_w3_encode_ExternalExtensibleBase : ({ compatible | base : String.String } -> Lamdera.Wire3.Encoder) -> ExternalExtensibleBase compatible -> Lamdera.Wire3.Encoder +expected_w3_encode_ExternalExtensibleBase w3_x_c_compatible = + w3_x_c_compatible + + +expected_w3_decode_ExternalExtensibleBase w3_x_c_compatible = + w3_x_c_compatible + + +expected_w3_encode_ExternalRecordViaExtensible : ExternalRecordViaExtensible -> Lamdera.Wire3.Encoder +expected_w3_encode_ExternalRecordViaExtensible = + \w3_rec_var0 -> + Lamdera.Wire3.encodeSequenceWithoutLength + [ Lamdera.Wire3.encodeString w3_rec_var0.base + , Lamdera.Wire3.encodeInt w3_rec_var0.green + , Lamdera.Wire3.encodeInt w3_rec_var0.red + ] + + +expected_w3_decode_ExternalRecordViaExtensible = + Lamdera.Wire3.succeedDecode + (\base0 green0 red0 -> { base = base0, green = green0, red = red0 }) + |> Lamdera.Wire3.andMapDecode Lamdera.Wire3.decodeString + |> Lamdera.Wire3.andMapDecode Lamdera.Wire3.decodeInt + |> Lamdera.Wire3.andMapDecode Lamdera.Wire3.decodeInt diff --git a/test/scenario-alltypes/src/Test/Wire_Union_ForeignRecordAlias.elm b/test/scenario-alltypes/src/Test/Wire_Union_ForeignRecordAlias.elm new file mode 100644 index 000000000..3494828bb --- /dev/null +++ b/test/scenario-alltypes/src/Test/Wire_Union_ForeignRecordAlias.elm @@ -0,0 +1,85 @@ +module Test.Wire_Union_ForeignRecordAlias exposing (..) + +import Bytes.Decode +import Bytes.Encode +import Lamdera.Wire3 +import Test.External exposing (ExternalRecordBasic, ExternalRecordViaExtensible) + + +{-| Regression test: extensible record aliases through alias chains. +See: +-} +type WrapsBasicRecord + = WrapsBasicRecord ExternalRecordBasic + + +type WrapsExtensibleRecord + = WrapsExtensibleRecord ExternalRecordViaExtensible + + +type WrapsInRecord + = WrapsInRecord { field : ExternalRecordViaExtensible } + + +expected_w3_encode_WrapsBasicRecord : WrapsBasicRecord -> Lamdera.Wire3.Encoder +expected_w3_encode_WrapsBasicRecord w3v = + case w3v of + WrapsBasicRecord v0 -> + Lamdera.Wire3.encodeSequenceWithoutLength [ Bytes.Encode.unsignedInt8 0, Test.External.w3_encode_ExternalRecordBasic v0 ] + + +expected_w3_decode_WrapsBasicRecord = + Bytes.Decode.unsignedInt8 + |> Lamdera.Wire3.andThenDecode + (\w3v -> + case w3v of + 0 -> + Lamdera.Wire3.succeedDecode WrapsBasicRecord |> Lamdera.Wire3.andMapDecode Test.External.w3_decode_ExternalRecordBasic + + _ -> + Lamdera.Wire3.failDecode + ) + + +expected_w3_encode_WrapsExtensibleRecord : WrapsExtensibleRecord -> Lamdera.Wire3.Encoder +expected_w3_encode_WrapsExtensibleRecord w3v = + case w3v of + WrapsExtensibleRecord v0 -> + Lamdera.Wire3.encodeSequenceWithoutLength [ Bytes.Encode.unsignedInt8 0, Test.External.w3_encode_ExternalRecordViaExtensible v0 ] + + +expected_w3_decode_WrapsExtensibleRecord = + Bytes.Decode.unsignedInt8 + |> Lamdera.Wire3.andThenDecode + (\w3v -> + case w3v of + 0 -> + Lamdera.Wire3.succeedDecode WrapsExtensibleRecord |> Lamdera.Wire3.andMapDecode Test.External.w3_decode_ExternalRecordViaExtensible + + _ -> + Lamdera.Wire3.failDecode + ) + + +expected_w3_encode_WrapsInRecord : WrapsInRecord -> Lamdera.Wire3.Encoder +expected_w3_encode_WrapsInRecord w3v = + case w3v of + WrapsInRecord v0 -> + Lamdera.Wire3.encodeSequenceWithoutLength [ Bytes.Encode.unsignedInt8 0, Test.External.w3_encode_ExternalRecordViaExtensible v0.field ] + + +expected_w3_decode_WrapsInRecord = + Bytes.Decode.unsignedInt8 + |> Lamdera.Wire3.andThenDecode + (\w3v -> + case w3v of + 0 -> + Lamdera.Wire3.succeedDecode WrapsInRecord + |> Lamdera.Wire3.andMapDecode + (Lamdera.Wire3.succeedDecode (\field0 -> { field = field0 }) + |> Lamdera.Wire3.andMapDecode Test.External.w3_decode_ExternalRecordViaExtensible + ) + + _ -> + Lamdera.Wire3.failDecode + ) From 3aa00f19fcf92ff2aee82b57a409553bf14a64c7 Mon Sep 17 00:00:00 2001 From: Dillon Kearns Date: Tue, 24 Mar 2026 09:19:55 -0700 Subject: [PATCH 2/2] Use as-pattern in encoder; address PR feedback - Use @-pattern to avoid reconstructing TRecord - No sorting needed in encoder (accesses fields by name, consistent with existing Holey TRecord branch at line 364) Co-Authored-By: Claude Opus 4.6 (1M context) --- extra/Lamdera/Wire3/Encoder.hs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/extra/Lamdera/Wire3/Encoder.hs b/extra/Lamdera/Wire3/Encoder.hs index e53084fcb..ac0f84e79 100644 --- a/extra/Lamdera/Wire3/Encoder.hs +++ b/extra/Lamdera/Wire3/Encoder.hs @@ -369,9 +369,8 @@ inlineIfRecordOrCall depth ifaces cname tipe tvars aType = -- Resolve extensible records through TAlias chains, -- e.g. Color = ColorValue { red, green, blue, alpha } case resolveTvar tvars tipe of - TAlias _ _ _ (Filled (TRecord fieldMap Nothing)) -> - let extendedRecord = TRecord fieldMap Nothing - in deepEncoderForType depth ifaces cname extendedRecord + TAlias _ _ _ (Filled extendedRecord@(TRecord _ Nothing)) -> + deepEncoderForType depth ifaces cname extendedRecord _ -> normalEncoder Filled _ -> normalEncoder