Skip to content

Add support for Shiny app auto-loading - #1388

Open
lionel- wants to merge 15 commits into
oak-source/3-source-dirfrom
oak-source/4-shiny
Open

Add support for Shiny app auto-loading#1388
lionel- wants to merge 15 commits into
oak-source/3-source-dirfrom
oak-source/4-shiny

Conversation

@lionel-

@lionel- lionel- commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Addresses posit-dev/positron#14790
Progress towards #1338

Shiny apps are now detected to inject a file loader in the workspace that implements Shiny auto-loading (https://shiny.posit.co/r/reference/shiny/1.7.5/loadsupport.html).

  • The detection follows RStudio: an app.R file containing shinyApp, a ui.R file containing shinyUI, or a server.R file containing shinyServer.

  • The entry point sees global.R and the whole R/ directory, plus an implicit shiny attach. global.R loads first and doesn't see R/. The autoload isn't recursive, unlike tar_source(). _disable_autoload.R drops the directory but keeps global.R.

  • Shiny apps under inst/app/ are detected too, since package loading doesn't claim inst/ files in the first place.

  • An explicit source() now replaces the R/ auto-collation instead of adding to it: it contradicts the layout guess rather than describing a second execution. A detected loader like Shiny does, so it stays additive.

  • Loader detection moves into a new load_context module, with per-package conventions under load_context/contrib/. Currently has testthat and shiny contributions.

Positron Release Notes

New Features

Bug Fixes

  • N/A

@lionel-

lionel- commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

AI generated review guide:


Covers:

  • oak-source/1-r-collation
  • oak-source/2-source-inheritance
  • oak-source/3-source-dir
  • oak-source/4-shiny

Related issues: posit-dev/positron#15144 and posit-dev/positron#14790.

Setup

Use a plain folder with no DESCRIPTION file. Package projects use package-specific resolution instead.

Section 6 tests experimental diagnostics. Enable them in settings.json:

{
  "oak.diagnostics.experimental.enabled": true
}

For each case, check resolution, goto-definition, find-references, and live updates after editing where applicable.

1. Non-package R/ collation

Files directly inside a non-package R/ directory load in case-insensitive basename order. At the top level, each file sees only its predecessors. Function bodies see the full directory because they run after loading finishes.

Basic resolution

ws/
  R/
    a.R      # a_val <- 1
    b.R      # b_val <- 2

In b.R:

  • a_val resolves.
  • Goto-definition lands in a.R.
  • Find-references from either file finds the definition and use.

Top-level and function-body ordering

# R/a.R
b_val                 # unresolved
f <- function() b_val # resolves to R/b.R

A file-scope local({ ... }) block is eager like the top level, so it also sees only predecessors.

Case-insensitive ordering

R/a.R      # a_val <- 1
R/Z.R      # a_val should resolve here

a.R must load before Z.R. Byte ordering would produce the wrong result.

Boundaries

  • Separate R/ directories do not share a collation.
  • Collation is not recursive. R/models/fit.R is not included.
  • A package R/ file omitted from Collate: behaves as a standalone script.

Default search path

# R/a.R
median(1:10)

median must resolve through the default search path. Non-package collation must not impose package namespace behavior.

2. Source inheritance

A file loaded by source() sees the sourcing file's state at the call site.

# main.R
library(dplyr)
config <- 1
source("helpers.R")
result <- 2
# helpers.R
config                    # resolves to main.R
filter                    # resolves through dplyr
f <- function() result    # resolves to main.R
result                    # unresolved at the top level

The top-level result is unresolved because main.R has not executed that assignment when it sources helpers.R. The use inside f() resolves because the function can run after main.R finishes.

Transitive inheritance

For main.R -> setup.R -> helpers.R, names and attaches inherited from main.R should reach helpers.R.

Explicit sourcing replaces fallback collation

Non-package R/ collation is a fallback. If main.R sources R/a.R but not R/b.R, a.R must not resolve a name defined only in b.R.

A detected loader remains an additional context. In a Shiny app, sourcing R/a.R from main.R adds main.R's bindings without hiding the support context shared by R/a.R and R/b.R.

Multiple sourcing files

Each sourcing file is an alternative runtime context. Results are unioned rather than resolved by file priority.

# file1.R          # file2.R          # foo.R
x <- 1             x <- 1             list(x, y)
source("foo.R")    y <- 2
                   source("foo.R")

Expected goto-definition results in foo.R:

  • x has targets in both file1.R and file2.R.
  • y has one target in file2.R.

The analysis does not yet distinguish "bound on every path" from "bound on at least one path".

Package and testthat files

Package R/ files and testthat files keep their loader context instead of inheriting source sites.

For example, if data-raw/build.R sources R/foo.R, foo.R must retain its package collation, NAMESPACE imports, and base resolution.

Editing and cache stability

Edit main.R without changing the source target. Try inserting a line above the call and editing an unrelated function body. Resolution in helpers.R should not flicker or disappear.

3. Directory sourcing

3.1 sourceDir()

The copied helper from ?source is recognized by the name sourceDir:

# main.R
sourceDir <- function(path, trace = TRUE, ...) {
  for (nm in list.files(path, pattern = "[.][RrSsQq]$")) {
    source(file.path(path, nm), ...)
  }
}
sourceDir("code")

helper_from_code_dir()

Expected:

  • helper_from_code_dir() resolves into code/*.R.
  • The local sourceDir definition does not suppress the source effect.

This is deliberately name-based. An unrelated function named sourceDir will also be interpreted as directory sourcing.

3.2 targets::tar_source()

# _targets.R
library(targets)
tar_source()

tar_target(x, my_helper())

Expected:

  • Bare tar_source() uses the default files = "R".
  • targets::tar_source() also works.
  • tar_source("code") loads a positional directory.
  • tar_source("R/utils.R") loads a positional script.

Known limitations:

  • tar_source(files = "code") is not recognized because path matching is positional.
  • tar_source(c("R", "utils")) is not recognized because only one static string is supported.

The named-argument case must be a miss. It must not silently fall back to R.

3.3 Ordering within a sourced directory

The current implementation recursively includes subdirectories for both tar_source() and sourceDir(). Recursion matches tar_source(), but over-approximates the usual non-recursive sourceDir() implementation.

Files in the sourced directory see one another even when the directory is not named R.

Given sourceDir("code") loading a.R before b.R:

# code/a.R
b_val                 # unresolved
f <- function() b_val # resolves to code/b.R

# code/b.R
a_val                 # resolves to code/a.R

The source call snapshots the sourcing file once. Ordering between a.R and b.R comes from the target list.

4. Shiny applications

runApp() loads global.R, then direct children of R/, then the app entry point. Since these calls are implicit, the app is detected from its layout.

An app directory contains one of these entry points and markers:

File Required text
app.R shinyApp
ui.R shinyUI
server.R shinyServer

Filenames are matched case-insensitively. Marker detection is a plain text search, so comments and strings also count.

Basic support loading

ws/
  app.R      # shinyApp(ui, server)
  global.R   # cfg <- 1
  R/
    a.R      # a_val <- 1
    b.R      # b_val <- 2

In app.R:

  • cfg, a_val, and b_val resolve.
  • Shiny exports such as reactive and fluidPage resolve without library(shiny).

Entry-point top level sees all support files

# app.R
mod_ui()              # resolves
shinyApp(ui, server)

loadSupport() finishes before app.R starts, so top-level uses in the entry point see the full support set.

global.R runs first

# global.R
reactive(1) # resolves through the implicit shiny attach
a_val       # unresolved because R/a.R has not run

An R/ support file can see bindings from global.R.

Non-recursive support directory

loadSupport() does not recurse. R/models/fit.R is not part of the app support set.

_disable_autoload.R

Adding R/_disable_autoload.R disables loading of the app's R/ directory but does not disable global.R.

ws/app.R                  # shinyApp(ui, server)
ws/global.R               # cfg <- 1
ws/R/a.R                  # cfg and reactive should not resolve here
ws/R/_disable_autoload.R

The R/ files fall back to ordinary non-package collation and lose the implicit Shiny attach.

Split ui.R and server.R

Both entry points see the support set. They run in sibling environments, so they do not see bindings from each other.

Nested and packaged apps

  • R/app.R inside an app remains a support file. It is not treated as another app root.
  • A package app under inst/app/ receives Shiny loading behavior.
  • A package's own R/ directory is never treated as Shiny support, even if the package root contains app.R.

Live updates and cache stability

  1. Remove the shinyApp marker from app.R.
  2. Confirm that global.R, support-file visibility, and the implicit Shiny attach disappear.
  3. Restore the marker and confirm that they return.
  4. Edit unrelated text in app.R. Support files should not flicker or lose resolution.

5. Issue checks

posit-dev/positron#15144

Clone https://github.com/benzipperer/ctrl_click and check the three targets in main.R.

  • greet_local already worked.
  • greet_static_helper already worked.
  • greet_dynamic_helper still does not resolve because lapply(list.files("R"), source) requires dataflow analysis.

Replacing the dynamic loader with sourceDir("R") or tar_source() should make it resolve. Independently, files inside R/ should now resolve one another.

posit-dev/positron#14790

Functions spread across the workspace root, with no R/ directory and no source calls, are still unsupported. Confirm that goto-definition still reports no definition found.

6. Experimental diagnostics

These require oak.diagnostics.experimental.enabled.

6.1 source-cycle warning

# a.R
source("b.R")

# b.R
source("a.R")

Expected:

  • Both files report the cycle.
  • Each diagnostic is anchored at the start of the file.
  • The message says analysis is incomplete until the cycle is removed.

6.2 inherited-shadow information

This diagnostic reports a bare call whose NSE effect changes under inherited source context.

# a.R
source <- identity
base::source("b.R")

# b.R
source("c.R")

# c.R
foo <- 1

Expected:

  • The diagnostic appears in b.R on source("c.R").
  • It explains that standalone and inherited contexts resolve the call differently.

base::source("b.R") is required. A bare call would resolve to the local identity, removing both the source edge and the diagnostic.

The diagnostic should not appear when:

  • The sourced file's own attach ordering explains the shadow.
  • The sourcing file binds the name after the source call.
  • Both winning definitions are plain functions with no NSE effect.

Only library, require, source, and tar_source callees are considered. NSE-only functions such as withr::defer() do not trigger this diagnostic.

7. Known gaps

  • lapply(list.files("R"), source) is not recognized.

@lionel-
lionel- requested a review from thomasp85 August 3, 2026 18:46
@lionel-
lionel- force-pushed the oak-source/4-shiny branch 2 times, most recently from c784e05 to 4479950 Compare August 4, 2026 08:29
@lionel-
lionel- force-pushed the oak-source/4-shiny branch 2 times, most recently from 0003e80 to 0d7a8e0 Compare August 4, 2026 11:45
lionel- added a commit to posit-dev/positron that referenced this pull request Aug 4, 2026
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