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.
- shinyreact/.agents/skills/shinyreact-build-app/SKILL.md +433 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/bookmarking.md +24 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/debugging.md +29 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/modules.md +118 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/no-build.md +35 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/shiny-outputs.md +74 -0
- shinyreact/.agents/skills/shinyreact-build-app/references/testing.md +188 -0
- shinyreact/.agents/skills/shinyreact-convert-app/SKILL.md +282 -0
- shinyreact/__init__.py +24 -0
- shinyreact/_app.py +205 -0
- shinyreact/_bookmark.py +86 -0
- shinyreact/_dep.py +93 -0
- shinyreact/_dep_discovery.py +76 -0
- shinyreact/_input_handler.py +42 -0
- shinyreact/_page.py +656 -0
- shinyreact/_protocol.py +17 -0
- shinyreact/_reactive_output.py +19 -0
- shinyreact/_send_message.py +40 -0
- shinyreact/playwright.py +204 -0
- shinyreact/www/shinyreact.css +1 -0
- shinyreact/www/shinyreact.js +49 -0
- shinyreact-0.1.0.dist-info/METADATA +114 -0
- shinyreact-0.1.0.dist-info/RECORD +25 -0
- shinyreact-0.1.0.dist-info/WHEEL +4 -0
- shinyreact-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|