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
shinyreact/_app.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""``shinyreact.ReactApp``: a ``shiny.App`` whose UI is discovered, not passed.
|
|
2
|
+
|
|
3
|
+
``shiny.App`` accepts the full-document UI itself via ``ui.page_html()``
|
|
4
|
+
(py-shiny#2475). ``ReactApp`` adds the two things it does not do: discover the
|
|
5
|
+
ui.tsx-pattern UI next to the app file, and serve the sibling assets a full
|
|
6
|
+
document references.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
15
|
+
|
|
16
|
+
from shiny import App as _ShinyApp
|
|
17
|
+
from shiny.types import MISSING, MISSING_TYPE
|
|
18
|
+
|
|
19
|
+
from ._dep import ShinyreactJs, _serves_bundle
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
StaticAssets = str | Path | Mapping[str, str | Path]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# `page_react_html()` tags the `ui.page_html()` document it returns with the
|
|
26
|
+
# directory it was read from, so `ReactApp` can serve the sibling assets
|
|
27
|
+
# (`ui.js` etc.) the document references. An attribute rather than a subclass:
|
|
28
|
+
# py-shiny exports `ui.page_html()` but not its document class.
|
|
29
|
+
SRC_DIR_ATTR = "_shinyreact_src_dir"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _add_react_dir(
|
|
33
|
+
static_assets: StaticAssets | None | MISSING_TYPE, react_dir: Path | None
|
|
34
|
+
) -> StaticAssets | None:
|
|
35
|
+
"""Resolve ``static_assets`` into what :class:`shiny.App` should mount.
|
|
36
|
+
|
|
37
|
+
``react_dir`` is the directory a full HTML document's relative asset URLs
|
|
38
|
+
resolve against, so it has to be served at ``"/"``. It is *added to* what
|
|
39
|
+
the author asked for rather than replacing it, so an unrelated mount cannot
|
|
40
|
+
silently stop ``ui.js`` from being served. Four cases:
|
|
41
|
+
|
|
42
|
+
- not passed (``MISSING``) → ``react_dir`` at ``"/"``
|
|
43
|
+
- ``None`` → nothing; the author said no static assets and meant it
|
|
44
|
+
- a mapping → their mounts, plus ``react_dir`` at ``"/"`` if they left
|
|
45
|
+
``"/"`` free
|
|
46
|
+
- a bare path → theirs only; a bare path *is* the ``"/"`` mount, so they
|
|
47
|
+
have already said which directory serves the document's siblings
|
|
48
|
+
"""
|
|
49
|
+
author_mounts: Mapping[str, str | Path]
|
|
50
|
+
if isinstance(static_assets, MISSING_TYPE):
|
|
51
|
+
author_mounts = {}
|
|
52
|
+
elif static_assets is None or not isinstance(static_assets, Mapping):
|
|
53
|
+
return static_assets
|
|
54
|
+
else:
|
|
55
|
+
author_mounts = static_assets
|
|
56
|
+
|
|
57
|
+
if react_dir is None or not react_dir.is_dir():
|
|
58
|
+
return author_mounts or None
|
|
59
|
+
# Author's keys last, so an explicit "/" replaces ours. Shiny sorts mount
|
|
60
|
+
# points by descending length itself, so insertion order is irrelevant.
|
|
61
|
+
return {"/": react_dir, **author_mounts}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ReactApp(_ShinyApp):
|
|
65
|
+
""":class:`shiny.App` for the ui.tsx pattern — the UI is discovered.
|
|
66
|
+
|
|
67
|
+
The app file is just the server::
|
|
68
|
+
|
|
69
|
+
from shinyreact import ReactApp
|
|
70
|
+
|
|
71
|
+
app = ReactApp(server, bookmark_store="url")
|
|
72
|
+
|
|
73
|
+
With no ``ui=``, the UI is discovered next to the calling module the same
|
|
74
|
+
way :func:`set_react_page` discovers it: ``www/index.html`` present →
|
|
75
|
+
:func:`page_react_html`; otherwise → :func:`page_react` (``www/ui.js`` /
|
|
76
|
+
``www/ui.css``, served by the dependency itself). The discovered UI is a
|
|
77
|
+
function of the request, so it re-renders per request — which is what makes
|
|
78
|
+
``bookmark_store=`` work with no further wiring, and what lets the mode
|
|
79
|
+
switch mid-session when you create or delete ``www/index.html``.
|
|
80
|
+
|
|
81
|
+
``ui=`` overrides discovery and behaves exactly like ``shiny.App``'s ``ui``
|
|
82
|
+
argument, except that a document from :func:`page_react_html` still gets
|
|
83
|
+
its directory mounted. Note the
|
|
84
|
+
discovery reads the *immediate* calling frame (like :func:`page_react_dep`),
|
|
85
|
+
so a helper that wraps ``ReactApp(...)`` must pass ``ui=`` explicitly.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
server: The server function, as for :class:`shiny.App`.
|
|
89
|
+
ui: Overrides UI discovery. Anything ``shiny.App`` accepts.
|
|
90
|
+
static_assets: Extra static mounts. **Added to** the React asset
|
|
91
|
+
directory rather than replacing it — see below. Defaults to
|
|
92
|
+
:data:`shiny.types.MISSING`, not ``None``, so that passing ``None``
|
|
93
|
+
explicitly can mean "no static assets at all".
|
|
94
|
+
bookmark_store: ``"url"`` / ``"server"`` to enable bookmarking, as for
|
|
95
|
+
:class:`shiny.App`. Requires a callable UI, which discovery
|
|
96
|
+
provides; a static ``ui=page_react_html(...)`` raises.
|
|
97
|
+
shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css`` to
|
|
98
|
+
the discovered UI: ``"server"`` (default) or ``"client"`` for an
|
|
99
|
+
npm-tier app whose bundle imports ``@posit-dev/shinyreact``. Ignored
|
|
100
|
+
when ``ui=`` is passed — build that UI with
|
|
101
|
+
``shinyreact_js="client"`` yourself.
|
|
102
|
+
**kwargs: Forwarded to :class:`shiny.App` (``debug=``, ``test_mode=``).
|
|
103
|
+
|
|
104
|
+
Static assets
|
|
105
|
+
-------------
|
|
106
|
+
A full HTML document references its bundle with a plain relative URL
|
|
107
|
+
(``<script src="ui.js">``), so *something* has to serve the directory the
|
|
108
|
+
document lives in. ``ReactApp`` mounts it at ``/`` for you:
|
|
109
|
+
|
|
110
|
+
- **discovery mode** — ``www/`` next to the app file, mounted whenever the
|
|
111
|
+
directory exists. It is mounted even in ``page_react()`` mode, where the
|
|
112
|
+
dependency serves the assets itself and the mount is unused: the mode is
|
|
113
|
+
decided per *request* while ``static_assets`` is a *constructor*
|
|
114
|
+
argument, so an unused mount is the only way to keep both modes working.
|
|
115
|
+
An unused mount is harmless; a missing one is a 404 per asset.
|
|
116
|
+
- **``ui=page_react_html(...)``** — the document's own directory, which
|
|
117
|
+
:func:`page_react_html` tagged onto the document it returned.
|
|
118
|
+
|
|
119
|
+
Your ``static_assets`` is **merged with** that mount, not substituted for
|
|
120
|
+
it, so adding an unrelated mount doesn't take the bundle down with it::
|
|
121
|
+
|
|
122
|
+
ReactApp(server, static_assets={"/data": DATA_DIR})
|
|
123
|
+
# serves /data from DATA_DIR *and* / from www/
|
|
124
|
+
|
|
125
|
+
You win wherever you actually collide with it, and there are three ways to::
|
|
126
|
+
|
|
127
|
+
ReactApp(server, static_assets={"/": DIST_DIR}) # explicit "/" key
|
|
128
|
+
ReactApp(server, static_assets=DIST_DIR) # a bare path *is* "/"
|
|
129
|
+
ReactApp(server, static_assets=None) # nothing mounted
|
|
130
|
+
|
|
131
|
+
The last one is why the default is :data:`shiny.types.MISSING` rather than
|
|
132
|
+
``None``: with ``None`` as the default there would be no way to say "mount
|
|
133
|
+
nothing", since not passing the argument and passing ``None`` would look
|
|
134
|
+
identical from in here.
|
|
135
|
+
|
|
136
|
+
Shiny requires every mount path to be absolute, and sorts mount points by
|
|
137
|
+
descending length, so a nested mount is matched before ``/``.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
server: Any,
|
|
143
|
+
*,
|
|
144
|
+
ui: Any = None,
|
|
145
|
+
static_assets: StaticAssets | None | MISSING_TYPE = MISSING,
|
|
146
|
+
bookmark_store: Literal["url", "server", "disable"] = "disable",
|
|
147
|
+
shinyreact_js: ShinyreactJs = "server",
|
|
148
|
+
**kwargs: Any,
|
|
149
|
+
) -> None:
|
|
150
|
+
# Validate now rather than at first page render: a typo should fail at
|
|
151
|
+
# startup, next to the call that made it.
|
|
152
|
+
_serves_bundle(shinyreact_js)
|
|
153
|
+
|
|
154
|
+
# The directory holding the React bundle, to be mounted at "/".
|
|
155
|
+
react_dir: Path | None = None
|
|
156
|
+
|
|
157
|
+
if ui is None:
|
|
158
|
+
# Import here: _page imports SRC_DIR_ATTR from this module.
|
|
159
|
+
from ._page import page_react, page_react_html
|
|
160
|
+
|
|
161
|
+
caller_file = sys._getframe(1).f_globals.get("__file__")
|
|
162
|
+
# No __file__ (REPL / exec'd code): fall back to CWD, matching
|
|
163
|
+
# page_react() / page_react_html().
|
|
164
|
+
app_dir = Path(caller_file).parent if caller_file else Path.cwd()
|
|
165
|
+
src_dir = app_dir / "www"
|
|
166
|
+
index_path = src_dir / "index.html"
|
|
167
|
+
react_dir = src_dir
|
|
168
|
+
|
|
169
|
+
def discovered_ui(request: Any) -> Any:
|
|
170
|
+
# Re-checked per request, not latched at construction: the UI is
|
|
171
|
+
# already a per-request function, so creating or deleting
|
|
172
|
+
# www/index.html during a dev session now switches modes without
|
|
173
|
+
# a restart. `exists()` is one stat call per page render.
|
|
174
|
+
if index_path.exists():
|
|
175
|
+
return page_react_html(index_path, shinyreact_js=shinyreact_js)
|
|
176
|
+
return page_react(src_dir=src_dir, shinyreact_js=shinyreact_js)
|
|
177
|
+
|
|
178
|
+
ui = discovered_ui
|
|
179
|
+
|
|
180
|
+
else:
|
|
181
|
+
react_dir = getattr(ui, SRC_DIR_ATTR, None)
|
|
182
|
+
|
|
183
|
+
if react_dir is not None and bookmark_store != "disable":
|
|
184
|
+
# Shiny would raise here too ("App(ui=) must be a function"),
|
|
185
|
+
# but the fix a shinyreact author needs is specific, and the
|
|
186
|
+
# obvious workaround is wrong: wrapping *this object* in a
|
|
187
|
+
# `lambda request: doc` yields a callable UI that never
|
|
188
|
+
# restores, because page_react_html() reads the RestoreContext
|
|
189
|
+
# when it builds the config tag — i.e. already, before any
|
|
190
|
+
# request. The call has to happen per request.
|
|
191
|
+
raise TypeError(
|
|
192
|
+
"bookmark_store= needs a UI that is rebuilt per request, but"
|
|
193
|
+
" ui=page_react_html(...) is a single document built once."
|
|
194
|
+
" Drop ui= and let ReactApp discover www/index.html, or pass"
|
|
195
|
+
" the call itself:"
|
|
196
|
+
' ui=lambda request: page_react_html("client/index.html").'
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
super().__init__(
|
|
200
|
+
ui,
|
|
201
|
+
server,
|
|
202
|
+
static_assets=_add_react_dir(static_assets, react_dir),
|
|
203
|
+
bookmark_store=bookmark_store,
|
|
204
|
+
**kwargs,
|
|
205
|
+
)
|
shinyreact/_bookmark.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from htmltools import HTML, HTMLDependency, head_content, tags
|
|
6
|
+
from shiny.bookmark._restore_state import (
|
|
7
|
+
RestoreContext,
|
|
8
|
+
get_current_restore_context,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from ._protocol import PROTOCOL_VERSION
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_restore_input_values(ctx: RestoreContext) -> dict[str, object]:
|
|
15
|
+
"""Return the underlying input value map from a RestoreContext.
|
|
16
|
+
|
|
17
|
+
Reads ``ctx.input.as_dict()`` directly. Does NOT call ``RestoreInputSet.get()``
|
|
18
|
+
or the public ``restore_input(id, default)`` helper — those mark each value
|
|
19
|
+
as pending and Shiny's normal flow would mark them used on the first flush,
|
|
20
|
+
making the value unavailable to subsequent ``restore_input`` callers in the
|
|
21
|
+
same render. We only want to *report* the values to the client; consumption
|
|
22
|
+
semantics are unchanged.
|
|
23
|
+
"""
|
|
24
|
+
return ctx.input.as_dict()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _current_restore_values() -> dict[str, object]:
|
|
28
|
+
"""Restored input values for the active request, or {} when none.
|
|
29
|
+
|
|
30
|
+
Returns ``{}`` when no session/RestoreContext is active (outside an HTTP
|
|
31
|
+
request) or when no bookmark query string was parsed.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
ctx = get_current_restore_context()
|
|
35
|
+
except RuntimeError:
|
|
36
|
+
# No active session/RestoreContext available outside an HTTP request.
|
|
37
|
+
return {}
|
|
38
|
+
if ctx is None:
|
|
39
|
+
return {}
|
|
40
|
+
return _read_restore_input_values(ctx)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _config_script_tag() -> HTMLDependency:
|
|
44
|
+
"""Head-injected ``#shinyreact-config`` JSON script tag.
|
|
45
|
+
|
|
46
|
+
Always emitted by page entry points. Carries the wire-protocol version
|
|
47
|
+
(asserted by the JS client at boot — see
|
|
48
|
+
``decisions/2026-08-17-js-distribution.md``) and, when a bookmark is being
|
|
49
|
+
restored, the restored input values under ``restore``.
|
|
50
|
+
|
|
51
|
+
The payload is plain JSON in a ``type="application/json"`` script tag —
|
|
52
|
+
the browser never executes it as JavaScript, so no JS-string-literal
|
|
53
|
+
escaping is needed. Exactly one property of the encoding matters:
|
|
54
|
+
|
|
55
|
+
- Every ``<`` is emitted as ``\\u003c`` so the payload can never contain
|
|
56
|
+
``</script`` (which would terminate the surrounding tag) or ``<!--``.
|
|
57
|
+
|
|
58
|
+
``json.dumps`` also defaults to ``ensure_ascii=True``, so non-ASCII lands
|
|
59
|
+
as ``\\uXXXX`` escapes — but nothing depends on that. U+2028 / U+2029 were
|
|
60
|
+
a hazard only while the payload was a JS string literal (#183); inside a
|
|
61
|
+
JSON tag they are inert, which is why R emits them literally and the tests
|
|
62
|
+
in both languages assert a *round-trip* rather than an escape.
|
|
63
|
+
|
|
64
|
+
The client reads it with ``JSON.parse``, which treats keys like
|
|
65
|
+
``__proto__`` and ``constructor`` as ordinary own properties.
|
|
66
|
+
|
|
67
|
+
SECURITY
|
|
68
|
+
--------
|
|
69
|
+
Bookmarked input values appear in the rendered HTML page source. In
|
|
70
|
+
URL bookmark mode the values are also already in the URL itself, so this
|
|
71
|
+
tag adds no exposure. In server-stored bookmark mode (``?_state_id_=...``)
|
|
72
|
+
the URL hides the values, but this tag re-exposes them in the page
|
|
73
|
+
source. Anything that can read the HTML — browser extensions, logging
|
|
74
|
+
proxies, screen captures, "View Source" — can read these values. Apps must
|
|
75
|
+
not put credentials, tokens, PII, or other sensitive data into inputs that
|
|
76
|
+
participate in bookmarking.
|
|
77
|
+
"""
|
|
78
|
+
config: dict[str, object] = {"protocolVersion": PROTOCOL_VERSION}
|
|
79
|
+
values = _current_restore_values()
|
|
80
|
+
if values:
|
|
81
|
+
config["restore"] = values
|
|
82
|
+
|
|
83
|
+
payload = json.dumps(config).replace("<", "\\u003c")
|
|
84
|
+
return head_content(
|
|
85
|
+
tags.script(HTML(payload), type="application/json", id="shinyreact-config")
|
|
86
|
+
)
|
shinyreact/_dep.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from htmltools import HTMLDependency, TagChild, TagList
|
|
5
|
+
|
|
6
|
+
from ._bookmark import _config_script_tag
|
|
7
|
+
|
|
8
|
+
_WWW_DIR = Path(__file__).parent / "www"
|
|
9
|
+
|
|
10
|
+
# `@posit-dev/shinyreact`'s version, i.e. the release of the bundle in `www/`.
|
|
11
|
+
# It is the shinyreact HTMLDependency's version, so
|
|
12
|
+
# `/lib/shinyreact-0.1.1/shinyreact.js` names the JS release being served and
|
|
13
|
+
# an npm-tier app can read the page source to see whether its bundled copy
|
|
14
|
+
# matches the server's. Bump alongside `pkg-js/package.json`; `test_dep.py`
|
|
15
|
+
# pins the two together. Mirrors R's `.shinyreact_js_version`.
|
|
16
|
+
_SHINYREACT_JS_VERSION = "0.1.1"
|
|
17
|
+
|
|
18
|
+
# Only reachable from a repo checkout (editable install), never from an
|
|
19
|
+
# installed wheel. Its presence is the "dev checkout" signal for
|
|
20
|
+
# `_bundle_version()`.
|
|
21
|
+
_JS_PACKAGE_JSON = Path(__file__).parents[3] / "pkg-js" / "package.json"
|
|
22
|
+
|
|
23
|
+
# Who supplies shinyreact.js (and shinyreact.css) to the page.
|
|
24
|
+
ShinyreactJs = Literal["server", "client"]
|
|
25
|
+
_SHINYREACT_JS_VALUES = ("server", "client")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _file_mtime_int(path: Path) -> int | None:
|
|
29
|
+
"""Return the file's mtime in whole seconds, or None if it doesn't exist."""
|
|
30
|
+
try:
|
|
31
|
+
return int(path.stat().st_mtime)
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bundle_version() -> str:
|
|
37
|
+
"""``_SHINYREACT_JS_VERSION``, suffixed with the bundle's mtime in a dev checkout.
|
|
38
|
+
|
|
39
|
+
An installed package serves ``/lib/shinyreact-0.1.1/``. In the repo
|
|
40
|
+
checkout it is ``/lib/shinyreact-0.1.1.<mtime>/`` instead, so a
|
|
41
|
+
``make update-dist`` still cache-busts while the URL still names the release.
|
|
42
|
+
Mirrors R's ``.bundle_version()``.
|
|
43
|
+
"""
|
|
44
|
+
if _JS_PACKAGE_JSON.is_file():
|
|
45
|
+
mtime = _file_mtime_int(_WWW_DIR / "shinyreact.js")
|
|
46
|
+
if mtime is not None:
|
|
47
|
+
return f"{_SHINYREACT_JS_VERSION}.{mtime}"
|
|
48
|
+
return _SHINYREACT_JS_VERSION
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _dep() -> HTMLDependency:
|
|
52
|
+
return HTMLDependency(
|
|
53
|
+
name="shinyreact",
|
|
54
|
+
version=_bundle_version(),
|
|
55
|
+
source={"subdir": str(_WWW_DIR)},
|
|
56
|
+
script={"src": "shinyreact.js", "defer": ""},
|
|
57
|
+
stylesheet={"href": "shinyreact.css"},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _serves_bundle(shinyreact_js: ShinyreactJs) -> bool:
|
|
62
|
+
"""Validate ``shinyreact_js=`` and say whether the page attaches the bundle.
|
|
63
|
+
|
|
64
|
+
The one place the value is checked, so every entry point rejects a typo the
|
|
65
|
+
same way. A bad value is a startup error rather than a page that silently
|
|
66
|
+
loads no hooks.
|
|
67
|
+
"""
|
|
68
|
+
if shinyreact_js not in _SHINYREACT_JS_VALUES:
|
|
69
|
+
expected = ", ".join(repr(v) for v in _SHINYREACT_JS_VALUES)
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"shinyreact_js={shinyreact_js!r} is not valid. Expected one of "
|
|
72
|
+
f'{expected}. Use "server" when the shinyreact package should serve '
|
|
73
|
+
"shinyreact.js (the default, and what a no-build app needs), and "
|
|
74
|
+
'"client" when your own bundle imports @posit-dev/shinyreact and '
|
|
75
|
+
"therefore ships its own copy."
|
|
76
|
+
)
|
|
77
|
+
return shinyreact_js == "server"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _dep_page(shinyreact_js: ShinyreactJs = "server") -> TagChild:
|
|
81
|
+
"""Page-level shinyreact dependency: bundle + ``#shinyreact-config`` tag.
|
|
82
|
+
|
|
83
|
+
Use from page entry points (``page_react_html``, ``set_react_page``'s page
|
|
84
|
+
function) — the config tag carries the protocol version on every page and
|
|
85
|
+
the bookmark restore payload when one is active.
|
|
86
|
+
|
|
87
|
+
``shinyreact_js="client"`` omits ``shinyreact.js`` / ``shinyreact.css`` for
|
|
88
|
+
npm-tier pages, whose client bundle ships its own copy. The config tag is
|
|
89
|
+
always emitted: it carries the protocol version and any bookmark restore
|
|
90
|
+
payload.
|
|
91
|
+
"""
|
|
92
|
+
bundle = _dep() if _serves_bundle(shinyreact_js) else None
|
|
93
|
+
return TagList(bundle, _config_script_tag())
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Automatic renderer HTML-dependency discovery for Core mode (#146, #203, #220).
|
|
2
|
+
|
|
3
|
+
Express's ``set_react_page()`` can inline renderer dependencies into the page
|
|
4
|
+
head because the page function runs after the renderers mount. A Core-mode page
|
|
5
|
+
(:func:`page_react`, :func:`page_react_html`) is built before ``server()`` runs,
|
|
6
|
+
so there is nothing to inline. Instead — the same design R uses
|
|
7
|
+
(``pkg-r/R/dep-discovery.R``) — after every reactive flush we diff the session's
|
|
8
|
+
registered outputs, extract each new output's UI, and push any not-yet-sent
|
|
9
|
+
dependencies to the client as a ``shinyreact-deps`` custom message. The JS
|
|
10
|
+
bundle loads them and re-runs ``Shiny.bindAll()``.
|
|
11
|
+
|
|
12
|
+
Diffing on *every* flush (not just the first) also covers outputs registered
|
|
13
|
+
after startup — e.g. a module server mounted inside an observer.
|
|
14
|
+
|
|
15
|
+
The hook: the JS bundle sends one ``.shinyreact_init`` ping (type
|
|
16
|
+
``shinyreact.init``) after Shiny initializes; that type's input handler
|
|
17
|
+
(``_input_handler.py``) calls :func:`install_dep_discovery`. Every session gets
|
|
18
|
+
exactly one ping, whether or not the app has any other inputs.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
24
|
+
|
|
25
|
+
from htmltools import HTMLDependency, Tag, TagList
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from shiny.session import Session
|
|
29
|
+
|
|
30
|
+
_INSTALLED_FLAG = "_shinyreact_dep_discovery"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def install_dep_discovery(session: Session | None) -> bool:
|
|
34
|
+
"""Install the per-session flush hook. Returns whether it was installed."""
|
|
35
|
+
if session is None:
|
|
36
|
+
return False
|
|
37
|
+
# `_outputs` and `on_flushed` are missing on mock/express-stub sessions;
|
|
38
|
+
# discovery no-ops there rather than raising.
|
|
39
|
+
outputs = getattr(getattr(session, "output", None), "_outputs", None)
|
|
40
|
+
if outputs is None or not callable(getattr(session, "on_flushed", None)):
|
|
41
|
+
return False
|
|
42
|
+
# Two copies of the JS bundle on one page send two pings; install once.
|
|
43
|
+
# (py-shiny has no `session$userData` equivalent, so: an attribute.)
|
|
44
|
+
if getattr(session, _INSTALLED_FLAG, False):
|
|
45
|
+
return False
|
|
46
|
+
setattr(session, _INSTALLED_FLAG, True)
|
|
47
|
+
|
|
48
|
+
seen_outputs: set[str] = set()
|
|
49
|
+
sent_deps: set[str] = set()
|
|
50
|
+
|
|
51
|
+
async def push_new_output_deps() -> None:
|
|
52
|
+
new_names = [name for name in outputs if name not in seen_outputs]
|
|
53
|
+
if not new_names:
|
|
54
|
+
return
|
|
55
|
+
seen_outputs.update(new_names)
|
|
56
|
+
|
|
57
|
+
deps: list[HTMLDependency] = []
|
|
58
|
+
for name in new_names:
|
|
59
|
+
ui = outputs[name].renderer.auto_output_ui()
|
|
60
|
+
if isinstance(ui, (Tag, TagList)):
|
|
61
|
+
deps.extend(ui.tagify().get_dependencies())
|
|
62
|
+
deps = [d for d in deps if f"{d.name}@{d.version}" not in sent_deps]
|
|
63
|
+
if not deps:
|
|
64
|
+
return
|
|
65
|
+
sent_deps.update(f"{d.name}@{d.version}" for d in deps)
|
|
66
|
+
|
|
67
|
+
# `_process_ui()` registers each dep's resource route with the app and
|
|
68
|
+
# returns the client-side JSON (Python's `createWebDependency()`). The
|
|
69
|
+
# client skips deps already on the page, so overlap is harmless.
|
|
70
|
+
payload = session._process_ui(TagList(*deps))["deps"]
|
|
71
|
+
await session.send_custom_message(
|
|
72
|
+
"shinyreact-deps", cast("dict[str, object]", payload)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
session.on_flushed(cast(Any, push_new_output_deps), once=False)
|
|
76
|
+
return True
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Built-in shinyreact input handlers, registered on import.
|
|
2
|
+
|
|
3
|
+
See docs/superpowers/specs/2026-06-04-shinyreact-default-input-handler-design.md.
|
|
4
|
+
|
|
5
|
+
Python's deserializer never simplifies the way R's does (an array of objects is
|
|
6
|
+
already a list of dicts), so both handlers are no-ops here. They exist so that
|
|
7
|
+
the ``:shinyreact.default`` / ``:shinyreact.asis`` wire suffixes do not raise
|
|
8
|
+
"No input handler registered for type" on the Python server.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from shiny.input_handler import input_handlers
|
|
16
|
+
|
|
17
|
+
from ._dep_discovery import install_dep_discovery
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from shiny.module import ResolvedId
|
|
21
|
+
from shiny.session import Session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# force=True so re-importing this module (e.g. importlib.reload during dev or
|
|
25
|
+
# tests) re-registers idempotently instead of raising "already registered".
|
|
26
|
+
@input_handlers.add("shinyreact.default", force=True)
|
|
27
|
+
def _shinyreact_default(value: Any, name: ResolvedId, session: Session) -> Any:
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@input_handlers.add("shinyreact.asis", force=True)
|
|
32
|
+
def _shinyreact_asis(value: Any, name: ResolvedId, session: Session) -> Any:
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# The JS bundle sends one `.shinyreact_init:shinyreact.init` ping per session
|
|
37
|
+
# after Shiny initializes (pkg-js/src/dep-discovery.ts); the handler bootstraps
|
|
38
|
+
# automatic output dependency discovery, matching R (issue #220).
|
|
39
|
+
@input_handlers.add("shinyreact.init", force=True)
|
|
40
|
+
def _shinyreact_init(value: Any, name: ResolvedId, session: Session) -> Any:
|
|
41
|
+
install_dep_discovery(session)
|
|
42
|
+
return value
|