Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/skills/release-shinyreact/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<version>.<bundle mtime in whole seconds>`, 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)
Expand Down
36 changes: 29 additions & 7 deletions pkg-py/src/shinyreact/_dep.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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.<mtime>/`` 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"},
Expand Down
54 changes: 37 additions & 17 deletions pkg-py/tests/test_dep.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import warnings
from pathlib import Path
from typing import cast
Expand All @@ -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
Expand All @@ -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-<version>/`` 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 ``<version>.<mtime>``.

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():
Expand Down Expand Up @@ -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"
42 changes: 37 additions & 5 deletions pkg-r/R/dep.R
Original file line number Diff line number Diff line change
Expand Up @@ -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.<mtime>/` 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.
Expand Down
38 changes: 33 additions & 5 deletions pkg-r/tests/testthat/test-dep.R
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>/` 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)
})