A block's UI function is a pure function of its namespace id — validate_block_ui() rejects any other signature. That is a false model. The UI is really a function of the block's state, which sneaks in through the closure over the constructor arguments, and of its input data, which is unreachable. So a block whose controls depend on data has no legal way to express them in ui(), and the only channel left is a server-side push at module start, which is the one that gets dropped on a panel that does not exist yet (see #317).
Authors have already worked this out the hard way. Across blockr.cdexdev's 91 blocks there are 56 static input widgets and 150 uiOutput placeholders, so controls are already predominantly rendered through outputs, roughly 3:1 — discovered by trial and error, with nothing in the API to point at it.
Proposal
Passing ui = NULL to new_block() means the block supplies its UI from the server. The default UI then becomes a placeholder, and core renders into it through an output — which shiny suspends while its element is absent and runs when it binds, so it is lazy and correct by construction, with no readiness signal and no replay.
The server returns a ui component alongside expr and state, as a named list of functions, one per control, so a re-render tears down one control rather than all of them:
list(
expr = reactive(...),
state = list(by = sels, all_x = allx, all_y = ally),
ui = list(
by = function(by) {
selectInput(session$ns("by"), "By columns",
choices = cols(), selected = by, multiple = TRUE)
},
type = function(all_x, all_y) {
checkboxGroupInput(session$ns("type"), "Join type",
choices = c("all.x", "all.y"),
selected = c("all.x", "all.y")[c(all_x, all_y)])
}
)
)
Each slot's formals name the state it needs, and core supplies those as plain values read under isolate(). Anything the slot reads from its own closure — cols() here — stays a live read inside the render, so it drives the re-render. One sentence covers it: core isolates what you name, and anything you fetch yourself stays live.
That division is what makes the design safe rather than conventional. A slot that depends reactively on the state its own control writes closes a state → render → DOM → input → state loop. It converges, because reactiveVal deduplicates identical writes, but every lap re-mounts the control — focus lost, open dropdown closed, typed text discarded — and the trigger is the user's own interaction with that very control. Measured on a deferred panel, the naive spelling renders three times before it settles and once more per user click, where the isolated form renders exactly once and never again. Handing state in as values makes the cycle unrepresentable instead of merely discouraged.
The state list is the correct isolation set because it is by definition the values the block's controls write back, which is also what makes it the thing core serializes. Core does not have to guess which reads are dangerous; it already holds the list.
Core changes
The new_block() gains ui = NULL as its default, meaning server-supplied. Nothing relies on the current omitted-ui default today: all 56 registered blocks across core, dm, dplyr, io, viz and stats pass an explicit ui.
Validation in check_expr_val() requires ui when the block opted in, accepts NULL for a block with no controls, and checks that each slot's formals are a subset of the state names, so a typo fails loudly at construction instead of rendering a control with a NULL value.
Two related fixes belong here because the design depends on them.
State may already exceed the constructor arguments — check_expr_val() only errors when state is missing a ctor input — which is what lets a block declare a control-written value that is not a constructor argument and still have it isolated. But blockr_ser.block() sends the whole state as the payload and restore does do.call(ctor_fun(ctor), payload), so an extra entry reaches the constructor, lands in ..., becomes a block attribute and is re-emitted next save. Filtering the payload to block_ctor_inputs(x) gives state two clean roles: the isolation vocabulary, which may exceed the constructor arguments, and the serialized payload, which is exactly the constructor arguments. The no-state path already produces exactly that set via initial_block_state(), so both paths converge on one rule. Do not mirror the filter on deserialize, where existing saved boards legitimately carry attribute entries.
A state name colliding with a data-input name should be rejected outright, since a slot formal that could mean either an isolated value or a live reactive is the confusion this design exists to remove. No registered block collides today.
Open
What a block paints while it is dormant. The data reactives req() out when a block is not needed, so a slot reading cols() would abort and paint no control at all, which is worse than today. The paint needs to degrade to state-only rather than abort, and fill in data-derived content once the block evaluates.
Also for blockr.dock: has_expr_ui() answers "does this block have controls" statically, by rendering expr_ui("block", x) and testing for markup, including for blocks with no running server. Under server-supplied UI that question has no static answer, so core should expose the predicate instead of front-ends probing by rendering.
A block's UI function is a pure function of its namespace id —
validate_block_ui()rejects any other signature. That is a false model. The UI is really a function of the block's state, which sneaks in through the closure over the constructor arguments, and of its input data, which is unreachable. So a block whose controls depend on data has no legal way to express them inui(), and the only channel left is a server-side push at module start, which is the one that gets dropped on a panel that does not exist yet (see #317).Authors have already worked this out the hard way. Across blockr.cdexdev's 91 blocks there are 56 static input widgets and 150
uiOutputplaceholders, so controls are already predominantly rendered through outputs, roughly 3:1 — discovered by trial and error, with nothing in the API to point at it.Proposal
Passing
ui = NULLtonew_block()means the block supplies its UI from the server. The default UI then becomes a placeholder, and core renders into it through an output — which shiny suspends while its element is absent and runs when it binds, so it is lazy and correct by construction, with no readiness signal and no replay.The server returns a
uicomponent alongsideexprandstate, as a named list of functions, one per control, so a re-render tears down one control rather than all of them:Each slot's formals name the state it needs, and core supplies those as plain values read under
isolate(). Anything the slot reads from its own closure —cols()here — stays a live read inside the render, so it drives the re-render. One sentence covers it: core isolates what you name, and anything you fetch yourself stays live.That division is what makes the design safe rather than conventional. A slot that depends reactively on the state its own control writes closes a
state → render → DOM → input → stateloop. It converges, becausereactiveValdeduplicates identical writes, but every lap re-mounts the control — focus lost, open dropdown closed, typed text discarded — and the trigger is the user's own interaction with that very control. Measured on a deferred panel, the naive spelling renders three times before it settles and once more per user click, where the isolated form renders exactly once and never again. Handing state in as values makes the cycle unrepresentable instead of merely discouraged.The
statelist is the correct isolation set because it is by definition the values the block's controls write back, which is also what makes it the thing core serializes. Core does not have to guess which reads are dangerous; it already holds the list.Core changes
The
new_block()gainsui = NULLas its default, meaning server-supplied. Nothing relies on the current omitted-uidefault today: all 56 registered blocks across core, dm, dplyr, io, viz and stats pass an explicitui.Validation in
check_expr_val()requiresuiwhen the block opted in, acceptsNULLfor a block with no controls, and checks that each slot's formals are a subset of the state names, so a typo fails loudly at construction instead of rendering a control with aNULLvalue.Two related fixes belong here because the design depends on them.
State may already exceed the constructor arguments —
check_expr_val()only errors when state is missing a ctor input — which is what lets a block declare a control-written value that is not a constructor argument and still have it isolated. Butblockr_ser.block()sends the whole state as the payload and restore doesdo.call(ctor_fun(ctor), payload), so an extra entry reaches the constructor, lands in..., becomes a block attribute and is re-emitted next save. Filtering the payload toblock_ctor_inputs(x)gives state two clean roles: the isolation vocabulary, which may exceed the constructor arguments, and the serialized payload, which is exactly the constructor arguments. The no-state path already produces exactly that set viainitial_block_state(), so both paths converge on one rule. Do not mirror the filter on deserialize, where existing saved boards legitimately carry attribute entries.A state name colliding with a data-input name should be rejected outright, since a slot formal that could mean either an isolated value or a live reactive is the confusion this design exists to remove. No registered block collides today.
Open
What a block paints while it is dormant. The data reactives
req()out when a block is not needed, so a slot readingcols()would abort and paint no control at all, which is worse than today. The paint needs to degrade to state-only rather than abort, and fill in data-derived content once the block evaluates.Also for blockr.dock:
has_expr_ui()answers "does this block have controls" statically, by renderingexpr_ui("block", x)and testing for markup, including for blocks with no running server. Under server-supplied UI that question has no static answer, so core should expose the predicate instead of front-ends probing by rendering.