feat: shinyreact_js= switch for npm-tier pages; convert examples/09-hmr (#217) - #264
Merged
Merged
Conversation
Every page entry point unconditionally injected the IIFE bundle, so an app bundling `@posit/shinyreact` got two runtimes on the page. Add `shinyreact_js = "server" | "client"` (default `"server"`) to `page_react()`, `page_react_html()`, `set_react_page()`, and `ReactApp()`, mirrored in R. `"client"` omits shinyreact.js/.css only; the `#shinyreact-config` tag is always emitted, since the npm client hard-errors without it. Validation lives in one place per language, and the Express/App entry points check eagerly so a typo fails at startup. Convert `examples/09-hmr` to import `@posit/shinyreact` (`file:../../pkg-js` until the first publish): the dev/prod `shiny-bridge` alias and the React externalization are gone, and React is bundled in both modes, which is what Fast Refresh needs. Also warn from the npm entry when `window.shinyreact` is already present. The double load is harmless — the registries are page-scoped — but silent, and only the npm build can observe it, since script order guarantees the IIFE ran first.
R's counterpart of Python's keyword-only `*`, so the same call reads the same in both languages. Anything reaching the dots is a positional argument the caller meant to name or a misspelled name; both now error instead of being silently dropped. Named arguments are reported by name (a misspelled `extra_dep=` names itself), unnamed ones by position. The dots are never evaluated, so a rejected argument cannot run its own expression on the way to being refused.
Replaces the hand-rolled check from the previous commit. rlang's message is better -- it echoes each offending argument as `name = expr`, so a misspelled `extra_dep = list()` names itself -- and it carries the `rlib_error_dots_nonempty` class, which the test now asserts instead of matching wording that belongs to rlang. Adds rlang to Imports.
`getShiny()` read a bare `window`, but it is reached from debounce timers and event callbacks that can outlive the document. In CI the 01-hello example UI test finished before a 100 ms input debounce fired, so the timer ran after jsdom tore down and the bare identifier was a ReferenceError -- an unhandled error that failed the vitest run with all 277 tests passing. Guard with `typeof window`. Every caller already handles a missing Shiny with `?.` or an `if`, so returning undefined degrades the way they expect. Also covers a real browser case: a page or iframe unloading with a debounce pending.
The only conflicts were the three generated bundles (pkg-js/dist, pkg-py/src/shinyreact/www, pkg-r/inst/lib/shiny). Resolved by rebuilding from the merged source with `make update-dist` rather than hand-merging minified JS; all three are byte-identical afterwards.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #217.
Every page entry point injected the IIFE bundle unconditionally, so an app that
bundles
@posit/shinyreactgot two runtimes on the page.The switch
shinyreact_js = "server" | "client", default"server"— who suppliesshinyreact.js(andshinyreact.css) to the page."client"omits only those two files. The#shinyreact-configtag isemitted either way, because the npm-tier client hard-errors without it.
Validation lives in one place per language (
_serves_bundle()/serves_bundle()), so every entry point rejects a typo identically.set_react_page()andReactApp()validate eagerly at call time ratherthan at first page render, so a typo fails at app startup next to the line that
caused it:
Why an enum, and why this name
include_runtime=Falsewas the first shape. It lost on two counts: if thebundler tier becomes the norm, a boolean is a permanent double negative at the
call site, and "runtime" is already overloaded in this repo —
pkg-js/vite.config.npm.tsexternalizes
react/jsx-runtime. An enum lets the default flip later with norename and no call-site churn, and leaves room for the decision record's
Option D (server-served ESM).
shinyreact_jsnames the artifact, so it does not promise the narrower thingexamples/04-shadcn/vite.config.jsalready controls withrollupOptions.external(where React comes from). Names considered and dropped:
react_runtime(collides with React Server Components /
react-dom/server),runtime_source("runtime"),
shinyreact_source(sits next tosrc_dirin the same signature),shinyreact_dep/include_dep(a dep is a server-side htmltools object, so="client"is a category error — andpage_react()attaches four kinds ofdependency,
page_react_html()already hasextra_deps=, and R already has aninternal
shinyreact_dep()).What the server deliberately does not do
It never validates the choice, because it cannot: whether the client bundles a
copy is a property of the built
ui.js, known only once it executes — after thepage's script tags are committed. A
package.json-sniffing heuristic wasconsidered and rejected: it is wrong on 2 of the 3 examples that have one
(
03-columns-shadcnand04-shadcnhave a build step and use the global), andpackage.jsonis not part of a deployed app, so the same app would render oneway locally and another in production.
Double-load warning
Instead, the case that fails silently announces itself. From the npm entry:
console.warn, not a throw: nothing is broken (the registries are page-scoped,so both copies share one set of inputs, outputs, and handlers) and killing a
working app over wasted bytes is the wrong trade. It lives in the npm entry
because only that build can observe the collision — deferred classic and module
scripts execute in document order and the page emits the bundle dep first, so
installGlobal()runs first and sees nothing.The reverse mistake (
"client"with a client that bundles nothing) needs nohelp: it throws on the app's first hook call.
examples/09-hmr → the npm tier
@posit/shinyreact;src/shiny-bridge.{dev,prod}.ts, theresolve.aliasswitch, and the React-externalizingrollupOptionsare goneFast Refresh requires and what the production-only React inside the IIFE could
never provide
app.pyusesset_react_page(shinyreact_js="client")file:../../pkg-js— repo-relative, sothe lockfile stays portable (
"resolved": "../../pkg-js", "link": true); buildpkg-jsbefore installingAcceptance test from #217, on the real example page:
and the built
www/ui.jscontains nowindow.shinyreactreference.Verification
New tests: omit-the-bundle for
page_react,page_react_html, bothset_react_page()modes, and bothReactAppdiscovered modes; typo rejectionin both languages, cross-referenced by name; two
entry-parity.test.tscasesfor the warning. That file's
beforeEachalso now deleteswindow.shinyreact—installGlobal()was leaking it across tests, so what a test saw depended onwhat ran before it.
Docs:
FEATURES.md(sharedshinyreact_jsblock, four updated signatures, thethird deliberate tier difference), both
examples/09-hmrdocs,examples/README.md, the decision record, and the#217justification commentsin
message-registry.ts/react-registry.ts/ CLAUDE.md, which all cited theold flag when explaining why two copies can coexist.
pkg-py/tests/playwright/test_module_dependency.pyis an unrelatedruff formatfixup that
make py-formatproduced.#263 (
examples/11-npm-local, evidence for #261) touches the same files andconflicts semantically in
pkg-js/src/npm.ts: it removesrequireShinyReactConfigTag(), while this PR keeps it and inserts thedouble-load warning immediately above it. The two ideas are compatible — #263's
page_bare(page_react_dep(...))page needs no switch, and this PR is what makespage_react()/set_react_page()/ReactApp()/page_react_html()usable atthe npm tier — but they need sequencing, and whichever lands second will have to
resolve
npm.ts,entry-parity.test.ts,FEATURES.md,CLAUDE.md,decisions/2026-08-17-js-distribution.md, andexamples/README.md.