Skip to content

fix: omit proto3 implicit-presence defaults when encoding from parse().root#2345

Merged
dcodeIO merged 2 commits into
protobufjs:masterfrom
spokodev:fix/proto3-default-presence-without-resolveall
Jul 6, 2026
Merged

fix: omit proto3 implicit-presence defaults when encoding from parse().root#2345
dcodeIO merged 2 commits into
protobufjs:masterfrom
spokodev:fix/proto3-default-presence-without-resolveall

Conversation

@spokodev

Copy link
Copy Markdown
Contributor

A proto3 singular scalar set to its default value is incorrectly serialized onto the wire when the type is built via parse(proto).root or new Root().addJSON(json) and encoded without first calling resolveAll().

const M = protobuf.parse('syntax="proto3"; message M { int32 i32=1; string s=2; bool b=3; }')
  .root.lookupType("M");

M.encode({ i32: 0 }).finish()    // [8, 0]   — spec requires []
M.encode({ s: "" }).finish()     // [18, 0]  — spec requires []
M.encode({ b: false }).finish()  // [24, 0]  — spec requires []
M.encode({ i32: 5 }).finish()    // [8, 5]   — correct

Calling root.resolveAll() first (or using load()/loadSync()/fromJSON(), which resolve internally) produces the correct [], so only the lazy parse().root encode path is affected.

Cause

Type#setup() — the lazy initializer that builds the encoder on first encode() — resolves field types (field.resolve()) but never resolves feature defaults. So at codegen time field._features.field_presence is still undefined, and Field#hasPresence returns undefined !== "IMPLICIT"true. The generated encoder then takes the presence-tracking branch (no default-value check) for an implicit-presence field and emits its default.

Impact

Non-canonical proto3 output from the most common inline-proto path: larger payloads, and byte-mismatches against peers that omit defaults (Go/C++/Python, and protobuf.js's own load()), which breaks deterministic serialization, signing/hashing of serialized bytes, and golden-file/conformance comparisons. Decoding is unaffected.

Fix

Resolve feature defaults in setup() before the codecs are generated — the same resolution load()/loadSync()/fromJSON() already perform — so parse().root produces the same wire form. Verified across syntaxes: proto3 implicit fields now omit defaults ([]); proto2, proto3 optional, and editions explicit-presence fields still emit them ([8,0]); -0.0 is still preserved.

Tests

Added a comp_optional case mirroring the existing "implicit scalar conversion defaults" test but without the resolveAll() call. It fails on the current code ([8,0]…) and passes with the fix. Full test:sources green (2723 passing, 0 failing); no regressions.

…).root

A proto3 singular scalar set to its default value was serialized onto the
wire when the type was built via parse(proto).root or new Root().addJSON()
and encoded without a prior resolveAll().

Type#setup() (the lazy initializer that builds the encoder on first encode)
resolved field types but not feature defaults, so field presence was still
unresolved when the encoder was generated. Field#hasPresence then read an
undefined field_presence feature and returned true, making the encoder
treat an implicit-presence field as explicit and emit its default value.

Resolve feature defaults in setup() before generating the codecs, the same
resolution load()/loadSync()/fromJSON() already perform. proto3 implicit
fields now omit defaults; proto2, proto3 optional, and editions
explicit-presence fields are unaffected.
@dcodeIO dcodeIO merged commit d59d100 into protobufjs:master Jul 6, 2026
5 checks passed
@dcodeIO

dcodeIO commented Jul 6, 2026

Copy link
Copy Markdown
Member

Thanks!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants