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/_page.py ADDED
@@ -0,0 +1,656 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import warnings
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, Callable, cast
7
+
8
+ from htmltools import HTML, HTMLDependency, Tag, TagChild, TagList
9
+ from shiny.express.ui import page_opts
10
+ from shiny.render.renderer import Renderer
11
+ from shiny.session import get_current_session
12
+ from shiny.ui import page_html
13
+
14
+ from ._app import SRC_DIR_ATTR
15
+ from ._bookmark import _config_script_tag
16
+ from ._dep import ShinyreactJs, _dep, _dep_page, _file_mtime_int, _serves_bundle
17
+
18
+ if TYPE_CHECKING:
19
+ # Private, but it is the only name for HTMLDependency's stylesheet entry.
20
+ from htmltools._core import ScriptItem, StylesheetItem
21
+
22
+ # The class `page_html()` returns. `App(ui=)` accepts it but not its
23
+ # `HTMLTextDocument` base, so declaring the base as our return type would
24
+ # make the documented `App(page_react_html(...), server)` fail to
25
+ # type-check. py-shiny exports the function but not the class -- hence the
26
+ # private import, kept type-only so a rename upstream is a pyright error,
27
+ # not an ImportError at app startup.
28
+ from shiny.ui._page import PageHtmlDocument
29
+
30
+
31
+ def page_bare(
32
+ *args: TagChild,
33
+ title: str | None = None,
34
+ lang: str = "en",
35
+ **kwargs: Any,
36
+ ) -> Tag:
37
+ """Create a bare HTML page with only Shiny dependencies.
38
+
39
+ This is the escape hatch for fully custom setups that don't need the
40
+ shinyreact JS/CSS. It wraps ``shiny.ui.page_bootstrap()`` with minimal
41
+ defaults.
42
+
43
+ Pass :class:`~htmltools.HTMLDependency` objects as positional arguments to
44
+ include them in the page — Shiny automatically hoists them to ``<head>``.
45
+
46
+ With no ``theme=``, the page carries **no Bootstrap**: only jQuery, Shiny's
47
+ own JS/CSS, and a ``width=device-width`` viewport meta tag. Shiny's own
48
+ default would attach Bootstrap, and in the ui.tsx pattern the client owns
49
+ styling. Pass a ``theme=`` — e.g. ``shiny.ui.Theme()`` — to get Bootstrap
50
+ back; then everything is a plain passthrough to
51
+ :func:`shiny.ui.page_bootstrap`.
52
+
53
+ Args:
54
+ *args: Child tags or HTMLDependency objects to include in the page.
55
+ title: Page title.
56
+ lang: HTML ``lang`` attribute.
57
+ **kwargs: Forwarded to :func:`shiny.ui.page_bootstrap` — its own
58
+ ``theme=``, or tag attributes for the page. Deliberately not
59
+ surfaced as named parameters: in the ui.tsx pattern the client owns
60
+ styling, so Bootstrap theming is a passthrough, not part of this
61
+ API.
62
+ """
63
+ from shiny.ui import head_content, page_bootstrap, tags
64
+
65
+ if kwargs.get("theme") is not None:
66
+ return page_bootstrap(*args, title=title, lang=lang, **kwargs)
67
+
68
+ # No theme: build the page ourselves rather than let page_bootstrap()
69
+ # attach Bootstrap (#285). It excludes Shiny's own CSS on the assumption
70
+ # that the Bootstrap CSS bundles it, so ask for it explicitly here.
71
+ from shiny.html_dependencies import jquery_deps, shiny_deps
72
+
73
+ kwargs.pop("theme", None)
74
+ return tags.html(
75
+ tags.head(tags.title(title) if title else None),
76
+ tags.body(
77
+ jquery_deps(),
78
+ *shiny_deps(),
79
+ # The one thing worth keeping from the Bootstrap dependency:
80
+ # without it a phone renders the page at 980px wide. Via
81
+ # head_content() so it reaches the *document's* <head> -- Shiny
82
+ # nests this page tag inside the document it builds.
83
+ head_content(
84
+ tags.meta(
85
+ name="viewport", content="width=device-width, initial-scale=1"
86
+ )
87
+ ),
88
+ *args,
89
+ **kwargs,
90
+ ),
91
+ lang=lang,
92
+ )
93
+
94
+
95
+ def _resolve_react_dirs(
96
+ src_dir: str | Path | None, caller_dir: Path
97
+ ) -> tuple[Path, str]:
98
+ """Resolve ``page_react``'s asset dir and derive the app name.
99
+
100
+ Returns ``(base_dir, app_name)``. ``app_name`` is the app folder's name:
101
+ when the asset dir is the conventional ``www/``, its parent (the app dir)
102
+ names the app; otherwise the asset dir itself does.
103
+ """
104
+ if src_dir is None:
105
+ base_dir = caller_dir / "www"
106
+ else:
107
+ src_dir = Path(src_dir)
108
+ base_dir = src_dir if src_dir.is_absolute() else caller_dir / src_dir
109
+ app_name = base_dir.parent.name if base_dir.name == "www" else base_dir.name
110
+ return base_dir, app_name
111
+
112
+
113
+ def page_react(
114
+ *args: TagChild,
115
+ src_dir: str | Path | None = None,
116
+ js_file: str = "ui.js",
117
+ css_file: str | None = "ui.css",
118
+ title: str | None = None,
119
+ lang: str = "en",
120
+ shinyreact_js: ShinyreactJs = "server",
121
+ **kwargs: Any,
122
+ ) -> Tag:
123
+ """Create a React page from conventional assets — no HTML file required.
124
+
125
+ The zero-configuration page for the ui.tsx pattern: the server emits no
126
+ body HTML at all. It attaches the shinyreact bundle plus your app's entry
127
+ assets, discovered at ``www/ui.js`` and ``www/ui.css`` (relative to the
128
+ calling module). Your JS owns the DOM — create and append your own mount
129
+ container::
130
+
131
+ const root = ReactDOM.createRoot(
132
+ document.body.appendChild(document.createElement("div")),
133
+ );
134
+
135
+ ``ui.js`` is required (a missing file warns, pointing at the resolved
136
+ path); ``ui.css`` is attached only when it exists. Both are served as an
137
+ :class:`~htmltools.HTMLDependency` versioned by ``ui.js``'s mtime, so the
138
+ browser re-fetches after every edit — unlike raw ``<script src=...>``
139
+ tags in a hand-written HTML file, which the browser caches.
140
+
141
+ Args:
142
+ *args: Extra children or :class:`~htmltools.HTMLDependency` objects.
143
+ src_dir: Directory containing the assets. Defaults to ``www/`` next
144
+ to the calling module; relative paths resolve against the caller.
145
+ js_file: JS entry filename within ``src_dir`` (default ``"ui.js"``).
146
+ css_file: CSS filename within ``src_dir`` (default ``"ui.css"``).
147
+ title: Page title. Defaults to the app folder's name (``src_dir``'s
148
+ parent when ``src_dir`` is a ``www/`` dir).
149
+ lang: HTML ``lang`` attribute.
150
+ shinyreact_js: Who supplies ``shinyreact.js`` (and
151
+ ``shinyreact.css``) to the page.
152
+
153
+ - ``"server"`` (default) — the shinyreact package serves them as an
154
+ :class:`~htmltools.HTMLDependency`. What a no-build app needs,
155
+ and what makes ``window.shinyreact`` exist.
156
+ - ``"client"`` — your own bundle imports ``@posit-dev/shinyreact`` and
157
+ ships its own copy, so the server sends nothing. Serving them too
158
+ would put two copies of React and the hooks on one page.
159
+
160
+ The ``#shinyreact-config`` tag is emitted either way — it carries
161
+ the protocol version and any bookmark restore payload.
162
+ **kwargs: Forwarded to :func:`page_bare`, and on to
163
+ :func:`shiny.ui.page_bootstrap`.
164
+ """
165
+ caller_file = sys._getframe(1).f_globals.get("__file__")
166
+ caller_dir = Path(caller_file).parent if caller_file else Path.cwd()
167
+ base_dir, app_name = _resolve_react_dirs(src_dir, caller_dir)
168
+ return page_bare(
169
+ _dep_page(shinyreact_js),
170
+ page_react_dep(
171
+ src_dir=base_dir,
172
+ js_file=js_file,
173
+ css_file=css_file,
174
+ name=app_name,
175
+ ),
176
+ *args,
177
+ title=title if title is not None else app_name,
178
+ lang=lang,
179
+ **kwargs,
180
+ )
181
+
182
+
183
+ def page_react_dep(
184
+ *,
185
+ src_dir: str | Path | None = None,
186
+ js_file: str = "ui.js",
187
+ css_file: str | None = "ui.css",
188
+ name: str | None = None,
189
+ ) -> HTMLDependency:
190
+ """Build an HTMLDependency for a React app's JS and CSS entry points.
191
+
192
+ The JS file's mtime is the dependency version, so the
193
+ ``/lib/<name>-<version>/`` URL changes on every rebuild and the browser
194
+ re-fetches. That is what you want while developing, and the wrong thing for
195
+ a published package — an mtime is whatever the install happened to write, so
196
+ it is neither stable across machines nor meaningful to a reader. There is no
197
+ ``version=`` here on purpose: a package shipping a fixed version should
198
+ build its own :class:`~htmltools.HTMLDependency` (the same advice as for a
199
+ classic, non-module bundle), which is five lines and leaves nothing about
200
+ the dependency implicit.
201
+
202
+ Both the script and the stylesheet are attached only when the file exists
203
+ inside the resolved ``src_dir``, so a bundle that ships no CSS — or that has
204
+ not been built yet — does not emit a tag pointing at a 404. Pass
205
+ ``css_file=None`` to never attach a stylesheet. A missing ``js_file`` warns,
206
+ since it is the entry point and an empty dependency would otherwise fail
207
+ silently.
208
+
209
+ A missing ``src_dir`` **raises** :class:`NotADirectoryError`, where a
210
+ missing ``js_file`` only warns. The asymmetry is not arbitrary: Shiny mounts
211
+ the dependency's source directory as static files, so a directory that does
212
+ not exist is fatal no matter what this function does — the only question is
213
+ whether the author gets Starlette's ``Directory '...' does not exist`` from
214
+ inside ``App.__init__``, or a message naming the argument that chose the
215
+ path. Matches R's ``page_react_dep()``.
216
+
217
+ Path resolution
218
+ ---------------
219
+ The base directory is ``src_dir`` when given. Passing it explicitly is
220
+ recommended for library authors — the inference below reads the *immediate*
221
+ calling frame, so wrapping this function in a helper resolves against the
222
+ wrapper's directory rather than the app's.
223
+
224
+ When ``src_dir`` is omitted it is inferred:
225
+
226
+ 1. **Module call (typical):** when the caller is a regular Python module
227
+ (``__file__`` set), paths resolve against the module's directory. This
228
+ is the expected usage::
229
+
230
+ # /path/to/my-app/app.py
231
+ from shinyreact import page_react_dep
232
+
233
+ dep = page_react_dep(js_file="bundle.js")
234
+ # dep.source["subdir"] == "/path/to/my-app"
235
+ # dep.name == "my-app"
236
+ # version == mtime of /path/to/my-app/bundle.js
237
+
238
+ 2. **REPL / exec'd code (no ``__file__``):** falls back to
239
+ :func:`pathlib.Path.cwd` — the current working directory of the
240
+ process. This matches the convention CLI tools use when resolving
241
+ relative paths::
242
+
243
+ >>> import os, shinyreact
244
+ >>> os.chdir("/path/to/my-app")
245
+ >>> shinyreact.page_react_dep(js_file="bundle.js")
246
+ # source["subdir"] == "/path/to/my-app"
247
+ # name == "my-app"
248
+
249
+ The fallback is deliberate — call from any working directory and you
250
+ get a predictable result. If you need a specific directory regardless
251
+ of CWD, pass ``src_dir``.
252
+
253
+ Args:
254
+ src_dir: Directory containing the JS/CSS. Inferred from the calling
255
+ frame when omitted (see above).
256
+ js_file: Filename of the JS entry point, relative to ``src_dir``
257
+ (default ``"ui.js"``). Attached only if the file exists.
258
+ css_file: Filename of the CSS file, relative to ``src_dir`` (default
259
+ ``"ui.css"``). Attached only if the file exists; ``None`` to skip.
260
+ name: Dependency name. Defaults to ``src_dir``'s basename.
261
+ """
262
+ if src_dir is not None:
263
+ base_dir = Path(src_dir)
264
+ else:
265
+ caller_file = sys._getframe(1).f_globals.get("__file__")
266
+ # If the caller has no __file__ (REPL or dynamically exec'd code),
267
+ # fall back to the current working directory — same convention as
268
+ # most CLI tools resolving relative paths.
269
+ base_dir = Path(caller_file).parent if caller_file else Path.cwd()
270
+ dep_name = name if name is not None else base_dir.name
271
+
272
+ if not base_dir.is_dir():
273
+ # Shiny mounts a dependency's source dir as static files, so this is
274
+ # fatal either way -- but the error it raises is Starlette's
275
+ # "Directory '...' does not exist", from inside App.__init__, naming
276
+ # neither shinyreact nor the argument that chose the path. Fail here
277
+ # instead, where the message can say what to do about it.
278
+ raise NotADirectoryError(
279
+ f"React asset directory not found: {base_dir}. Shiny serves this "
280
+ "directory's files, so it must exist by the time the page is "
281
+ "built. Build the bundle first (its output directory is created "
282
+ "by the build), or pass a different src_dir=."
283
+ )
284
+
285
+ js_path = base_dir / js_file
286
+ mtime = _file_mtime_int(js_path)
287
+ version = str(mtime) if mtime is not None else "0"
288
+
289
+ script: ScriptItem | None = None
290
+ if js_path.exists():
291
+ script = {"src": js_file, "type": "module"}
292
+ else:
293
+ # An empty dependency loads nothing and reports nothing, so say so here
294
+ # — without the tag there is not even a 404 in the console to go on.
295
+ warnings.warn(
296
+ f"JS entry point not found: {js_path}. No script tag will be "
297
+ "emitted. Build the bundle first?",
298
+ stacklevel=2,
299
+ )
300
+
301
+ stylesheet: StylesheetItem | None = None
302
+ if css_file is not None and (base_dir / css_file).exists():
303
+ stylesheet = {"href": css_file}
304
+
305
+ return HTMLDependency(
306
+ name=dep_name,
307
+ version=version,
308
+ source={"subdir": str(base_dir)},
309
+ script=script,
310
+ stylesheet=stylesheet,
311
+ )
312
+
313
+
314
+ # Cache of documents read by page_react_html(), keyed by resolved path. Value is
315
+ # (stat signature, text).
316
+ _DOCUMENT_CACHE: dict[Path, tuple[tuple[int, int], str]] = {}
317
+
318
+
319
+ def _read_document_cached(path: Path) -> str:
320
+ """Read an HTML document, re-reading only when it has changed on disk.
321
+
322
+ ``ReactApp`` makes the UI a per-request function, so ``page_react_html()``
323
+ runs on every page render. Reading the file each time is what lets an author
324
+ edit ``index.html`` and hit refresh — no restart — but re-reading bytes that
325
+ have not changed is pure waste on a page that never changes.
326
+
327
+ So: stat every call, read only when the signature moves. ``st_mtime_ns`` plus
328
+ ``st_size`` is the same heuristic build tools use; a same-nanosecond,
329
+ same-size edit would be missed, which needs a machine fast enough to write
330
+ twice within one filesystem timestamp tick.
331
+
332
+ Not thread-locked deliberately: two concurrent requests may both read the
333
+ file and both store the result, which costs one redundant read and cannot
334
+ produce a wrong answer.
335
+ """
336
+ stat = path.stat()
337
+ signature = (stat.st_mtime_ns, stat.st_size)
338
+ cached = _DOCUMENT_CACHE.get(path)
339
+ if cached is not None and cached[0] == signature:
340
+ return cached[1]
341
+ text = path.read_text(encoding="utf-8")
342
+ _DOCUMENT_CACHE[path] = (signature, text)
343
+ return text
344
+
345
+
346
+ def set_react_page(
347
+ path: str | Path | None = None, *, shinyreact_js: ShinyreactJs = "server"
348
+ ) -> None:
349
+ """Set the page for this Express app to a React app (the ui.tsx pattern).
350
+
351
+ With no arguments, serves ``www/index.html`` when it exists; otherwise
352
+ falls back to :func:`page_react`-style discovery of ``www/ui.js`` /
353
+ ``www/ui.css``, emitting no body HTML at all (your JS appends its own
354
+ mount container to ``<body>``). Passing ``path`` explicitly requires the
355
+ file to exist.
356
+
357
+ When an HTML file is used, it is read once (cached at call time) and used
358
+ as the page body. In both modes, dependencies from traditional Shiny
359
+ renderers (e.g. ``@render.data_frame``) are discovered automatically and
360
+ injected into the page head.
361
+
362
+ Renderers defined inside ``@module.server`` are discovered too: every
363
+ renderer mounted while the app body runs is found via the session's
364
+ registered outputs, so module components load their JS/CSS with no extra
365
+ configuration. Renderers mounted *dynamically after page load* (e.g. a
366
+ module server called inside a ``@reactive.effect``) are not in the initial
367
+ page; when their UI is delivered through Shiny's dynamic-UI path
368
+ (``@render.ui``), Shiny injects their dependencies on render.
369
+
370
+ .. note::
371
+
372
+ Edits to ``index.html`` require restarting the Shiny server — see the
373
+ comment in :func:`_build_react_page_fn` for the upstream Shiny Express
374
+ constraint that prevents per-request re-reads.
375
+
376
+ Path resolution
377
+ ---------------
378
+ ``path`` resolution depends on whether it is absolute or relative:
379
+
380
+ 1. **Absolute path:** used verbatim, regardless of caller or CWD::
381
+
382
+ # /tmp/standalone-app.py
383
+ from shinyreact import set_react_page
384
+ set_react_page("/srv/myapp/www/index.html")
385
+ # → reads /srv/myapp/www/index.html
386
+
387
+ 2. **Relative path from a module (typical):** resolved against the
388
+ caller's module directory (read from the calling frame's
389
+ ``__file__``)::
390
+
391
+ # /path/to/my-app/app.py
392
+ from shinyreact import set_react_page
393
+ set_react_page() # → /path/to/my-app/www/index.html
394
+ set_react_page("static/index.html") # → /path/to/my-app/static/index.html
395
+
396
+ This is the expected usage for ``shiny run app.py``.
397
+
398
+ 3. **Relative path with no caller ``__file__`` (REPL / exec'd code):**
399
+ falls back to :func:`pathlib.Path.cwd` — the current working
400
+ directory of the process. Same convention CLI tools use for relative
401
+ paths::
402
+
403
+ >>> import os, shinyreact
404
+ >>> os.chdir("/path/to/my-app")
405
+ >>> shinyreact.set_react_page() # → /path/to/my-app/www/index.html
406
+ >>> shinyreact.set_react_page("a.html") # → /path/to/my-app/a.html
407
+
408
+ The fallback is deliberate — call from any working directory and you
409
+ get a predictable result. If you need a specific path regardless of
410
+ CWD, pass an absolute path (case 1).
411
+
412
+ Args:
413
+ path: Path to the HTML file. Absolute paths are used verbatim;
414
+ relative paths resolve against the caller module's directory,
415
+ or against ``Path.cwd()`` when there is no caller ``__file__``.
416
+ When ``None`` (the default), uses ``www/index.html`` if it
417
+ exists, else discovers ``www/ui.js`` / ``www/ui.css``.
418
+ shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css``:
419
+ ``"server"`` (default) or ``"client"`` for an npm-tier app whose
420
+ bundle imports ``@posit-dev/shinyreact`` — see :func:`page_react`.
421
+ """
422
+ # Validate now rather than at first page render: a typo should fail at
423
+ # startup, next to the call that made it.
424
+ _serves_bundle(shinyreact_js)
425
+ caller_file = sys._getframe(1).f_globals.get("__file__")
426
+ # If the caller has no __file__ (REPL or dynamically exec'd code),
427
+ # fall back to the current working directory.
428
+ caller_dir = Path(caller_file).parent if caller_file else Path.cwd()
429
+
430
+ if path is None:
431
+ index_path = caller_dir / "www" / "index.html"
432
+ if not index_path.exists():
433
+ page_opts(
434
+ page_fn=_build_react_page_fn_discovered(caller_dir, shinyreact_js)
435
+ )
436
+ return
437
+ else:
438
+ path = Path(path)
439
+ index_path = path if path.is_absolute() else caller_dir / path
440
+ page_opts(page_fn=_build_react_page_fn(index_path, shinyreact_js))
441
+
442
+
443
+ def page_react_html(
444
+ path: str | Path = "www/index.html",
445
+ *,
446
+ extra_deps: list[HTMLDependency] | None = None,
447
+ shinyreact_js: ShinyreactJs = "server",
448
+ ) -> PageHtmlDocument:
449
+ """Serve a React ``index.html`` document (the ui.tsx pattern, Core API).
450
+
451
+ Reads a complete HTML document — the kind a Vite build emits — and injects
452
+ Shiny's and shinyreact's dependencies into it. The document must contain
453
+ Shiny's dependency placeholder in ``<head>``::
454
+
455
+ <meta name="shiny-dependency-placeholder" content="">
456
+
457
+ The script/link tags render in its place; the same literal is
458
+ py-shiny's own ``DEPS_PLACEHOLDER``. It is an ordinary ``<meta>``
459
+ tag rather than template syntax, so the document stays valid HTML that a
460
+ bundler's dev server can serve unchanged. Matches R's ``page_react_html()``.
461
+
462
+ You rarely need to call this yourself — :class:`shinyreact.ReactApp`
463
+ discovers ``www/index.html`` and calls it for you::
464
+
465
+ from shinyreact import ReactApp
466
+
467
+ app = ReactApp(server)
468
+
469
+ Call it directly to pass a non-default path (``ReactApp(server,
470
+ ui=page_react_html("client/index.html"))``). ``shiny.App`` works too (via
471
+ ``ui.page_html()``, py-shiny#2475), but only ``ReactApp`` mounts the
472
+ document's directory at ``/``, so the assets the document references
473
+ (your bundle's JS/CSS) are served when they live next to it
474
+ (conventionally ``www/``).
475
+
476
+ For apps that don't need to own the HTML document, prefer
477
+ :func:`page_react` — it requires no HTML file at all and works with plain
478
+ ``shiny.App``.
479
+
480
+ Args:
481
+ path: Path to the HTML document. Absolute paths are used verbatim;
482
+ relative paths resolve against the caller module's directory, or
483
+ against :func:`pathlib.Path.cwd` when there is no caller
484
+ ``__file__``. Defaults to ``"www/index.html"``.
485
+ extra_deps: Additional :class:`~htmltools.HTMLDependency` objects to
486
+ render at the placeholder. A full document has no tag tree to
487
+ attach dependencies to, so this is the only way in — the
488
+ counterpart of :func:`page_react`'s positional ``*args``. They
489
+ render *after* Shiny's and shinyreact's, so they can rely on
490
+ ``window.shinyreact`` existing.
491
+ shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css``:
492
+ ``"server"`` (default) or ``"client"`` for an npm-tier app whose
493
+ bundle imports ``@posit-dev/shinyreact`` — see :func:`page_react`.
494
+ """
495
+ path = Path(path)
496
+ if path.is_absolute():
497
+ index_path = path
498
+ else:
499
+ caller_file = sys._getframe(1).f_globals.get("__file__")
500
+ # If the caller has no __file__ (REPL or dynamically exec'd code),
501
+ # fall back to the current working directory.
502
+ caller_dir = Path(caller_file).parent if caller_file else Path.cwd()
503
+ index_path = caller_dir / path
504
+ if not index_path.exists():
505
+ raise FileNotFoundError(f"HTML file not found: {index_path}")
506
+ # ui.page_html() (py-shiny#2475) owns the placeholder: it prefixes Shiny's
507
+ # own dependencies and raises at render time when the document has no
508
+ # placeholder to insert them at. We only add shinyreact's bundle and the
509
+ # #shinyreact-config tag.
510
+ doc = page_html(
511
+ _read_document_cached(index_path),
512
+ extra_deps=[
513
+ *([_dep()] if _serves_bundle(shinyreact_js) else []),
514
+ _config_script_tag(),
515
+ *(extra_deps or []),
516
+ ],
517
+ )
518
+ # Tagged, not subclassed: py-shiny does not export the class publicly.
519
+ # ReactApp reads this to mount the document's directory at "/".
520
+ setattr(doc, SRC_DIR_ATTR, index_path.parent)
521
+ return doc
522
+
523
+
524
+ def _collect_renderer_deps(renderer: Renderer, deps: list[HTMLDependency]) -> None:
525
+ """Append a renderer's output-UI dependencies to ``deps``.
526
+
527
+ Calls ``.tagify()`` first so dependencies that only materialize during
528
+ tagification are resolved (a bare ``get_dependencies()`` on the untagified
529
+ UI can miss them). The page function runs under the Express stub session,
530
+ whose ``_process_ui`` is a no-op, so tagify — not ``session._process_ui`` —
531
+ is the correct resolver here; the resolved deps are emitted into the page
532
+ TagList, and Shiny registers their file routes when it renders the page.
533
+ """
534
+ ui = renderer.auto_output_ui()
535
+ if isinstance(ui, (Tag, TagList)):
536
+ deps.extend(ui.tagify().get_dependencies())
537
+
538
+
539
+ def _harvest_renderer_deps(args: tuple[Any, ...]) -> list[HTMLDependency]:
540
+ """Collect HTMLDependencies from Express renderers for the page head.
541
+
542
+ Looks at the top-level renderers Shiny Express hands to the page function,
543
+ plus every renderer registered on the active session — including those
544
+ defined inside ``@module.server``, which ``args`` never sees (issue #87).
545
+ At the tagify pass the stub session already holds every synchronously
546
+ mounted renderer in ``output._outputs``.
547
+ """
548
+ deps: list[HTMLDependency] = []
549
+ for arg in args:
550
+ if isinstance(arg, Renderer):
551
+ _collect_renderer_deps(arg, deps)
552
+ session = get_current_session()
553
+ if session is not None:
554
+ # `_outputs` is private; Shiny exposes no public API to iterate
555
+ # registered outputs.
556
+ for info in session.output._outputs.values():
557
+ _collect_renderer_deps(info.renderer, deps)
558
+ return deps
559
+
560
+
561
+ # The `page_opts()` options a React page can honor, mapped onto page_bare()'s
562
+ # (i.e. page_bootstrap()'s) parameters. Everything else page_auto() might
563
+ # forward describes a Bootstrap layout a bare React page does not have.
564
+ _SUPPORTED_PAGE_OPTS = ("title", "lang", "theme")
565
+
566
+
567
+ def _react_page_opts(kwargs: dict[str, Any], *, mode: str) -> dict[str, Any]:
568
+ """Validate the options ``page_auto()`` forwards to our page function.
569
+
570
+ ``page_opts()`` records its arguments and ``page_auto()`` splats them into
571
+ whatever ``page_fn`` it resolved — ours. So a page function taking only
572
+ ``*args`` raises ``TypeError: ... unexpected keyword argument 'title'`` from
573
+ inside a private local, which tells an author nothing about what to do.
574
+ Accept what the page can express, and name the rest.
575
+ """
576
+ unsupported = [k for k in kwargs if k not in _SUPPORTED_PAGE_OPTS]
577
+ if unsupported:
578
+ supported = ", ".join(_SUPPORTED_PAGE_OPTS)
579
+ raise TypeError(
580
+ f"page_opts({unsupported[0]}=...) is not supported by "
581
+ f"set_react_page()'s {mode}: a React page has no Bootstrap layout "
582
+ f"to apply it to. Supported page options: {supported}."
583
+ )
584
+ return {k: v for k, v in kwargs.items() if v is not None}
585
+
586
+
587
+ def _build_react_page_fn_discovered(
588
+ app_dir: Path, shinyreact_js: ShinyreactJs = "server"
589
+ ) -> Callable[..., Tag]:
590
+ """Express page function for the no-HTML-file mode.
591
+
592
+ Serves a :func:`page_react` page from ``app_dir/www`` with the same
593
+ renderer-dependency discovery as the index.html mode.
594
+ """
595
+
596
+ def _react_page_fn(*args: Any, **kwargs: Any) -> Tag:
597
+ # `title` here is a default, so `page_opts(title=...)` overrides it.
598
+ opts = {"title": app_dir.name, **_react_page_opts(kwargs, mode="page")}
599
+ return page_react(
600
+ *_harvest_renderer_deps(args),
601
+ src_dir=app_dir / "www",
602
+ shinyreact_js=shinyreact_js,
603
+ **opts,
604
+ )
605
+
606
+ return _react_page_fn
607
+
608
+
609
+ def _build_react_page_fn(
610
+ index_path: Path, shinyreact_js: ShinyreactJs = "server"
611
+ ) -> Callable[..., Tag]:
612
+ if not index_path.exists():
613
+ raise FileNotFoundError(f"HTML file not found: {index_path}")
614
+
615
+ # `index.html` is read once at construction time and closed over.
616
+ # See issue #82 (https://github.com/posit-dev/shinyreact/issues/82) for
617
+ # why a per-request re-read can't be implemented from inside this package
618
+ # alone:
619
+ #
620
+ # Shiny Express's `shiny/express/_run.py` calls `run_express(...).tagify()`
621
+ # ONCE at app startup. The resulting `app_ui` is a static `RenderedHTML`
622
+ # whose bytes are served verbatim for every `/` request (see
623
+ # `shiny/_app.py` around `if callable(self.ui): ... else: ui = self.ui`).
624
+ # Express only wraps `app_ui` in a per-request callable when
625
+ # `app_opts(bookmark_store=...)` is set to something other than `"disable"`
626
+ # — the only knob exposed today that flips static → callable.
627
+ #
628
+ # So this closure could re-read on mtime change all it wants; it's only
629
+ # invoked once. A real fix needs an upstream py-shiny change adding an
630
+ # opt-in for per-request `app_ui` independent of bookmarking. Until then,
631
+ # editing `www/index.html` requires restarting the Shiny server.
632
+ # Explicit encoding: the default follows the platform locale, so a document
633
+ # with non-ASCII content would decode differently on a machine whose locale
634
+ # is not UTF-8. R's page_react_html() decodes UTF-8 unconditionally.
635
+ index_html = index_path.read_text(encoding="utf-8")
636
+
637
+ def _react_page_fn(*args: Any, **kwargs: Any) -> Tag:
638
+ if kwargs:
639
+ # This mode emits no page tag at all — the document's own HTML is
640
+ # the body — so there is nothing for title/lang/theme to land on.
641
+ # Raise instead of ignoring, and instead of the bare TypeError that
642
+ # page_auto()'s splat used to produce from inside this local.
643
+ raise TypeError(
644
+ f"page_opts({next(iter(kwargs))}=...) is not supported by "
645
+ "set_react_page()'s HTML-file mode, which emits the document "
646
+ "as the page body and no page tag of its own. Put it in the "
647
+ "HTML, or use the no-HTML-file mode, which supports "
648
+ f"{', '.join(_SUPPORTED_PAGE_OPTS)}."
649
+ )
650
+ deps = _harvest_renderer_deps(args)
651
+ # Shiny de-duplicates dependencies by name+version when hoisting to
652
+ # <head>, so any overlap between the harvest passes is harmless.
653
+ # page_opts types page_fn as -> Tag, but TagList works at runtime
654
+ return cast(Tag, TagList(_dep_page(shinyreact_js), *deps, HTML(index_html)))
655
+
656
+ return _react_page_fn
@@ -0,0 +1,17 @@
1
+ """The wire-protocol version this server speaks.
2
+
3
+ Rendered into every page via the ``#shinyreact-config`` JSON script tag
4
+ (see ``_bookmark.py``); the JS client asserts the major versions match at
5
+ boot. The protocol covers every shape that crosses the client/server
6
+ boundary; ``protocol/surface.json`` enumerates them and a test in each
7
+ language enforces it. Only changes an existing peer would misinterpret bump
8
+ this version — additive shapes do not (see ``protocol/README.md``) — so
9
+ client and server package releases do not need to be in lockstep.
10
+
11
+ Decided in ``decisions/2026-08-17-js-distribution.md``. Mirrors
12
+ ``PROTOCOL_VERSION`` in ``pkg-js/src/shiny-react/config.ts`` and
13
+ ``pkg-r/R/protocol.R``; a parity test in each language asserts all three
14
+ match.
15
+ """
16
+
17
+ PROTOCOL_VERSION = "1.0"