Skip to content

fix(ggplotly): preserve legend ordering #2495

Merged
cpsievert merged 7 commits into
masterfrom
fix/ci
Jul 21, 2026
Merged

fix(ggplotly): preserve legend ordering #2495
cpsievert merged 7 commits into
masterfrom
fix/ci

Conversation

@cpsievert

@cpsievert cpsievert commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Why

CRAN and CI started failing on test-ggplot-legend.R with no code changes on our side:

Expected `legend_title` to match regexp "^factor\(vs\)".
Actual text:
✖ │ factor(cyl)<br />factor(vs)

Root cause: when a plot has two separate discrete legends (e.g. color +
shape) that ggplot2 can't merge into one, ggplot2 orders them for display
using a content hash (rlang::hash()) as a tie-break whenever neither guide
has an explicit order=. A recent rlang release changed how that hash is
computed ("all hash values will now be different" - rlang 1.3.0 NEWS), which
silently flipped which guide sorts first - in real ggplot2::ggplotGrob()
renders too, not just plotly's output. Nothing in this package or in user
plot code changed; a transitive dependency bump reordered something ggplot2
never actually guaranteed the order of.

That would just be cosmetic, except plotly builds the combined legend title
(R/ggplotly.R) from that same hash-ordered guide list, while the legend
entries underneath it (the actual traces/markers) are named independently,
always in the order aesthetics were declared in aes()
(layers2traces.R's discreteScales). So the rlang bump made the title
order and the entry order disagree with each other, which is the actual bug
worth fixing here (not just "match whatever ggplot2 happens to do this
week").

What changed

get_gdefs_ggproto() now re-sorts guides after guides$merge() by
(explicit guide order, aesthetic declaration order) instead of leaving
ggplot2's hash tie-break in place. This:

  • keeps legend titles in agreement with the legend entries they label,
    regardless of future hashing/rlang/ggplot2 changes
  • still respects an explicit guide_legend(order = ...) when the user sets
    one (see the "legend can be manipulated via guides()" test, which
    deliberately declares shape before color in aes() but orders them the
    opposite way via order=)

Known gap (not fixed here, on purpose): the pre-ggplot2-3.5.0 legacy guide
code path (get_gdefs()) has the identical latent ordering issue, but it's
untestable in any currently-installed ggplot2 and effectively dead code, so
it was left alone.

Follow-up needed: tests/testthat/_snaps/ggplot-legend/scatter-legend.svg
still encodes the old (regressed) title order and needs to be regenerated
via the visual-test Docker flow before the macOS/VISUAL_TESTS CI leg will
pass again.

ggplot2's Guides$merge() breaks ties between same-order guides using a
content hash (rlang::hash()), which has no relation to aesthetic
declaration order. A recent rlang release changed that hash, flipping
the tie-break and silently reordering combined legend titles (e.g.
"factor(vs)<br />factor(cyl)" became "factor(cyl)<br />factor(vs)").

Legend entries themselves are named independently, always in aesthetic
declaration order (layers2traces.R's discreteScales), so title order
and entry order could now disagree. Reorder guides in
get_gdefs_ggproto() by (explicit guide order, aesthetic declaration
order) so the title always agrees with the entries it labels,
regardless of upstream hashing changes.
Silences the R CMD check NOTE about deprecated special names in
structure() calls.
@cpsievert cpsievert changed the title fix: get R CMD check & CI passing fix(ggplotly): preserve legend ordering Jul 21, 2026
@cpsievert
cpsievert merged commit 6b74f6f into master Jul 21, 2026
10 checks passed
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.

1 participant