shinyreact 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ # Hosting a traditional Shiny renderer
2
+
3
+ Two components do this, and neither needs a `*Output()` placeholder on the
4
+ server — that is the whole point. Keep the render function exactly as you would
5
+ write it in a normal Shiny app; the client says where it goes.
6
+
7
+ **`ShinyOutput`** — for a widget that owns its own DOM (data frames, plotly,
8
+ DT, leaflet). You are rendering the element that widget's *binding* looks for,
9
+ so how you spell it is per-widget and not guessable — copy it from the widget's
10
+ own `*Output()` function:
11
+
12
+ ```jsx
13
+ // custom-element widgets: name the tag
14
+ <ShinyOutput id="my_table" tagName="shiny-data-frame" />
15
+
16
+ // classic bindings: a div carrying the binding's classes (tagName defaults to "div")
17
+ <ShinyOutput id="scatter" className="shiny-ipywidget-output" />
18
+ <ShinyOutput id="scatter" className="plotly html-widget html-widget-output" />
19
+ ```
20
+
21
+ It renders that element with the `ref` directly on it — **no wrapper div**, so
22
+ your flex/grid CSS behaves — and runs Shiny's `bindAll` / `unbindAll` around
23
+ it. Any other prop is forwarded to the element, and children act as fallback
24
+ content until Shiny renders into it. The widget's binding JS and CSS are
25
+ discovered from the render function and delivered for you.
26
+
27
+ **`ImageOutput`** — for a server-drawn image (`[py]` `@render.plot`,
28
+ `[r]` `renderPlot()`). It measures itself and reports the size to the server, so
29
+ the plot is drawn at the element's dimensions rather than scaled after the fact,
30
+ and it shows a spinner placeholder before the first image arrives:
31
+
32
+ ```jsx
33
+ <ImageOutput id="my_plot" className="h-80 w-full" />
34
+ ```
35
+
36
+ **It must be given a size**, by `className`, by `width`/`height`, or by
37
+ surrounding CSS. With no size it measures 0×0 and the server never renders.
38
+ Resizes are watched and debounced (400 ms).
39
+
40
+ Reach for `reactive_output` + your own chart whenever the client *could* draw
41
+ it — you get a real React component instead of a server-rendered PNG. Use these
42
+ two when the server genuinely must draw (matplotlib/ggplot specifics) or when a
43
+ widget already exists and re-implementing it is not the job.
44
+
45
+ ## Hosting a real Shiny *input* widget
46
+
47
+ Sometimes you need the genuine article — a real ionRangeSlider, a real
48
+ selectize — because the port has to look widget-for-widget identical to the
49
+ original app. `ShinyOutput` alone will not do it: it calls only
50
+ `Shiny.bindAll()`, and an input binding also needs its `initialize()` pass plus
51
+ the widget's own JS/CSS dependency (ion-rangeslider, selectize) on the page.
52
+
53
+ Shiny's dynamic-UI output does all three. Render the widget server-side and
54
+ host the holder in the client:
55
+
56
+ ```python
57
+ @render.ui # [r] output$widgets <- renderUI({ ... })
58
+ def widgets():
59
+ return ui.input_slider("bins", "Bins", min=1, max=50, value=9)
60
+ ```
61
+
62
+ ```jsx
63
+ <ShinyOutput id="widgets" className="shiny-html-output" />
64
+ ```
65
+
66
+ Shiny's html-output binding calls `renderContent()`, which loads the
67
+ dependencies and then runs `initializeInputs()` *and* `bindAll()`. So
68
+ `input.bins()` / `input$bins` arrives exactly as in a classic app, and
69
+ `update_slider()` / `updateSliderInput()` keeps working against the id.
70
+
71
+ This is a deliberate exception, not the default — React-owned state through
72
+ `useShinyInput` / `useSetShinyInput` is still how you build inputs. Use the
73
+ holder when pixel-identical widgets matter more than owning the state.
74
+
@@ -0,0 +1,188 @@
1
+ # Verifying a shinyreact app
2
+
3
+ Do not stop at "the code is written". At minimum:
4
+
5
+ 1. **Factor pure logic out of the app file.** Binning, formatting, conversions
6
+ go in a module beside the app so a test can import them directly, with no
7
+ session at all. Logic left inside `app.py` / `app.R` next to the page call
8
+ is still reachable — `testServer()` / `local_server` below drive the app
9
+ itself — but only through an input, which is a slower and blunter tool than
10
+ calling a function.
11
+ 2. **Write down what the app does, in plain English, before the tests** — a
12
+ behavior file beside the app, one atomically checkable claim per line
13
+ ("the caption reads `N eruptions in M bins`, singular `bin` at M=1"). An
14
+ agent that writes the client and then writes the client's tests is agreeing
15
+ with itself; both artifacts encode the same misunderstanding. A description
16
+ a human can falsify at a glance is what breaks that loop.
17
+ 3. **Test against that description**, at whichever of these layers is cheapest:
18
+
19
+ | Layer | Proves | Cost |
20
+ |---|---|---|
21
+ | pure functions in their own module | binning, formatting, conversions | trivial — always do this |
22
+ | `shiny::testServer()` `[r]` / the `local_server` fixture `[py]` | the reactive graph: inputs in, `reactive_output` values out | low, and no browser |
23
+ | the client mounted in jsdom against a fake Shiny | rendering, input wiring, wire ids, status handling | low, and it exercises the file the app ships |
24
+ | Playwright | layout, real Shiny, real bindings | high; reserve for what the others cannot see |
25
+
26
+ ## Where the tests live
27
+
28
+ Beside the app, and runnable **from the app directory** — someone sitting in
29
+ the app should not need your repo's tooling:
30
+
31
+ ```
32
+ myapp/
33
+ app.py / app.R
34
+ faithful.py the factored logic
35
+ www/ui.js
36
+ tests/
37
+ test_faithful.py [py] pytest — the factored logic, called directly
38
+ test_outputs.py [py] pytest — the app, via `local_server`
39
+ testthat.R [r] runner: library(testthat); test_dir("testthat")
40
+ testthat/
41
+ test-histogram.R [r]
42
+ ui.test.ts [js]
43
+ ```
44
+
45
+ ```bash
46
+ pytest # [py] from the app directory
47
+ Rscript -e 'shiny::runTests()' # [r] also shinytest2::test_app()
48
+ ```
49
+
50
+ `[r]` The two-file `tests/testthat.R` + `tests/testthat/` split is not a
51
+ convention you can skip: it is the layout `shiny::runTests()` and
52
+ `shinytest2::test_app()` look for. Put the tests directly in `tests/` and
53
+ neither finds them.
54
+
55
+ `[py]` A test that imports the app's own module needs the app directory on the
56
+ path, because pytest's rootdir is `tests/`:
57
+
58
+ ```python
59
+ EXAMPLE = Path(__file__).resolve().parents[1]
60
+ sys.path.insert(0, str(EXAMPLE))
61
+
62
+ from faithful import histogram, waiting # noqa: E402
63
+ ```
64
+
65
+ ## Testing the server: `testServer()` `[r]`, `local_server` `[py]`
66
+
67
+ **This is the highest-value layer for a `ui.tsx` app.** The server contains
68
+ only reactive computation, so "input X produces output Y" *is* the server, and
69
+ both languages can assert it with no browser and no client: set inputs, read
70
+ outputs, and get the JSON value the client would have received.
71
+
72
+ ### `[r]` `shiny::testServer()`
73
+
74
+ `reactive_output` is an ordinary Shiny render function, so `testServer()`
75
+ drives it directly.
76
+
77
+ ```r
78
+ test_that("the histogram recomputes when bins changes", {
79
+ server <- function(input, output, session) {
80
+ output$dist_data <- reactive_output({
81
+ if (is.null(input$bins)) return(NULL)
82
+ histogram(waiting, input$bins)
83
+ })
84
+ }
85
+
86
+ shiny::testServer(server, {
87
+ session$setInputs(bins = 9)
88
+ expect_equal(output$dist_data$counts, c(16, 37, 30, 16, 14, 57, 67, 29, 6))
89
+
90
+ session$setInputs(bins = 1)
91
+ expect_equal(sum(output$dist_data$counts), 272)
92
+ })
93
+ })
94
+ ```
95
+
96
+ `output$id` is the value itself — no spec wrapper, no coercion — so assert on
97
+ it directly. This is the cheapest way to pin "input X produces output Y",
98
+ which is most of what a shinyreact server does.
99
+
100
+ Module servers work the same way: `testServer(card_server, args = list(id =
101
+ "left"), { ... })`.
102
+
103
+ ### `[py]` the `local_server` fixture
104
+
105
+ The Python counterpart (py-shiny#2470, so newer than shiny 1.7.0). `local_server`
106
+ is a built-in pytest fixture — nothing to import — holding an already-started
107
+ `shiny.testserver.test_server()` session. It loads the app file — Express or
108
+ Core, `shiny.App` or `shinyreact.ReactApp` — and runs its server against a mock
109
+ connection:
110
+
111
+ ```python
112
+ import pytest
113
+ from shiny.testserver import TestServerSession
114
+
115
+ # The fixture defaults to `app.py` beside the test file; ours is a directory up.
116
+ pytestmark = pytest.mark.parametrize("local_server", ["../app.py"], indirect=True)
117
+
118
+
119
+ def test_the_histogram_recomputes_when_bins_changes(local_server: TestServerSession):
120
+ local_server.set_inputs(bins=9)
121
+ counts = local_server.get_output("dist_data").value["counts"]
122
+ assert counts == [16, 37, 30, 16, 14, 57, 67, 29, 6]
123
+ assert local_server.get_output("dist_caption") == "272 eruptions in 9 bins"
124
+ ```
125
+
126
+ The fixture is function-scoped, so each test gets a fresh session. Call
127
+ `test_server()` directly (as a context manager — it returns an *unstarted*
128
+ session) when the fixture cannot express what you need: a server function or
129
+ `App` object, `client_data=`, `timeout_secs=`.
130
+
131
+ `get_output()` returns a value that compares equal to the underlying one, so
132
+ assert on it directly; use `.value` when you need to index into it, and
133
+ `.status` (`"ok"` / `"error"` / `"silent"`) or `.error` to assert the
134
+ non-value outcomes. Traditional renderers are readable too, so
135
+ `@render.data_frame` / `@render_plotly` outputs mounted through `ShinyOutput`
136
+ can be checked at the wire level.
137
+
138
+ Four things to know, all of them shinyreact-specific:
139
+
140
+ - **An untyped input id needs no `:shinyreact.default` suffix.** The hook
141
+ appends it on the wire, but both Python handlers are no-ops, so
142
+ `set_inputs(bins=9)` is equivalent.
143
+ - **A typed one does.** `set_inputs(**{"when:shiny.datetime": 1756382400})` is
144
+ what makes the handler run and `input.when()` a `datetime`; without the
145
+ suffix you are testing a different app than the one the client drives.
146
+ - **An event input needs two calls.** `useShinyInput` registers its default at
147
+ mount and sends the event after, so an output behind
148
+ `@reactive.event(..., ignore_init=True)` only fires on the *second*
149
+ `set_inputs` for that id.
150
+ - **An unset input means `status == "silent"`, not a `None` value.**
151
+ `input.x()` raises a silent exception while unset, so a `if x is None:`
152
+ branch in your server is unreachable from a real client — assert the status
153
+ instead. A later `req()` failure reports `"silent"` too: the status describes
154
+ the *latest* render, matching the blank the browser shows.
155
+
156
+ Module ids can be read as the session sees them
157
+ (`local_server.get_output("counter-n")`) or through a scope, which strips the
158
+ namespace on the way in and out:
159
+
160
+ ```python
161
+ counter = local_server.make_scope("counter")
162
+ counter.set_inputs(n=7)
163
+ assert counter.get_output("label") == "n=7"
164
+ ```
165
+
166
+ ## The jsdom layer — mount the client the app ships
167
+
168
+ Evaluate the real `www/ui.js` against a fake `window.Shiny` (`setInputValue`
169
+ recording to an array, `bindAll`/`unbindAll` stubs) rather than importing the
170
+ component — a test that imports a component the app does not use is testing
171
+ nothing. It also lets you assert the **wire id**, including any `:type` suffix,
172
+ which is the contract the server actually sees.
173
+
174
+ Three traps cost real time:
175
+
176
+ - **`fireEvent.change`, not `el.dispatchEvent(new Event("change"))`.** React
177
+ tracks the native value setter, so a raw event on a directly-assigned `value`
178
+ is silently ignored. (`onInput` works either way.)
179
+ - **Debounce is real.** Nothing is on the wire immediately after mount — wait
180
+ out the 100 ms default. And two actions in one tick coalesce *even at
181
+ `debounceMs: 0`*, so to prove "no coalescing" put a real gap between them.
182
+ - **jsdom has no layout and runs no bindings**, so anything reading geometry,
183
+ and anything a widget draws, is out of reach. For `ShinyOutput` the testable
184
+ contract is the host element's shape — tag, id, classes.
185
+
186
+ A client built by Vite is a build artifact, so `www/ui.js` may be gitignored
187
+ and absent on a clean checkout. Build before testing, or test the source
188
+ component instead and accept that it is one step removed from what ships.
@@ -0,0 +1,282 @@
1
+ ---
2
+ name: shinyreact-convert-app
3
+ description: Convert an existing Shiny app (R or Python) to the shinyreact ui.tsx pattern — inspect the source, drive the running app in a browser, describe it in plain English, then port it against that description. Use when the user asks to port, convert, migrate, rewrite, or "React-ify" a Shiny app, or to reproduce an existing app's UI in React.
4
+ ---
5
+
6
+ # Converting an existing Shiny app to shinyreact
7
+
8
+ The failure mode of this task is not writing bad React. It is **porting an app
9
+ you never understood** — reproducing the widgets you can see in the source
10
+ while silently dropping the behavior that only shows up when you use it: what
11
+ is disabled until something else is set, what updates live versus on submit,
12
+ what the empty state says, which control secretly drives two outputs.
13
+
14
+ So the order is fixed:
15
+
16
+ ```
17
+ inspect the source → drive the running app → write PORT.md in plain English
18
+ → implement against it → verify
19
+ ```
20
+
21
+ Do not write a line of the new app before `PORT.md` exists. If the user asks
22
+ you to skip straight to code, port the smallest slice and write its `PORT.md`
23
+ section anyway — the description is what makes the port checkable by anyone
24
+ other than you.
25
+
26
+ Use the `shinyreact-build-app` skill for how to *write* the new app. This skill is about
27
+ knowing what to write.
28
+
29
+ **This skill covers Python and R together** — both as the source you are
30
+ porting from and as the target. Anything unmarked holds in both; `[py]` and
31
+ `[r]` mark the few places they differ. The port does not have to stay in the
32
+ source app's language, and the client half is identical either way.
33
+
34
+ ## Phase 1 — inspect the source
35
+
36
+ Read the whole app before summarizing any of it. Build these inventories and
37
+ **count** each, because "I read the app" is not a claim anyone can check:
38
+
39
+ | Inventory | What to capture |
40
+ |---|---|
41
+ | Inputs | id, widget type, default, range/choices, and every place the server reads it |
42
+ | Outputs | id, renderer (`renderPlot`, `renderText`, `renderDT`, `render_plotly`, …), and the UI element that hosts it |
43
+ | Reactives | `reactive()` / `@reactive.calc` / `eventReactive` / `observeEvent` — who triggers what |
44
+ | Data | source, shape, grain, size, whether it is loaded once or per session |
45
+ | Layout | pages/tabs, sidebar, cards, and what is nested in what |
46
+ | Conditional UI | `conditionalPanel`, `req()`, `validate/need`, `update*Input`, `insertUI` |
47
+ | Modules | each module's namespace and its input/output surface |
48
+ | Non-Shiny deps | DT, plotly, leaflet, bslib themes, custom CSS/JS |
49
+
50
+ Two questions to answer explicitly, because they decide the port's shape:
51
+
52
+ - **What does each output actually contain?** A `renderPlot` that draws a chart
53
+ from a small data frame becomes data + a client-side chart. A `renderPlot`
54
+ of something matplotlib-specific stays server-rendered behind `ImageOutput`.
55
+ - **Where does the reactivity fan out?** One filter feeding six cards should
56
+ become one shared `@reactive.calc` and six aggregations, not six independent
57
+ pipelines.
58
+
59
+ ## Phase 2 — drive the running app
60
+
61
+ Static reading cannot tell you what the app *feels* like. Run it and use it.
62
+
63
+ Ask the user how to start it if it is not obvious. Then, with the browser tools
64
+ (`claude-in-chrome`, or Playwright):
65
+
66
+ - Take a screenshot of the initial state before touching anything. That is the
67
+ empty/default state, and it is the state ports most often get wrong.
68
+ - Move **every** control, including to its extremes and to an empty value.
69
+ Note what re-renders, what does not, and what flashes.
70
+ - Watch for things no source read reveals: debounce (does it update while
71
+ dragging or on release?), controls that disable each other, validation
72
+ messages, an output that stays stale during recompute versus one that blanks.
73
+ - Note exact copy: labels, button text, placeholder text, empty-state strings,
74
+ singular/plural. The port should be diffable against the original by reading.
75
+ - Check the console and network only if something looks wrong; do not go
76
+ spelunking.
77
+
78
+ Keep this bounded. If the app needs credentials, data you do not have, or more
79
+ than a few minutes of clicking to reach a state, stop and ask the user rather
80
+ than exploring further.
81
+
82
+ ## Phase 3 — write `PORT.md`
83
+
84
+ One file at the root of the new app. It has two parts, and both matter:
85
+
86
+ **Part 1 — the behavior tree.** What the app does, in plain English, as a
87
+ nested bullet list, one atomically checkable claim per leaf. The tree path says
88
+ *where* the claim lives (data vs. UI vs. reactivity vs. wire); the leaf says
89
+ *what to check*:
90
+
91
+ ```
92
+ - histogram of Old Faithful eruption WAITING times
93
+ - data: faithful.csv, column `waiting` (minutes, ~43-96)
94
+ - NOT the `eruptions` column
95
+ - binning matches R's hist(): equal-width, (lo, hi], first bin inclusive
96
+ - bins slider
97
+ - range 1-50, default 9
98
+ - updates live, not debounced
99
+ - drives BOTH outputs
100
+ - dist_data: {breaks: number[], counts: number[]}
101
+ - dist_caption: "272 eruptions in N bins", singular "bin" when N=1
102
+ - while recalculating: previous chart stays mounted, dims (no skeleton flash)
103
+ ```
104
+
105
+ The rules:
106
+
107
+ - Behavior, not implementation. "the caption reads `N eruptions in M bins`,
108
+ singular `bin` at M=1", not "calls `format()`".
109
+ - Specifics or nothing. Exact ids, defaults, ranges, copy, wire shapes. "handles
110
+ empty input" is unauditable.
111
+ - Mark uncertainty with `(verify)` rather than dropping it. A leaf you were not
112
+ sure of and silently omitted is how a tree becomes untrustworthy.
113
+ - Present tense, describing the **original** app. This is the specification the
114
+ port is judged against.
115
+
116
+ **Part 2 — checklists.** One checklist per feature area (per tab, per card, per
117
+ data path — whatever the app's own seams are), with a checkbox per leaf or
118
+ small group of leaves:
119
+
120
+ ```markdown
121
+ ## Checklist — filters sidebar
122
+
123
+ - [x] date range picker, defaults to the last 30 days
124
+ - [x] search box, debounced ~300 ms, matches name and id
125
+ - [ ] category multi-select, empty means "all"
126
+ - [ ] reset button clears all three and re-runs
127
+ ```
128
+
129
+ Add a short **Status** line at the top (what is done, what is next, what is
130
+ blocked) and a **Deliberate divergences** section for anything you are *not*
131
+ porting as-is, with the reason. An unexplained difference reads as a bug
132
+ forever.
133
+
134
+ ## Phase 4 — implement, updating `PORT.md` as you go
135
+
136
+ `PORT.md` is the shared progress record — other agents and the user read it to
137
+ know where the port stands, so a stale one is worse than none.
138
+
139
+ - Tick a box **when the behavior works**, not when the code is written.
140
+ - When you discover the description was wrong, fix the description in the same
141
+ change, and say so in the Status line. The original app wins over your notes.
142
+ - When you decide not to port something, move it to Deliberate divergences
143
+ rather than leaving the box unticked forever.
144
+ - Keep the file's structure stable between updates so a diff is readable.
145
+
146
+ Port order that avoids rework: data + reactives first (they decide the output
147
+ shapes), then one full vertical slice end to end (one input, one output,
148
+ rendered), then the rest of the outputs, then layout and polish.
149
+
150
+ ## Phase 5 — verify
151
+
152
+ Four layers, cheapest first: factor pure logic out of the app file so it is
153
+ importable and test it directly; drive the ported server with no browser —
154
+ `[r]` `shiny::testServer()`, `[py]` the `local_server` pytest fixture — which
155
+ for a `ui.tsx` app covers most of it, since the server is only reactive
156
+ computation; test the client by evaluating the real `www/ui.js` against a fake
157
+ `window.Shiny` in jsdom (not by importing the component — that tests a copy the
158
+ app does not ship); and reserve Playwright for layout and real bindings, which
159
+ the others structurally cannot see. The `shinyreact-build-app` skill's
160
+ [`references/testing.md`](../shinyreact-build-app/references/testing.md) has
161
+ the details and the traps.
162
+
163
+ A port has one advantage a new app does not: **the original still runs.** Where
164
+ the logic is a pure transform, capture its output from the original app and
165
+ assert the same values in the port.
166
+
167
+ Every leaf you assert gets a `(test)` marker in `PORT.md`. Finish by
168
+ re-driving the ported app in the browser against the checklists — including the
169
+ initial state screenshot from Phase 2, side by side.
170
+
171
+ ### Two cheap side-by-side checks
172
+
173
+ With both apps running, evaluate this on each page and diff the two results.
174
+ It catches the two failure modes a checklist walk-through misses — a widget
175
+ whose CSS/JS the port never loads, and a JS exception that stops Shiny
176
+ connecting at all (so *nothing* works and every box looks equally broken):
177
+
178
+ ```js
179
+ JSON.stringify({
180
+ connected: !!window.Shiny?.shinyapp?.isConnected?.(),
181
+ inputs: document.querySelectorAll(".shiny-bound-input").length,
182
+ outputs: document.querySelectorAll(".shiny-bound-output").length,
183
+ errors: [...document.querySelectorAll(".shiny-output-error")].map((e) => e.id),
184
+ assets: [...document.querySelectorAll("link[rel=stylesheet],script[src]")]
185
+ .map((e) => (e.href || e.src).replace(location.origin, "").replace(/\?.*/, "")),
186
+ });
187
+ ```
188
+
189
+ Read `assets` by library, not by exact path — a theme-compiled Bootstrap and
190
+ the stock file are the same library, and `ui.js` / `ui.css` / `shinyreact-*`
191
+ are expected on the port only. **Bootstrap on the original and nothing on the
192
+ port is also expected**: a themeless `page_react()` attaches none (#285), since
193
+ the client owns styling. Read it as a to-do rather than a defect — every
194
+ Bootstrap class the original's markup leaned on (`btn`, `form-control`,
195
+ `container`, the grid) is unstyled in the port until your components supply it.
196
+ Also check the browser console for exceptions before trusting any other result,
197
+ and re-check after clicking into each tab: the port's dependencies arrive when
198
+ an output first renders, not at load.
199
+
200
+ ## Translation table
201
+
202
+ | Original | shinyreact |
203
+ |---|---|
204
+ | `sliderInput` / `textInput` / `selectInput` | a library control (shadcn/ui `Slider`, `Input`, `Select`) + `useShinyInput(id, default)` |
205
+ | `dateRangeInput` / `selectizeInput` | a real library component — a date picker or combobox, with `react-day-picker` / shadcn's `Combobox` underneath. Never hand-roll these |
206
+ | `actionButton` | `useShinyInput(id, 0, {debounceMs: 0, priority: "event"})`, increment on click; ignore the initial 0 server-side (`[py]` `@reactive.event(..., ignore_init=True)`, `[r]` an explicit `if (is.null(x) \|\| x == 0) return(NULL)`) |
207
+ | `renderText` / `renderPrint` | `reactive_output` returning the string, `useShinyOutputValue` client-side |
208
+ | `renderPlot` (data you could draw) | `reactive_output` returning the data; draw in React |
209
+ | `renderPlot` (matplotlib/ggplot-specific) | keep the renderer, host it with `ImageOutput` |
210
+ | `renderDT` / `render.data_frame` / `renderPlotly` | keep the renderer, host it with `ShinyOutput` — no `*Output()` placeholder needed, and its binding JS is discovered for you in both languages |
211
+ | `downloadButton` + `downloadHandler` | keep the `downloadHandler` unchanged — it is a server route, not a rendered value, and it works with no `downloadButton()` in any UI; host `<a class="shiny-download-link">` via `ShinyOutput` and Shiny's own binding fills in `href`. Never rebuild the file client-side from the JSON another output uses: the serializers disagree on details (R's `write.csv` renders `0.0002` as `2e-04`; a JS re-implementation will not) |
212
+ | `fileInput` | host a native `<input type="file">` (plus its label/button markup) via `ShinyOutput` so Shiny's own file-input binding does the multipart upload. Raw bytes cannot travel through `useShinyInput`, and reimplementing the upload RPC means depending on internal Shiny API |
213
+ | `tabsetPanel` / `navset_*` | a client-side tab strip that keeps every panel **mounted** and hides inactive ones (CSS `hidden`; Radix `Tabs` needs `forceMount`). Unmounting a panel drops its outputs' subscriptions mid-session (`Output not found` in the console); there is no suspend-when-hidden in this pattern, so note the always-computes divergence in `PORT.md` |
214
+ | `htmlTemplate("www/index.html")` / an app that owns `index.html` | keep the document and serve it with `page_react_html()` — see the next section |
215
+ | `conditionalPanel` | ordinary React conditional rendering; no server round trip |
216
+ | `update*Input` | the client already owns the value — set React state; use `send_message` only for genuine server-initiated events |
217
+ | `req()` / `validate` | return `None` / `NULL` and let the client show its empty state |
218
+ | Shiny modules | `ShinyModuleProvider` around the subtree |
219
+ | `insertUI` / `removeUI` | React state, not DOM surgery |
220
+
221
+ ### Porting an app that owns its HTML document
222
+
223
+ When the source UI is `htmlTemplate("www/index.html")` (or `[py]` a
224
+ hand-written `index.html`), the document is part of the app's surface — port
225
+ the document, don't flatten it into `page_react()`:
226
+
227
+ - **Upgrade the head.** Delete the hardcoded Shiny includes old templates
228
+ carry (`shared/jquery.min.js`, `shared/shiny.min.js`, `shiny.css`) and put
229
+ the `{{ headContent() }}` marker inside `<head>` — spelled exactly like
230
+ that, spaces included ([r] the check is a fixed-string match). Shiny's and
231
+ shinyreact's tags render at the marker.
232
+ - **Serve it.** `[r]` `shinyApp(ui = page_react_html("www/index.html"), server)`;
233
+ `[py]` `ReactApp(server)` discovers `www/index.html` on its own.
234
+ - The document keeps what the app owns (meta tags, fonts, analytics, the
235
+ layout shell); the parts with *behavior* move into the React client as
236
+ usual.
237
+ - `[r]` the whole file is an `htmlTemplate()`: every `{{ ... }}` in it
238
+ evaluates as R, so escape Mustache/Vue-style braces the document may
239
+ already contain.
240
+
241
+ Fall back to `page_react()` only when the document carries nothing worth
242
+ keeping — default scaffolding with no custom head content — and record that
243
+ in `PORT.md`'s Deliberate divergences, because silently dropping a file the
244
+ original ships reads as an omission.
245
+
246
+ ### Startup ordering is not the original's
247
+
248
+ In the original app every input value exists synchronously at session start.
249
+ In the port each `useShinyInput` arrives on its own async round trip after
250
+ mount, so:
251
+
252
+ - An `eventReactive(..., ignoreNULL = FALSE)` / `@reactive.event(...,
253
+ ignore_init=False)` can fire with sibling inputs still `NULL`.
254
+ - An event input with `debounceMs: 0` can outrun inputs left on the 100 ms
255
+ default — the click counter's initial `0` reaches the server before the
256
+ values the handler reads.
257
+
258
+ Give every input the handler reads at event time (via `isolate()` or inside
259
+ the event) `debounceMs: 0` as well, and guard the first flush with an explicit
260
+ `NULL`-or-initial-`0` check rather than trusting `ignoreInit` /
261
+ `ignore_init` alone — shinyreact's init ping flushes the reactive graph once
262
+ before real values arrive, which can spend that exemption.
263
+
264
+ ### Two decisions to make before you start porting
265
+
266
+ **Which widgets you are not going to write.** Shiny's inputs look small in the
267
+ source and are not: `dateRangeInput` is a calendar with keyboard navigation and
268
+ range validation, `selectizeInput` is a searchable multi-select. A port that
269
+ re-implements them by hand is where the schedule goes, and it is code with no
270
+ upstream tests that the user now owns. Take shadcn/ui + Tailwind as the default
271
+ component layer, `@tanstack/react-table` for tables, `recharts` for ordinary
272
+ charts, `react-hook-form` + `zod` for form validation, `date-fns` for dates.
273
+
274
+ Write from scratch only what is specific to *this* app — its dashboard layout,
275
+ its one bespoke visualization.
276
+
277
+ **Which outputs stay server-rendered.** Decide per output, because it decides
278
+ whether the port needs `ImageOutput` / `ShinyOutput` at all. A `renderPlot` of
279
+ a small data frame becomes data plus a React chart. A ggplot with custom
280
+ annotations, or an existing DT/plotly/leaflet widget, stays where it is and
281
+ gets hosted — re-implementing a widget that already works is work you can
282
+ simply not do.
shinyreact/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ from . import (
2
+ _input_handler, # noqa: F401 (side-effect import: registers input handlers)
3
+ )
4
+ from ._app import ReactApp
5
+ from ._page import (
6
+ page_bare,
7
+ page_react,
8
+ page_react_dep,
9
+ page_react_html,
10
+ set_react_page,
11
+ )
12
+ from ._reactive_output import reactive_output
13
+ from ._send_message import send_message
14
+
15
+ __all__ = [
16
+ "ReactApp",
17
+ "page_bare",
18
+ "page_react",
19
+ "page_react_dep",
20
+ "page_react_html",
21
+ "reactive_output",
22
+ "send_message",
23
+ "set_react_page",
24
+ ]