8  The Hook Contract

Chapter 6 built a placeholder from the three required pieces; this chapter is the reference for everything the constructors accept. All three constructors are thin wrappers over a shared core, so they take an overlapping set of arguments; the running example exercises the value constructor in full, and the consumer/source differences are in Chapter 7.

8.1 Every argument at once

A custom ppPercent: a 0–100 slider whose value is divided by 100 before it reaches the plot. It exercises every argument of ptr_define_placeholder_value(); the positional 40 in the app seeds the slider, the named step = 5 is forwarded to build_ui:

ppPercent <- ptr_define_placeholder_value(
  keyword = "ppPercent",

  build_ui = function(node, label = NULL, selected = NULL,
                      named_args = list(), ...) {
    step <- named_args$step %||% 1
    shiny::sliderInput(
      node$id, label = label %||% "Percent",
      min = 0, max = 100,
      value = selected,   # the framework routes ppPercent(40) in here
      step = step
    )
  },

  resolve_expr = function(value, node, ...) {
    if (is.null(value)) return(NULL)
    as.numeric(value) / 100
  },

  validate_session_input = function(value, ctx) {
    v <- suppressWarnings(as.numeric(value))
    if (length(v) != 1L || is.na(v) || v < 0 || v > 100) {
      return("Percent must be a single number between 0 and 100.")
    }
    TRUE
  },

  parse_positional_arg = ptr_arg_numeric(),
  parse_named_args     = list(step = ptr_arg_numeric()),
  embellish_eval       = function(x, ...) as.numeric(x) / 100,
  ui_text_defaults = list(label = "Percent for {param}")
)

ptr_app(
  ggplot(mtcars, aes(x = ppVar("wt"), y = ppVar("mpg"))) +
    geom_point(alpha = ppPercent(40, step = 5))
)

The ppPercent app at boot, showing the ggplot layer’s column pickers; the registered slider lives under the geom_point entry of the Layer dropdown, and after an update the generated code reads alpha = 0.4.

8.2 keyword

The name to register and to write in formulas. Required.

8.3 build_ui — and why its signature looks the way it does

build_ui returns the Shiny control. Its first argument is node (required) — carrying, among other things, node$id (the input id you must give your widget) and node$default (the literal positional default from the formula, or NULL — exposed for the framework’s own bookkeeping; never read it inside the hook, as explained next).

The framework does not call build_ui(node) bare. It calls it through an injector that also passes label (the resolved copy from ui_text_defaults, possibly overridden by the app’s ui_text), selected (the persisted value, injected only if your function can receive it), and named_args (the validated named arguments from the formula call). Hence the canonical signature function(node, label = NULL, selected = NULL, named_args = list(), ...):

  • selected and named_args are opt-in: each is injected only if you name it — or have .... If you neither name selected nor accept ..., it is never injected. label, by contrast, is always passed, so your signature must be able to receive it (name it, or keep ...). Naming the ones you use and keeping ... to swallow the rest is the safe, forward-compatible shape.
  • Seed the widget from selected — and only selected. The framework owns the precedence: on the first render it routes the formula’s positional default (ppPercent(40)) into selected; on every later render selected is the live input, verbatim. Never read node$default yourself and never hardcode a fallback for the empty case — both bypass the seeding layer and snap a user-cleared widget back to the default on the next re-render. When selected is empty (NULL, character(0), "", NA), render the widget empty; boot defaults belong in the formula.
  • Use node$id — and only node$id — as the widget id. Do not namespace it yourself; the framework already did.

8.4 resolve_expr

function(value, node, ...) → the value (or expression) spliced back into the formula. Return NULL to contribute nothing (an empty field), which drops the argument cleanly (Chapter 5). Here we divide by 100 so the plot sees a 0–1 alpha.

8.5 validate_session_input

Optional. function(value, ctx), run before resolve_expr. Return TRUE or NULL to accept; return a single character string to reject — it surfaces as the inline error message (rlang::abort() also rejects). Any other return value fails closed with a generic “validation failed” — so returning the value itself, a natural-looking mistake, rejects every draw. For mid-typing artifacts that should not flash an error, signal ptr_signal_partial() instead of aborting — it is caught on the live keystroke path but not on the draw path. (ctx earns its keep for consumers: Chapter 7.)

8.6 parse_positional_arg

Declares whether the placeholder accepts a positional default in the formula, and validates it. Pass one of the argument validators — ptr_arg_string(), ptr_arg_numeric(), ptr_arg_symbol(), ptr_arg_symbol_or_string(), ptr_arg_expression(). Each inspects the default as unevaluated code (the symbol, string, and expression validators never eval(); ptr_arg_numeric() first walks the AST against an allowlist, then constant-folds it in a sealed environment), so ppPercent(40) is validated to be a numeric literal at translate time. The element factories take vector = TRUE to accept a c(...) of elements — ptr_arg_numeric(vector = TRUE, length = 2), ptr_arg_symbol(vector = TRUE) for a multi-column default like c(mpg, hp). Leaving it NULL (the default) rejects any positional argument.

8.7 parse_named_args

A fully-named list mapping extra named-argument names to validators, same family as above. It lets the formula write ppPercent(40, step = 5); validated values arrive in build_ui as named_args. The name shared is reserved (it is the cross-widget binding key, Chapter 11) and may not appear here.

8.8 embellish_eval

The plain-R meaning of the keyword outside ptr_app(). A placeholder-embellished formula must stay valid plain R that still renders the original plot with no app running; embellish_eval supplies that meaning. Each constructor returns this function, so bind it under the keyword name — ppPercent <- ptr_define_placeholder_value("ppPercent", ...), after which ppPercent(40) is 0.4 as ordinary R. The meaning is author-controlled, never derived. If omitted, value and consumer keywords default to embellish_identity() (a transparent no-op wrapper); embellish_symbol_to_string() covers the column-selecting consumer case (it returns the referenced column names as strings, so a naked tidyselect formula still works).

8.9 ui_text_defaults

A named list of copy defaults over label, help, placeholder, and empty_text, with {param} interpolated to the argument name. These are the defaults; an app overrides them through ui_text = (Chapter 17).

8.10 Common pitfalls

  • Forgetting inputId = node$id. The widget renders, the user types, nothing happens — the value never reaches resolve_expr.
  • Returning a string when you wanted a symbol. resolve_expr = function(value, ...) value for a column picker splices "cyl" (a string literal), so geom_point(color = "cyl") paints a constant color. Wrap with rlang::sym(value).
  • Forgetting empty input. Shiny calls your hooks before the user touches anything. Treat NULL, character(0), NA, and empty strings as “no value yet” and return(NULL) — the framework prunes the argument.
  • Confusing resolve_expr with resolve_data. For sources: resolve_data produces the actual frame; resolve_expr produces the text in the rendered call (Chapter 7).
  • Dropping ... from a hook signature. Future framework versions may inject new named arguments; without ... the call fails.
  • Re-registering inside a reactive. The registry is process-global; register once at script top level, before ptr_app().

8.11 Unregistering

ptr_clear_placeholder("ppPercent") removes one user-registered keyword; ptr_clear_placeholder() removes them all. Seven keywords are protected (clearing one errors): the five built-in placeholders plus the structural ppLayerOff / ppVerbSwitch. Re-registering an existing keyword overwrites the previous entry with a warning — no need to clear first.