From 48f4f519f65f1f176ccdd61f060ae7e48bfc70ae Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Sat, 12 Sep 2026 18:57:43 -0400 Subject: [PATCH] fix: hardcode the shinyreact dep version per package --- .claude/skills/release-shinyreact/SKILL.md | 5 +- FEATURES.md | 15 ++++-- pkg-py/src/shinyreact/_dep.py | 36 ++++++++++++--- pkg-py/tests/test_dep.py | 54 +++++++++++++++------- pkg-r/R/dep.R | 42 +++++++++++++++-- pkg-r/tests/testthat/test-dep.R | 38 +++++++++++++-- 6 files changed, 150 insertions(+), 40 deletions(-) diff --git a/.claude/skills/release-shinyreact/SKILL.md b/.claude/skills/release-shinyreact/SKILL.md index b321b654..cf7af2e2 100644 --- a/.claude/skills/release-shinyreact/SKILL.md +++ b/.claude/skills/release-shinyreact/SKILL.md @@ -60,7 +60,10 @@ build that uploads a pending tarball; a human then approves it with 2FA. Two steps, and the second one is not yours to do. 1. Bump `version` in `pkg-js/package.json` (`cd pkg-js && npm version 1.2.3 - --no-git-tag-version` also updates `package-lock.json`). Open a PR, merge it. + --no-git-tag-version` also updates `package-lock.json`), and the matching + constants `_SHINYREACT_JS_VERSION` in `pkg-py/src/shinyreact/_dep.py` and + `.shinyreact_js_version` in `pkg-r/R/dep.R` (the shinyreact HTMLDependency + version; `test_dep.py` / `test-dep.R` fail on drift). Open a PR, merge it. 2. `git tag js/v1.2.3 && git push origin js/v1.2.3`. 3. `release-js.yaml` verifies the tag matches `package.json`, lints, tests, builds (IIFE + npm ESM + types), and runs `npm stage publish`. Nothing is on diff --git a/FEATURES.md b/FEATURES.md index dc4ed99b..ffe8958d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1176,11 +1176,16 @@ initial page. ## Asset delivery - the shinyreact bundle ships as one `HTMLDependency` named `shinyreact` - - its version is the bundle file's mtime in whole seconds, so browsers - re-fetch after a `make update-dist` - - the version fallback when the bundle file is missing differs - - `[py]` the literal `"0.1.0"` - - `[r]` `packageVersion("shinyreact")` + - its version is `@posit-dev/shinyreact`'s own version, a hardcoded constant + in each package, so `/lib/shinyreact-0.1.1/` names the JS release being + served `(test)` + - the constant must equal `pkg-js/package.json`'s `version`; a test in + each package fails on drift when run from the repo checkout `(test)` + - in a repo checkout (editable install / `load_all()`, detected by the + repo's `pkg-js/package.json` being reachable from the package) the + version is `.`, so + `make update-dist` still cache-busts `(test)` + - an installed package never carries the mtime suffix `(test)` - the script is `shinyreact.js` with a `defer` attribute - the stylesheet is `shinyreact.css`, attached unconditionally (no existence check) diff --git a/pkg-py/src/shinyreact/_dep.py b/pkg-py/src/shinyreact/_dep.py index c01e5b58..18ed1afe 100644 --- a/pkg-py/src/shinyreact/_dep.py +++ b/pkg-py/src/shinyreact/_dep.py @@ -6,7 +6,19 @@ from ._bookmark import _config_script_tag _WWW_DIR = Path(__file__).parent / "www" -_SHINYREACT_JS_PATH = _WWW_DIR / "shinyreact.js" + +# `@posit-dev/shinyreact`'s version, i.e. the release of the bundle in `www/`. +# It is the shinyreact HTMLDependency's version, so +# `/lib/shinyreact-0.1.1/shinyreact.js` names the JS release being served and +# an npm-tier app can read the page source to see whether its bundled copy +# matches the server's. Bump alongside `pkg-js/package.json`; `test_dep.py` +# pins the two together. Mirrors R's `.shinyreact_js_version`. +_SHINYREACT_JS_VERSION = "0.1.1" + +# Only reachable from a repo checkout (editable install), never from an +# installed wheel. Its presence is the "dev checkout" signal for +# `_bundle_version()`. +_JS_PACKAGE_JSON = Path(__file__).parents[3] / "pkg-js" / "package.json" # Who supplies shinyreact.js (and shinyreact.css) to the page. ShinyreactJs = Literal["server", "client"] @@ -21,15 +33,25 @@ def _file_mtime_int(path: Path) -> int | None: return None +def _bundle_version() -> str: + """``_SHINYREACT_JS_VERSION``, suffixed with the bundle's mtime in a dev checkout. + + An installed package serves ``/lib/shinyreact-0.1.1/``. In the repo + checkout it is ``/lib/shinyreact-0.1.1./`` instead, so a + ``make update-dist`` still cache-busts while the URL still names the release. + Mirrors R's ``.bundle_version()``. + """ + if _JS_PACKAGE_JSON.is_file(): + mtime = _file_mtime_int(_WWW_DIR / "shinyreact.js") + if mtime is not None: + return f"{_SHINYREACT_JS_VERSION}.{mtime}" + return _SHINYREACT_JS_VERSION + + def _dep() -> HTMLDependency: - # Use the bundle's mtime as the version so browsers re-fetch after a - # `make update-dist`. Falls back to a fixed version if the bundle is - # missing (e.g. in a partially-built dev checkout). - mtime = _file_mtime_int(_SHINYREACT_JS_PATH) - version = str(mtime) if mtime is not None else "0.1.0" return HTMLDependency( name="shinyreact", - version=version, + version=_bundle_version(), source={"subdir": str(_WWW_DIR)}, script={"src": "shinyreact.js", "defer": ""}, stylesheet={"href": "shinyreact.css"}, diff --git a/pkg-py/tests/test_dep.py b/pkg-py/tests/test_dep.py index 874bef11..b942f783 100644 --- a/pkg-py/tests/test_dep.py +++ b/pkg-py/tests/test_dep.py @@ -1,3 +1,4 @@ +import json import warnings from pathlib import Path from typing import cast @@ -6,9 +7,12 @@ import shinyreact._dep as _dep_mod from htmltools import HTMLDependency from htmltools._core import HTMLDependencySource, ScriptItem, StylesheetItem -from shinyreact._dep import _SHINYREACT_JS_PATH, _dep +from shinyreact._dep import _SHINYREACT_JS_VERSION, _WWW_DIR, _dep from shinyreact._page import page_react_dep +# pkg-js/package.json, when running from the repo checkout. +JS_PACKAGE_JSON = Path(__file__).parents[2] / "pkg-js" / "package.json" + # `HTMLDependency` normalizes `script=` / `stylesheet=` to a list and `source=` # to one of two TypedDicts, but the attributes stay declared as the wider @@ -33,16 +37,41 @@ def first_stylesheet(dep: HTMLDependency) -> StylesheetItem: return stylesheet[0] if isinstance(stylesheet, list) else stylesheet -def test_dep_version_tracks_bundle_mtime(): - """The shinyreact HTMLDependency version reflects the bundle's mtime. +def test_dep_version_is_the_js_package_version(monkeypatch: pytest.MonkeyPatch): + """Installed, the shinyreact HTMLDependency version is ``@posit-dev/shinyreact``'s. - Cache-busts the browser whenever ``make update-dist`` rewrites the bundle. + So ``/lib/shinyreact-/`` names the JS release being served. + Mirrors R's "shinyreact_dep() versions by the JS package version". """ - assert _SHINYREACT_JS_PATH.exists(), ( - "shinyreact.js missing — run `make update-dist`" + monkeypatch.setattr(_dep_mod, "_JS_PACKAGE_JSON", Path("/nonexistent")) + assert str(_dep().version) == _SHINYREACT_JS_VERSION + + +def test_dep_version_adds_bundle_mtime_in_dev_checkout( + monkeypatch: pytest.MonkeyPatch, +): + """In the repo checkout the version is ``.``. + + So ``make update-dist`` still cache-busts during development. Mirrors R's + "shinyreact_dep() version adds the bundle mtime in a dev checkout". + """ + monkeypatch.setattr(_dep_mod, "_JS_PACKAGE_JSON", Path(__file__)) + mtime = int((_WWW_DIR / "shinyreact.js").stat().st_mtime) + assert str(_dep().version) == f"{_SHINYREACT_JS_VERSION}.{mtime}" + + +def test_js_version_constant_matches_package_json(): + """The hardcoded version is bumped in step with ``pkg-js/package.json``. + + Mirrors R's ".shinyreact_js_version matches pkg-js/package.json". + """ + if not JS_PACKAGE_JSON.is_file(): + pytest.skip("running against an installed package, not the repo") + expected = json.loads(JS_PACKAGE_JSON.read_text())["version"] + assert _SHINYREACT_JS_VERSION == expected, ( + f"_SHINYREACT_JS_VERSION is {_SHINYREACT_JS_VERSION!r} but " + f"pkg-js/package.json is {expected!r} — bump the constant in _dep.py" ) - expected = str(int(_SHINYREACT_JS_PATH.stat().st_mtime)) - assert str(_dep().version) == expected def test_dep_script_has_defer(): @@ -217,12 +246,3 @@ def test_dep_stylesheet_attached_unconditionally() -> None: assert [s["href"] for s in cast("list[StylesheetItem]", dep.stylesheet)] == [ "shinyreact.css" ] - - -def test_dep_version_falls_back_when_bundle_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Deliberate divergence: R falls back to packageVersion("shinyreact"). - # Mirrors R's "shinyreact_dep() version falls back to the package version". - monkeypatch.setattr(_dep_mod, "_SHINYREACT_JS_PATH", Path("/nonexistent/x.js")) - assert str(_dep().version) == "0.1.0" diff --git a/pkg-r/R/dep.R b/pkg-r/R/dep.R index 212e20cf..9f338f9b 100644 --- a/pkg-r/R/dep.R +++ b/pkg-r/R/dep.R @@ -2,13 +2,45 @@ system.file("lib", "shiny", package = "shinyreact") } +# `@posit-dev/shinyreact`'s version, i.e. the release of the bundle in +# `inst/lib/shiny/`. It is the shinyreact htmlDependency's version, so +# `/lib/shinyreact-0.1.1/shinyreact.js` names the JS release being served and +# an npm-tier app can read the page source to see whether its bundled copy +# matches the server's. Bump alongside `pkg-js/package.json`; `test-dep.R` +# pins the two together. Mirrors Python's `_SHINYREACT_JS_VERSION`. +.shinyreact_js_version <- "0.1.1" + +# Internal: TRUE only in a repo checkout (`load_all()`), where the repo's +# `pkg-js/package.json` sits four levels above `inst/lib/shiny/`. Never TRUE +# for an installed package. +.dev_checkout <- function() { + file.exists(file.path( + .www_dir(), + "..", + "..", + "..", + "..", + "pkg-js", + "package.json" + )) +} + +# Internal: `.shinyreact_js_version`, suffixed with the bundle's mtime in a +# dev checkout. Installed, a page loads `/lib/shinyreact-0.1.1/`; in the repo +# it loads `/lib/shinyreact-0.1.1./` instead, so `make update-dist` +# still cache-busts while the URL still names the release. Mirrors Python's +# `_bundle_version()`. .bundle_version <- function() { - js <- file.path(.www_dir(), "shinyreact.js") - mtime <- suppressWarnings(file.mtime(js)) - if (length(mtime) == 1L && !is.na(mtime)) { - return(as.character(as.integer(mtime))) + if (.dev_checkout()) { + mtime <- suppressWarnings(file.mtime(file.path( + .www_dir(), + "shinyreact.js" + ))) + if (length(mtime) == 1L && !is.na(mtime)) { + return(paste0(.shinyreact_js_version, ".", as.integer(mtime))) + } } - as.character(utils::packageVersion("shinyreact")) + .shinyreact_js_version } # Internal: bare bundle dependency for per-output consumers. diff --git a/pkg-r/tests/testthat/test-dep.R b/pkg-r/tests/testthat/test-dep.R index 86000356..52fb7f43 100644 --- a/pkg-r/tests/testthat/test-dep.R +++ b/pkg-r/tests/testthat/test-dep.R @@ -68,12 +68,40 @@ test_that("shinyreact_dep() attaches the stylesheet unconditionally", { expect_identical(unname(unlist(dep$stylesheet)), "shinyreact.css") }) -test_that("shinyreact_dep() version falls back to the package version", { - # Deliberate divergence: Python falls back to the literal "0.1.0". - # Mirrors Python's test_dep_version_tracks_bundle_mtime. - local_mocked_bindings(.www_dir = function() withr::local_tempdir()) +test_that("shinyreact_dep() versions by the JS package version", { + # Installed, `/lib/shinyreact-/` names the @posit-dev/shinyreact + # release being served. Mirrors Python's + # test_dep_version_is_the_js_package_version. + local_mocked_bindings(.dev_checkout = function() FALSE) expect_identical( shinyreact:::shinyreact_dep()$version, - as.character(utils::packageVersion("shinyreact")) + shinyreact:::.shinyreact_js_version ) }) + +test_that("shinyreact_dep() version adds the bundle mtime in a dev checkout", { + # So `make update-dist` still cache-busts during development. Mirrors + # Python's test_dep_version_adds_bundle_mtime_in_dev_checkout. + local_mocked_bindings(.dev_checkout = function() TRUE) + js <- file.path(shinyreact:::.www_dir(), "shinyreact.js") + expect_identical( + shinyreact:::shinyreact_dep()$version, + paste0(shinyreact:::.shinyreact_js_version, ".", as.integer(file.mtime(js))) + ) +}) + +test_that(".shinyreact_js_version matches pkg-js/package.json", { + # The hardcoded version is bumped in step with the npm package. Mirrors + # Python's test_js_version_constant_matches_package_json. + package_json <- file.path( + testthat::test_path(), + "..", + "..", + "..", + "pkg-js", + "package.json" + ) + skip_if_not(file.exists(package_json), "not running from the repo") + expected <- jsonlite::read_json(package_json)$version + expect_identical(shinyreact:::.shinyreact_js_version, expected) +})