Skip to content

Fix deparse round-trip for evaluated ggplot2 theme elements in parse_ggplot2_args()#345

Open
stefanlinner wants to merge 4 commits into
insightsengineering:mainfrom
stefanlinner:fix_ggplot2_args
Open

Fix deparse round-trip for evaluated ggplot2 theme elements in parse_ggplot2_args()#345
stefanlinner wants to merge 4 commits into
insightsengineering:mainfrom
stefanlinner:fix_ggplot2_args

Conversation

@stefanlinner

Copy link
Copy Markdown

Fixes insightsengineering/teal.modules.general#974
TrackB_RMedicine_Hackathon_Apr2026

Problem

When users pass evaluated ggplot2 theme elements to ggplot2_args(), for example:

ggplot2_args(
  theme = list(text = element_text(size = 20))
)

the element objects get embedded as complex nested structures. When parse_ggplot2_args() builds the call via as.call() and teal's pipeline later deparses it to a string, the result looks like:

ggplot2::theme(text = structure(list(family = NULL, ..., margin = structure(...)), class = c("element_text", "element")))

This fails on re-parse because structure() cannot faithfully reconstruct nested grid/unit internals. The workaround so far has been to require quote() around element calls, which is not obvious to users.

Solution

This PR adds a to_call() helper (and supporting internal functions element_to_call(), margin_to_call(), unit_to_call()) that converts already evaluated ggplot2 element objects back into their constructor call form. For instance, an evaluated element_text(size = 20) object is reconstructed as the call ggplot2::element_text(size = 20).

The conversion is applied in parse_ggplot2_args() via lapply(ggplot2_args$theme, to_call) before the call is assembled. This is the last point where we control call construction before teal's eval_code() pipeline deparses the expression.

Covered element types: element_text, element_line, element_rect, element_blank, rel(), margin(), unit(), and waiver(). Already quoted calls and plain atomic values pass through unchanged. Unrecognized objects trigger a warning that suggests using quote() as a fallback.

Trade-offs

The reconstruction relies on utils::getFromNamespace() and formals() to introspect ggplot2 constructors at runtime, comparing property values against a default instance to determine which arguments the user actually set. This is coupled to ggplot2 internals, but the alternative (requiring quote() everywhere) is a worse user experience.

Other changes

  • Added grid to Imports in DESCRIPTION (needed for grid::unitType() in margin/unit reconstruction).
  • Removed ggplot2 version branching (packageVersion("ggplot2") <= "3.5.2") from tests. These branches were testing the old broken deparse output; with to_call() the output is clean across versions.
  • Added unit tests for to_call() covering each element type, passthrough behavior, the nested margin case, and the warning for unrecognized objects.

@github-actions

github-actions Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

✅ All contributors have signed the CLA
Posted by the CLA Assistant Lite bot.

@stefanlinner

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA.

@stefanlinner

Copy link
Copy Markdown
Author

recheck

@stefanlinner

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@stefanlinner

Copy link
Copy Markdown
Author

recheck

@osenan osenan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dear @stefanlinner

Thank you very much for your PR! I have not finished fully testing and reviewing your solution. But I can already suggest some changes:

  • Style: Please do not use abbreviations for variable names, they make the code more confusing with time. I have highlighted one, review the text and rename variables to a more meaningful name that is easier to read for code maintainers.
  • Tests: We try to test functions by calling only exported functions in the unit tests. Therefore, internal functions should be called indirectly, preparing scenarios and datasets that test all the code. Refactor the highlighted test so we only use the exported function.

Finally, please call spelling::update_wordlist with the words failing in the Spell Check job so it passes all CI checks.

Comment thread tests/testthat/test-ggplot2_args.R Outdated
Comment on lines +212 to +261
testthat::test_that("to_call passes through already-quoted calls unchanged", {
q <- quote(ggplot2::element_text(size = 20))
testthat::expect_identical(teal.widgets:::to_call(q), q)
})

testthat::test_that("to_call passes through atomic values unchanged", {
testthat::expect_identical(teal.widgets:::to_call("red"), "red")
testthat::expect_identical(teal.widgets:::to_call(42), 42)
testthat::expect_identical(teal.widgets:::to_call(TRUE), TRUE)
testthat::expect_null(teal.widgets:::to_call(NULL))
})

testthat::test_that("to_call reconstructs element_text", {
result <- teal.widgets:::to_call(ggplot2::element_text(size = 20))
testthat::expect_true(is.call(result))
# Verify round-trip
evaled <- eval(result)
testthat::expect_identical(evaled$size, 20)
})

testthat::test_that("to_call reconstructs element_rect", {
result <- teal.widgets:::to_call(ggplot2::element_rect(fill = "blue", colour = "black"))
testthat::expect_true(is.call(result))
evaled <- eval(result)
testthat::expect_identical(evaled$fill, "blue")
testthat::expect_identical(evaled$colour, "black")
})

testthat::test_that("to_call reconstructs element_text with custom margin via round-trip", {
original <- ggplot2::element_text(size = 14, margin = ggplot2::margin(5, 10, 5, 10))
result <- teal.widgets:::to_call(original)
testthat::expect_true(is.call(result))
deparsed <- deparse(result)
evaled <- eval(parse(text = deparsed))
testthat::expect_identical(evaled$size, 14)
testthat::expect_equal(as.numeric(evaled$margin), c(5, 10, 5, 10))
})

testthat::test_that("to_call reconstructs rel()", {
result <- teal.widgets:::to_call(ggplot2::rel(1.5))
testthat::expect_true(is.call(result))
testthat::expect_identical(eval(result), ggplot2::rel(1.5))
})

testthat::test_that("to_call warns for unrecognized non-atomic objects", {
# An arbitrary list that isn't a ggplot2 element
testthat::expect_warning(
teal.widgets:::to_call(structure(list(), class = "some_custom_thing")),
"could not be converted to a call"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, we do not include unit test directly calling the internal functions. Instead, please try calling parse_ggplot_args and prepare scenarios that indirectly will test to_call.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, thanks for your first feedback! I adapted the unit tests accordingly.

Comment thread R/ggplot2_args.R Outdated
#' @keywords internal
unit_to_call <- function(x) {
vals <- as.numeric(x)
u <- grid::unitType(x)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please do not use abbreviations

Suggested change
u <- grid::unitType(x)
unit_type <- grid::unitType(x)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I adapted the code accordingly.

Comment thread R/ggplot2_args.R Outdated
constructor <- utils::getFromNamespace(cls, "ggplot2")
default_obj <- tryCatch(constructor(), error = function(e) NULL)
# "color" is an alias for "colour" in ggplot2
param_names <- setdiff(names(formals(constructor)), c("...", "inherit.blank", "color"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
param_names <- setdiff(names(formals(constructor)), c("...", "inherit.blank", "color"))
param_names <- setdiff(names(formals(constructor)), c("...", "color"))

Not sure why we're excluding inherit.blank. The way it is now, if user set inherit.blank = TRUE, it will never go through.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I included that while testing and forgot to remove it later on. Thanks!

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: ggplot2_args not behaving properly with teal_data_module

3 participants