From eab46d3c30fcd66544c2f9f9d1bd82f2bed6a870 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 10:05:45 -0500 Subject: [PATCH 1/7] fix: update test expectation --- tests/testthat/test-ggplot-legend.R | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/testthat/test-ggplot-legend.R b/tests/testthat/test-ggplot-legend.R index 9dd92ea96c..b0ba92c0e3 100644 --- a/tests/testthat/test-ggplot-legend.R +++ b/tests/testthat/test-ggplot-legend.R @@ -1,5 +1,4 @@ - expect_traces <- function(gg, n.traces, name){ stopifnot(is.numeric(n.traces)) L <- expect_doppelganger_built(gg, paste0("legend-", name)) @@ -29,8 +28,8 @@ test_that("Discrete colour and shape get merged into one legend", { nms, paste0("(", d$vs, ",", d$cyl, ")") ) legend_title <- info$layout$legend$title$text - expect_match(legend_title, "^factor\\(vs\\)") - expect_match(legend_title, "factor\\(cyl\\)$") + expect_match(legend_title, "factor(vs)", fixed = TRUE) + expect_match(legend_title, "factor(cyl)", fixed = TRUE) }) From 91f450e4be69c43c38b5dee71acbfafd1559b741 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 11:07:55 -0500 Subject: [PATCH 2/7] update visual tests --- tests/testthat/_snaps/ggplot-legend/scatter-legend.svg | 2 +- tests/testthat/_snaps/ggridges/styling-points2.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testthat/_snaps/ggplot-legend/scatter-legend.svg b/tests/testthat/_snaps/ggplot-legend/scatter-legend.svg index 2b21497f20..fdbada61d9 100644 --- a/tests/testthat/_snaps/ggplot-legend/scatter-legend.svg +++ b/tests/testthat/_snaps/ggplot-legend/scatter-legend.svg @@ -1 +1 @@ -1015202530352345factor(vs)factor(cyl)(0,4)(0,6)(0,8)(1,4)(1,6)mpgwt +1015202530352345factor(cyl)factor(vs)(0,4)(0,6)(0,8)(1,4)(1,6)mpgwt diff --git a/tests/testthat/_snaps/ggridges/styling-points2.svg b/tests/testthat/_snaps/ggridges/styling-points2.svg index 1569f1f23d..1e0491b896 100644 --- a/tests/testthat/_snaps/ggridges/styling-points2.svg +++ b/tests/testthat/_snaps/ggridges/styling-points2.svg @@ -1 +1 @@ -45678setosaversicolorvirginicaPetal.LengthSpecies(virginica,1)(versicolor,1)(setosa,1)Sepal.LengthSpecies +45678setosaversicolorvirginicaSpeciesPetal.Length(virginica,1)(versicolor,1)(setosa,1)Sepal.LengthSpecies From 1d6a59d888dad089403ef57f04bf9cca26cc4141 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:09:19 -0500 Subject: [PATCH 3/7] Make merged-legend title order match legend-entry order 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)
factor(cyl)" became "factor(cyl)
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. --- R/ggplotly.R | 17 +++++++++++++++++ tests/testthat/test-ggplot-legend.R | 11 ++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/R/ggplotly.R b/R/ggplotly.R index 1c4461fc15..afe8befbf3 100644 --- a/R/ggplotly.R +++ b/R/ggplotly.R @@ -1592,6 +1592,23 @@ get_gdefs_ggproto <- function(scales, theme, plot, layers, layer_data) { if (length(guides$guides) > 0) { guides$merge() guides$process_layers(layers, layer_data) + # guides$merge() sorts guides by their explicit `order` (guide_legend(order=)), + # but breaks ties (the common case, since `order` defaults to 0/unset for + # everyone) using a content hash that bears no relation to aesthetic + # declaration order (ggplot2's `Guides$merge()`, via rlang::hash()). + # layers2traces() names legend entries in aesthetic declaration order (see + # `discreteScales`), so on ties, guides must be put back in that same + # order for the combined legend title (built below) to agree with the + # entries it labels -- while still respecting any explicit `order`. + guide_order <- vapply(guides$params, function(p) { + o <- p$order %||% 0 + if (identical(o, 0)) 99L else as.integer(o) + }, integer(1)) + declaration_order <- match(guides$aesthetics, aesthetics) + ord <- order(guide_order, declaration_order) + guides$guides <- guides$guides[ord] + guides$params <- guides$params[ord] + guides$aesthetics <- guides$aesthetics[ord] } # Add old legend/colorbar classes to guide params so that ggplotly() code # can continue to work the same way it always has diff --git a/tests/testthat/test-ggplot-legend.R b/tests/testthat/test-ggplot-legend.R index b0ba92c0e3..50f362d21b 100644 --- a/tests/testthat/test-ggplot-legend.R +++ b/tests/testthat/test-ggplot-legend.R @@ -27,9 +27,11 @@ test_that("Discrete colour and shape get merged into one legend", { expect_identical( nms, paste0("(", d$vs, ",", d$cyl, ")") ) + # legend title segments must appear in the same order as the aesthetics + # within each entry's name (e.g. "(vs_value,cyl_value)" above), since both + # describe the same combined legend legend_title <- info$layout$legend$title$text - expect_match(legend_title, "factor(vs)", fixed = TRUE) - expect_match(legend_title, "factor(cyl)", fixed = TRUE) + expect_identical(legend_title, "factor(vs)
factor(cyl)") }) @@ -88,8 +90,11 @@ test_that("legend can be manipulated via guides(aes = guide_xxx())", { theme(axis.text.x = element_text(angle = 90)) info <- expect_doppelganger_built(gg, "respect-guides") - + expect_equivalent(sum(sapply(info$data, "[[", "showlegend")), 4) + # explicit guide `order` must be respected, even though it disagrees with + # the shape-before-color order the aesthetics were declared in above + expect_identical(info$layout$legend$title$text, "Period
") }) p <- ggplot(mtcars, aes(x = mpg, y = wt, color = factor(vs))) + From 8ccf61d4fa5bba29bee856b14152f89aa01337ec Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:16:36 -0500 Subject: [PATCH 4/7] fix: replace deprecated .Label/.Names structure() names in tests Silences the R CMD check NOTE about deprecated special names in structure() calls. --- tests/testthat/test-ggplot-facets.R | 6 +++--- tests/testthat/test-group2NA.R | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/testthat/test-ggplot-facets.R b/tests/testthat/test-ggplot-facets.R index d8fd07c627..c862a2f86f 100644 --- a/tests/testthat/test-ggplot-facets.R +++ b/tests/testthat/test-ggplot-facets.R @@ -6,9 +6,9 @@ test_that("6 facets becomes 6 panels", { barley <- structure(list( yield = c(27, 48.86667, 27.43334, 39.93333, 32.96667, 28.96667, 43.06666, 55.2, 28.76667, 38.13333, 29.13333, 29.66667, 35.13333, 47.33333, 25.76667, 40.46667, 29.66667, 25.7, 39.9, 50.23333, 26.13333, 41.33333, 23.03333, 26.3, 36.56666, 63.8333, 43.76667, 46.93333, 29.76667, 33.93333, 43.26667, 58.1, 28.7, 45.66667, 32.16667, 33.6, 36.6, 65.7667, 30.36667, 48.56666, 24.93334, 28.1, 32.76667, 48.56666, 29.86667, 41.6, 34.7, 32, 24.66667, 46.76667, 22.6, 44.1, 19.7, 33.06666, 39.3, 58.8, 29.46667, 49.86667, 34.46667, 31.6, 26.9, 33.46667, 34.36666, 32.96667, 22.13333, 22.56667, 36.8, 37.73333, 35.13333, 26.16667, 14.43333, 25.86667, 27.43334, 38.5, 35.03333, 20.63333, 16.63333, 22.23333, 26.8, 37.4, 38.83333, 32.06666, 32.23333, 22.46667, 29.06667, 49.2333, 46.63333, 41.83333, 20.63333, 30.6, 26.43334, 42.2, 43.53334, 34.33333, 19.46667, 22.7, 25.56667, 44.7, 47, 30.53333, 19.9, 22.5, 28.06667, 36.03333, 43.2, 25.23333, 26.76667, 31.36667, 30, 41.26667, 44.23333, 32.13333, 15.23333, 27.36667, 38, 58.16667, 47.16667, 35.9, 20.66667, 29.33333), variety = structure(c(3L, 3L, 3L, 3L, 3L, 3L, 7L, 7L, 7L, 7L, 7L, 7L, 1L, 1L, 1L, 1L, 1L, 1L, 5L, 5L, 5L, 5L, 5L, 5L, 10L, 10L, 10L, 10L, 10L, 10L, 8L, 8L, 8L, 8L, 8L, 8L, 2L, 2L, 2L, 2L, 2L, 2L, 6L, 6L, 6L, 6L, 6L, 6L, 4L, 4L, 4L, 4L, 4L, 4L, 9L, 9L, 9L, 9L, 9L, 9L, 3L, 3L, 3L, 3L, 3L, 3L, 7L, 7L, 7L, 7L, 7L, 7L, 1L, 1L, 1L, 1L, 1L, 1L, 5L, 5L, 5L, 5L, 5L, 5L, 10L, 10L, 10L, 10L, 10L, 10L, 8L, 8L, 8L, 8L, 8L, 8L, 2L, 2L, 2L, 2L, 2L, 2L, 6L, 6L, 6L, 6L, 6L, 6L, 4L, 4L, 4L, 4L, 4L, 4L, 9L, 9L, 9L, 9L, 9L, 9L), - .Label = c("Svansota", "No. 462", "Manchuria", "No. 475", "Velvet", "Peatland", "Glabron", "No. 457", "Wisconsin No. 38", "Trebi"), class = "factor"), - year = structure(c(2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L), .Label = c("1932", "1931"), class = "factor"), - site = structure(c(3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L), .Label = c("Grand Rapids", "Duluth", "University Farm", "Morris", "Crookston", "Waseca"), class = "factor")), + levels = c("Svansota", "No. 462", "Manchuria", "No. 475", "Velvet", "Peatland", "Glabron", "No. 457", "Wisconsin No. 38", "Trebi"), class = "factor"), + year = structure(c(2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 2L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L), levels = c("1932", "1931"), class = "factor"), + site = structure(c(3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L, 3L, 6L, 4L, 5L, 1L, 2L), levels = c("Grand Rapids", "Duluth", "University Farm", "Morris", "Crookston", "Waseca"), class = "factor")), row.names = c("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", "116", "117", "118", "119", "120"), class = "data.frame" ) diff --git a/tests/testthat/test-group2NA.R b/tests/testthat/test-group2NA.R index 9321c4ce50..81873266a8 100644 --- a/tests/testthat/test-group2NA.R +++ b/tests/testthat/test-group2NA.R @@ -63,7 +63,7 @@ test_that("group2NA() yields the correct result", { id = c(27L, NA, 3L, 8L, 9L, 18L, 19L, 20L, 21L, 26L, 28L, 32L, 1L, 2L, 30L, NA, 4L, 6L, 10L, 11L, 5L, 7L, 12L, 13L, 14L, 15L, 16L, 17L, 22L, 23L, 24L, 25L, 29L, 31L)), - .Names = c("mpg", "cyl", "disp", "hp", "drat", "wt", "qsec", "vs", "am", "gear", "carb", "id"), + names = c("mpg", "cyl", "disp", "hp", "drat", "wt", "qsec", "vs", "am", "gear", "carb", "id"), class = "data.frame", row.names = c(NA, 34L)) From a365a7dcf991f3378ec7a2db3a21bb5c4d89c581 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:17:28 -0500 Subject: [PATCH 5/7] Revert changes to visual tests --- tests/testthat/_snaps/ggplot-legend/scatter-legend.svg | 2 +- tests/testthat/_snaps/ggridges/styling-points2.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testthat/_snaps/ggplot-legend/scatter-legend.svg b/tests/testthat/_snaps/ggplot-legend/scatter-legend.svg index fdbada61d9..2b21497f20 100644 --- a/tests/testthat/_snaps/ggplot-legend/scatter-legend.svg +++ b/tests/testthat/_snaps/ggplot-legend/scatter-legend.svg @@ -1 +1 @@ -1015202530352345factor(cyl)factor(vs)(0,4)(0,6)(0,8)(1,4)(1,6)mpgwt +1015202530352345factor(vs)factor(cyl)(0,4)(0,6)(0,8)(1,4)(1,6)mpgwt diff --git a/tests/testthat/_snaps/ggridges/styling-points2.svg b/tests/testthat/_snaps/ggridges/styling-points2.svg index 1e0491b896..1569f1f23d 100644 --- a/tests/testthat/_snaps/ggridges/styling-points2.svg +++ b/tests/testthat/_snaps/ggridges/styling-points2.svg @@ -1 +1 @@ -45678setosaversicolorvirginicaSpeciesPetal.Length(virginica,1)(versicolor,1)(setosa,1)Sepal.LengthSpecies +45678setosaversicolorvirginicaPetal.LengthSpecies(virginica,1)(versicolor,1)(setosa,1)Sepal.LengthSpecies From 01913e543787d89d9c4074d709ea5eb481b509c7 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:36:18 -0500 Subject: [PATCH 6/7] Update other visual tests --- tests/testthat/_snaps/ggplot-size/size-global-scaling.svg | 2 +- tests/testthat/_snaps/ggplot-theme/theme-marker-default.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testthat/_snaps/ggplot-size/size-global-scaling.svg b/tests/testthat/_snaps/ggplot-size/size-global-scaling.svg index 98c56dd7f3..ac1dbc9f20 100644 --- a/tests/testthat/_snaps/ggplot-size/size-global-scaling.svg +++ b/tests/testthat/_snaps/ggplot-size/size-global-scaling.svg @@ -1 +1 @@ -2340.40.81.21.6populationcountryParaguayPeruPhilippineseduilln +2340.40.81.21.6countrypopulationParaguayPeruPhilippineseduilln diff --git a/tests/testthat/_snaps/ggplot-theme/theme-marker-default.svg b/tests/testthat/_snaps/ggplot-theme/theme-marker-default.svg index cb7d459437..79dc683dc8 100644 --- a/tests/testthat/_snaps/ggplot-theme/theme-marker-default.svg +++ b/tests/testthat/_snaps/ggplot-theme/theme-marker-default.svg @@ -1 +1 @@ -2340.40.81.21.6populationcountryParaguayPeruPhilippineseduilln +2340.40.81.21.6countrypopulationParaguayPeruPhilippineseduilln From 0bff7a89e7fedafea57cee2e7775a01775e0e70c Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 21 Jul 2026 13:37:14 -0500 Subject: [PATCH 7/7] Update news --- NEWS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEWS.md b/NEWS.md index 174a6a732f..2bf1e22ed5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,6 +9,8 @@ * Closed #2483: `save_image()` no longer embeds Windows file paths directly into Python source passed to `reticulate`, fixing static image export with Kaleido on Windows. +* #2495: `ggplotly()` now keeps merged legend titles in sync with their legend entries, fixing a case where an upstream change in `{rlang}`'s hashing could silently reorder combined legend titles (e.g. `factor(vs)
factor(cyl)`) out of step with the entries they label. + # plotly 4.12.0