A Four-Day knitr Issue/PR Backlog Sprint

11 years of tech debt (almost) cleared

Yihui Xie 2026-09-20

After knitr v1.52 went to CRAN on 2026-09-06, I did something I don’t do often but have been hoping to do: I sat down and worked through the GitHub issue tracker more or less top to bottom. Over the past four days, I went through the whole backlog (with AI assistance). The issues and pull requests I fixed/merged/closed span more than a decade—the oldest was filed in May 2015, older than my oldest son! I have to admit that I’ve been exhausted by this sprint.

This post is a summary of the sprint, so you know what landed in the current development version (to be v1.53 in future) and what to look forward to in the next CRAN release. If you want to try any of it now, grab the development version:

install.packages('knitr', repos = 'https://yihui.r-universe.dev')

Unless anything urgent comes up, the next CRAN release of knitr won’t take place until a few months later, so you should have enough time to test everything mentioned in this post and give me feedback.

By the numbers

Over these four days of backlog work:

A large share of the closed issues were questions, stale reports, or things that had quietly been fixed years ago and just never got closed. Clearing those out is not glamorous work, but a tidy tracker is a lot easier to reason about, and it makes the genuinely open problems easier to see.

I did not, however, get all the way to zero. knitr still has a tiny number of open issues, so once again I have to admit that I can’t beat Will Landau (even with AI).

A small change, thirteen years in the making

Let me start with a minor one—not the most important thing that landed this cycle, but the one with an interesting backstory. It’s a tiny change in output, and it took me thirteen years to make (of course, I didn’t think of it every day).

The idea: in a single code chunk, echo one line of code but show the result of another. For example, show the reader mtcars in the source, but display the output of rmarkdown::paged_table(mtcars) instead of the raw data frame:

```{r, echo=1, eval=2}
mtcars
rmarkdown::paged_table(mtcars)
```

Jared Lander first asked me for this (not these two lines of code but something similar—I recall it was SQL-related) at the New York Strata conference in 2013. I remember thinking it was both a little tricky to implement and a little like “cheating”—showing code you didn’t actually run—so I quietly let it sit. It came back as #2129 in 2022, I said “okay, let’s reconsider,” and then… I let it sit again.

The eval option has long accepted a numeric vector to evaluate only certain lines, but the lines that were echoed-but-not-evaluated got a ## comment prefix slapped on them, so you couldn’t cleanly show them as real code. That comment prefix is now gone: the un-evaluated lines are masked internally with a sentinel comment (so evaluate() still skips them) that is stripped back out of the echoed source. So eval=2 echoes both lines verbatim and runs only the second, and the example above echoes mtcars as-is while showing only the paged_table() result.

Thirteen years for a two-character change in output. Sometimes the sitting is the hard part. With that out of my system, below is a list of other (more substantial) things done through the sprint.

New features, grouped by theme

Thirty-one new features is a lot to list flat, so I’ve written up the interesting ones in a handful of companion posts organized by topic:

Major bug fixes

current_input() returned the wrong filename under Quarto

Quarto hands knitr an intermediate .rmarkdown file, so current_input() was reporting that instead of your actual .qmd. Code that keyed off the input filename got confused:

```{r}
knitr::current_input()  # "report.rmarkdown" under Quarto, not "report.qmd"
```

It now returns the original .qmd (#2384).

purl() choked on chunk options that referenced params

If your R Markdown document defined params in the YAML header and a chunk used them in its options, tangling the document with purl() threw an error like object 'params' not found and left the chunk out of the script, because params wasn’t available when the option was evaluated during tangling:

---
params:
  eval: true
---

```{r, eval=params$eval}
print(params$eval)
```

Now the YAML params are parsed (statically—no code is run) and made available, so the option evaluates and the chunk is kept (#1938).

verbatim chunks obeyed a global echo = FALSE and showed nothing

The whole point of a verbatim (or embed) chunk is to display its content literally. But a document-wide echo = FALSE—common in slides, and the default in Quarto’s revealjs format—was hiding that content, which defeats the purpose:

```{r, include=FALSE}
knitr::opts_chunk$set(echo = FALSE)   # hide all R source globally
```

```{verbatim}
This block should still be shown---but used to vanish.
```

A verbatim chunk now ignores a global echo = FALSE and always shows its content; an echo = FALSE set on the chunk itself is still honored, so you can hide a specific one when you want to. This came out of #2239, a running wishlist of Quarto-support improvements that Christophe Dervieux has been keeping—several items from it have now shipped.

The ffmpeg animation hook swallowed failures

If ffmpeg exited with an error, knitr happily carried on and produced a document pointing at a video file that was never created—and with caching on, it could even cache the broken result:

```{r, fig.show='animate', animation.hook='ffmpeg'}
for (i in 1:10) plot(runif(20))   # produced a dead video link if ffmpeg failed
```

Now it errors out loudly, as it should have all along (#2130).

read_chunk() could crash purl() on innocent-looking code

Detection of read_chunk() calls used to be a crude string match, so a closing parenthesis inside a string argument could break tangling with a parse error:

```{r}
read_chunk(lines = ")")   # the ")" in a string used to crash purl()
```

It now parses the code and inspects the syntax tree, which also means functions whose names merely end in read_chunk are no longer mistaken for it, and namespaced calls (knitr::read_chunk()) are finally recognized (#1753).

Multi-device chunks ignored your device settings

When you asked for more than one device, the plot was actually recorded on the default pdf device with default arguments, so measurement-sensitive settings like pointsize were quietly lost:

```{r, dev=c('cairo_pdf', 'svg'), dev.args=list(pointsize=8)}
plot(cars)   # pointsize=8 was ignored during recording
```

The first device (and its dev.args) is now used for recording (#1323).

Centered subfigures across multiple rows were left-aligned in LaTeX

When you laid out subfigures over multiple rows inside a centered figure, the rows were separated by \newline, which ignores the alignment and pins the subfigures to the left:

```{r, fig.subcap=c('a','b','c'), fig.ncol=1, fig.align='center', out.width='30%'}
plot(1:10)
plot(cars)
boxplot(count ~ spray, InsectSprays)
```

The rows are now separated by \\, which centers them correctly. This one had been open since 2020 (#1907).

include_graphics(dpi = ...) did nothing outside HTML and LaTeX

For Word and other Pandoc formats, the dpi argument was ignored, so images came out at whatever size Pandoc guessed:

```{r}
knitr::include_graphics("diagram.png", dpi = 150)  # dpi ignored for Word/ODT
```

The physical size is now computed from the pixel dimensions and emitted (e.g., {width=2in}), which Pandoc understands (#2385).

Chunk references via <<>> didn’t work everywhere

knitr lets you reuse a chunk’s code by reference: write <<label>> on a line and the body of the chunk labelled label is spliced in. Handy for showing the same code in two places, or building up an example from named pieces. But there was a catch that tripped people up for years: the reference had to occupy a whole line by itself. The moment you tried to combine it with other code on the same line—most naturally, a pipe—it silently failed to expand:

```{r, eval=FALSE}
mtcars %>%
  <<filter-cyl>> %>%
  <<filter-gear>>
```

The last reference (on its own line) would expand, but the one followed by %>% would not, leaving a stray <<filter-cyl>> in your rendered output. Since piping named steps together is exactly the sort of thing you’d want chunk reuse for, this was a real papercut.

Now a reference can sit inline with other code, and a referenced chunk that spans multiple lines is spliced in with its continuation lines indented to match, so the pipeline above expands the way you’d expect (#2034).

Everything else

A grab bag of smaller additions that didn’t warrant their own post but might be exactly what you need:

Thanks

Big thanks to everyone who filed an issue, left a reproducible example, or sent a pull request during this round: @ABSOD, Antonio Canepa (@ajcanepa), Antoine Languillaume (@ALanguillaume), @alchemit, alex hayes (@alexpghayes), Amar Al-Zubaidi (@amarz45), Amelia McNamara (@AmeliaMN), @AndhikaWB, Alison Presmanes Hill (@apreshill), Aren Cambre (@arencambre), @aryoda, Philipp Aschersleben (@aschersleben), Aymeric Stamm (@astamm), @atusy, Anne-Wil (@AWKruijt), Angelo D’Ambrosio (@bakaburg1), Sebastian Meyer (@bastistician), Benjamin Smith (@benyamindsmith), @bergsmat, @bersbersbers, Bill Denney (@billdenney), Brian Diggs (@BrianDiggs), Alex Reinhart (@capnrefsmmat), Christophe Dervieux (@cderv), Claudiu Papasteri (@ClaudiuPapasteri), Colin J. Brislawn (@colinbrislawn), Connor P. Jackson (@connorp), Carson Sievert (@cpsievert), Michael Cysouw (@cysouw), @DaniMori, David W. Body (@davidbody), David C. Norris (@dcnorris), Peter DeWitt (@dewittpe), David McArthur (@dmca-glasgow), @dmurdoch, Daniel Possenriede (@dpprdan), Dr Andi Lowe (@DrAndiLowe), Elika Bergelson (@ebergelson), @edalfon, Edwin de Jonge (@edwindj), Elio Campitelli (@eliocamp), Iñaki Ucar (@Enchufa2), 饺子w (Yumechi) (@eternal-flame-AD), Florian Detsch (@fdetsch), Tamás Ferenci (@ferenci-tamas), Floris Vanderhaeghe (@florisvdh), Francesco Tabaro (@ftabaro), @gavril0, @GitHunter0, Gregor Sturm (@grst), Scott Rohde (@gsrohde), Hadley Wickham (@hadley), Doug Hemken (@Hemken), Henrik Bengtsson (@HenrikBengtsson), Hongyuan Jia (@hongyuanjia), David Hugh-Jones (@hughjonesd), Huanyuan Zhang-Zheng (@Hzhang-ouce), @iagogv3, @ilia-kats, @iMarcello, @JackCaster, Jakson Alves de Aquino (@jalvesaq), Jeroen Ooms (@jeroen), Jordan Mark Barbone (@jmbarbone), Joseph Guillaume (@josephguillaume), Jake Roden-Foreman (@jrf1111), @justanothergithubber, Brock (@katrinabrock), Kenneth C. Arnold (@kcarnold), Kene David Nwosu (@kendavidn), Kevin Ushey (@kevinushey), Pavel N. Krivitsky (@krivit), Kirill Müller (@krlmlr), Kyle F Butts (@kylebutts), Leonardo Gama (@leogama), Lionel Henry (@lionel-), Louisa Smith (@louisahsmith), Maëlle Salmon (@maelle), Dr. Ryan McShane (@math-mcshane), Max Held (@maxheld83), Michał Bojanowski (@mbojan), Matthew Hall (@mdhall272), @mdingemanse, @mgacc0, Michael Chirico (@MichaelChirico), Mine Cetinkaya-Rundel (@mine-cetinkaya-rundel), Mark (@mrdowdeswell), Marcel Schilling (@mschilli87), Martin Morgan (@mtmorgan), Marc Wouts (@mwouts), nassuphis (@nassuphis), Nicholas Tierney (@njtierney), @notfound4, Olivier Meslin (@oliviermeslin), @olivroy, Owen Jones (@owenjonesuob), PKleka (@pa0), Christopher Paciorek (@paciorek), Martin Nyolt (@PapaNappa), Paul Rougieux (@paulrougieux), Pawel Rucki (@pawelru), Eric Potash (@potash), Ramnath Vaidyanathan (@ramnathv), Eric Stemmler (@rcst), @rfenouil, Ross Gayler (@rgayler), Robert Hijmans (@rhijmans), Richard Mills (@Rich-F-G-Mills), Richard Iannone (@rich-iannone), Rebecca Butler (@rkb965), Robert (@rnorberg), Ruben C. Arslan (@rubenarslan), @RussellSteele, Sven (@sboge), David Selby (@Selbosh), Simone Giannerini (@sgiannerini), Shaun Nielsen (@Shaunson26), Taha Ahmed (@solarchemist), Sören Nikolaus (@srnnkls), @StephenGerry, Steve Condylios (@stevecondylios), @stla, Tim Florian Jaeger (@tfjaeger), Tim Mastny (@tmastny), Tom 2 Philippi (@tphilippi), Felix Turbanisch (@turbanisch), Robin Shannon (@vorpalvorpal), wikithink (@wikithink), Will Landau (@wlandau), @wuffi, @wzel, @youdie006, Zach Deane-Mayer (@zachmayer), Achim Zeileis (@zeileis), and @znmeb.

The tracker is a lot cleaner now, but I guess new issues/PRs will slowly come again. If you run into something, please file an issue, and I’ll see you after the next decade! Just kidding—I’ll process your issues when I have time.

A word on sponsorship

A little while ago I wrote about whether I should ask for sponsorship now that I’m no longer paid to maintain the R Markdown ecosystem. I went back and forth on it, and in the end listed a few ways to support the work with, as I said then, zero expectation.

Zero expectation turned out to be the right frame. Since that post I’ve received a total of 35 US dollars, plus 100 Chinese yuan (roughly 15 dollars), and a new GitHub sponsor with $5/month. These are not numbers that change anything financially—but because I truly expected nothing, it is infinitely better than my expectation. I sincerely thank the four kind sponsors.

I’m not surprised by how hard the sponsorship model is for individuals. Even with whatever visibility I have after years in this community, the numbers are what they are. And I’m one of the lucky ones: there are countless open-source contributors with far less visibility than me, quietly maintaining things the rest of us depend on, for whom getting sponsored at all would be dramatically harder. If you have the means and the inclination to support open source, please don’t stop at the maintainers you’ve already heard of—the ones you haven’t heard of may need it more.