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
19 changes: 19 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ R has no e2e suite, so no `(e2e)` leaf covers R (issue #194).
reader to upgrade the older side
- equal majors means compatible, so client and server package releases
need not be released in lockstep
- `[js]` every fatal handshake failure is visible on the page, not only in
DevTools (#213)
- before throwing, a fixed banner `<div id="shinyreact-fatal-error"
role="alert">` is appended to `<body>` carrying the same text as the
thrown error (e2e)
- literals in the message (both versions, `#shinyreact-config`,
`@posit/shinyreact`) are marked with backticks and render as `<code>`
chips; the backticks are stripped from the thrown error and the
console-only warning (e2e)
- the message is written with `textContent`, never `innerHTML`, so a
server-supplied version string cannot inject markup
- #7f1d1d on #fee2e2 (~9.5:1) — the banner carries the full sentence, never
color alone
- plain DOM, no dependency on Shiny being initialized — the failure being
reported is that client and server cannot talk
- one element reused across repeated failures; the newest message wins
- the throw is unchanged (fail fast); the banner is additive
- covers all three fatal paths: major mismatch (either direction), missing
tag in the npm build, and a tag with no `protocolVersion` in the npm build
- server → client boot config: one `<script type="application/json"
id="shinyreact-config">` tag
- it lands in `<head>` in every language and on every path
Expand Down
20 changes: 10 additions & 10 deletions pkg-js/dist/shinyreact.js

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions pkg-js/src/shiny-react/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PROTOCOL_VERSION,
assertProtocolCompatible,
readShinyReactConfig,
throwVisibly,
} from "../config";

function setConfigTag(text: string): void {
Expand All @@ -18,9 +19,14 @@ function setConfigTag(text: string): void {

afterEach(() => {
document.getElementById("shinyreact-config")?.remove();
document.getElementById("shinyreact-fatal-error")?.remove();
vi.restoreAllMocks();
});

function bannerText(): string | undefined {
return document.getElementById("shinyreact-fatal-error")?.textContent ?? undefined;
}

describe("readShinyReactConfig", () => {
it("returns null when the tag is absent", () => {
expect(readShinyReactConfig()).toBeNull();
Expand Down Expand Up @@ -60,6 +66,59 @@ describe("assertProtocolCompatible", () => {
});
});

describe("handshake failures are visible on the page", () => {
it("shows a banner when the server major is newer", () => {
expect(() => assertProtocolCompatible("999.0")).toThrow();
expect(bannerText()).toContain("999.0");
expect(bannerText()).toContain(PROTOCOL_VERSION);
});

it("shows a banner when the server major is older", () => {
expect(() => assertProtocolCompatible("0.9")).toThrow();
expect(bannerText()).toContain("0.9");
expect(bannerText()).toContain(PROTOCOL_VERSION);
});

it("reuses one banner element across repeated failures", () => {
expect(() => assertProtocolCompatible("999.0")).toThrow();
expect(() => assertProtocolCompatible("998.0")).toThrow();
expect(document.querySelectorAll("#shinyreact-fatal-error")).toHaveLength(1);
expect(bannerText()).toContain("998.0");
});

it("renders backticked literals as <code> chips", () => {
expect(() => assertProtocolCompatible("999.0")).toThrow();
const chips = Array.from(
document.querySelectorAll("#shinyreact-fatal-error code"),
).map((el) => el.textContent);
expect(chips).toEqual(["999.0", PROTOCOL_VERSION]);
});

it("does not interpret the message as HTML", () => {
expect(() => assertProtocolCompatible("<img src=x onerror=alert(1)>")).toThrow();
const banner = document.getElementById("shinyreact-fatal-error");
expect(banner?.querySelector("img")).toBeNull();
expect(banner?.textContent).toContain("<img src=x onerror=alert(1)>");
});

it("strips the backticks from the thrown message", () => {
expect(() => assertProtocolCompatible("999.0")).toThrowError(
/server speaks protocol 999\.0 but/,
);
});

it("still throws the same message it displays", () => {
let thrown: Error | undefined;
try {
throwVisibly("boom");
} catch (err) {
thrown = err as Error;
}
expect(thrown?.message).toBe("boom");
expect(bannerText()).toBe("boom");
});
});

describe("PROTOCOL_VERSION parity", () => {
it("matches the Python and R declarations", () => {
// PROTOCOL_VERSION is one contract declared in three languages; this
Expand Down
14 changes: 8 additions & 6 deletions pkg-js/src/shiny-react/bookmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
assertProtocolCompatible,
isShinyReactConfigTagRequired,
readShinyReactConfig,
throwVisibly,
} from "./config";

/**
Expand Down Expand Up @@ -41,9 +42,9 @@ export function applyRestoredValues(registry: InputRegistry): void {
// version checking.
const config = readShinyReactConfig();
if (config == null && isShinyReactConfigTagRequired()) {
throw new Error(
"shinyreact: no #shinyreact-config tag found in this page. The " +
"@posit/shinyreact client requires a shinyreact server recent " +
throwVisibly(
"shinyreact: no `#shinyreact-config` tag found in this page. The " +
"`@posit/shinyreact` client requires a shinyreact server recent " +
"enough to emit it — upgrade the shinyreact Python/R package.",
);
}
Expand All @@ -54,15 +55,16 @@ export function applyRestoredValues(registry: InputRegistry): void {
// servers always include it. Previously this silently skipped version
// checking altogether, which is the one thing the tag exists to prevent.
const message =
"shinyreact: the #shinyreact-config tag carries no protocolVersion, so " +
"shinyreact: the `#shinyreact-config` tag carries no `protocolVersion`, so " +
"the client cannot verify it speaks the same protocol as the server. " +
"Upgrade the shinyreact Python/R package.";
if (isShinyReactConfigTagRequired()) {
// The npm client is installed independently of the server, so it cannot
// assume compatibility — same reasoning as a missing tag being fatal.
throw new Error(message);
throwVisibly(message);
}
console.error(message);
// Backticks mark literals for the on-page banner; a console has no chips.
console.error(message.replace(/`/g, ""));
}

// Already applied — preserve the existing snapshot.
Expand Down
59 changes: 57 additions & 2 deletions pkg-js/src/shiny-react/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,60 @@ export function readShinyReactConfig(): ShinyReactConfig | null {
}
}

/**
* Show a handshake failure on the page, then throw it.
*
* The throw alone leaves a blank page whose only explanation is in DevTools —
* the handshake fails during the first hook mount, before anything renders.
* The banner is plain DOM on purpose: the failure being reported is precisely
* "the bundle and the server cannot talk to each other", so the surface must
* not depend on Shiny being alive. Idempotent by element id.
*
* `message` may mark literals (versions, ids, package names) with backticks;
* they render as `<code>` chips on the page and are stripped from the thrown
* Error, whose consumer is a console. Text is set via `textContent`, never
* `innerHTML` — a server-supplied version string must not be able to inject
* markup.
*
* Colors are WCAG AA at minimum: #7f1d1d on #fee2e2 is ~9.5:1, and the chips
* are ~11:1 on white. The banner never relies on color alone — it carries the
* full sentence and `role="alert"`.
*/
export function throwVisibly(message: string): never {
if (typeof document !== "undefined" && document.body) {
const id = "shinyreact-fatal-error";
let existing = document.getElementById(id);
if (!existing) {
existing = document.createElement("div");
existing.id = id;
existing.setAttribute("role", "alert");
existing.style.cssText =
"position:fixed;top:0;left:0;right:0;z-index:99999;padding:1rem 1.25rem;" +
"background:#fee2e2;color:#7f1d1d;border-bottom:4px solid #991b1b;" +
"font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;" +
"white-space:pre-wrap";
document.body.appendChild(existing);
}
const el = existing;
el.textContent = "";
// Odd segments were inside backticks.
message.split("`").forEach((segment, i) => {
if (i % 2 === 0) {
el.appendChild(document.createTextNode(segment));
return;
}
const code = document.createElement("code");
code.textContent = segment;
code.style.cssText =
"background:#fff;color:#7f1d1d;border:1px solid #f0a3a3;border-radius:4px;" +
"padding:0.1em 0.35em;font-family:ui-monospace,SFMono-Regular,monospace;" +
"font-size:0.95em";
el.appendChild(code);
});
}
throw new Error(message.replace(/`/g, ""));
}

/**
* Fail fast when the server's protocol major version disagrees with this
* client's. Same-major means compatible; a mismatch means one side must be
Expand All @@ -48,9 +102,10 @@ export function readShinyReactConfig(): ShinyReactConfig | null {
export function assertProtocolCompatible(serverVersion: string): void {
const major = (v: string) => v.split(".")[0];
if (major(serverVersion) !== major(PROTOCOL_VERSION)) {
throw new Error(
throwVisibly(
`shinyreact protocol mismatch: the server speaks protocol ` +
`${serverVersion} but this JS client supports ${PROTOCOL_VERSION}. ` +
`\`${serverVersion}\` but this JS client supports ` +
`\`${PROTOCOL_VERSION}\`. ` +
`Upgrade the older side (the shinyreact R/Python package, or the ` +
`client bundle) so the major protocol versions match.`,
);
Expand Down
20 changes: 10 additions & 10 deletions pkg-py/src/shinyreact/www/shinyreact.js

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions pkg-py/tests/playwright/apps/protocol_mismatch/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Fixture app whose server claims a protocol major the client cannot speak.

Rewriting the constant `_bookmark.py` reads is the only way to produce a
mismatched `#shinyreact-config` tag from a current server — the two versions
are pinned equal by a parity test. The client should paint the handshake
failure onto the page instead of just leaving it blank (#213).
"""

import shinyreact._bookmark as _bookmark
from shiny.express import render # noqa: F401
from shinyreact import set_react_page

# Expected on screen: a red banner across the top naming both protocol
# versions (999.0 server, whatever the client speaks) and telling the reader
# to upgrade the older side. The app body itself never renders.
_bookmark.PROTOCOL_VERSION = "999.0"

set_react_page()
11 changes: 11 additions & 0 deletions pkg-py/tests/playwright/apps/protocol_mismatch/www/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const { React, ReactDOM, useShinyInput } = window.shinyreact;
const h = React.createElement;

// Any hook mount runs the handshake, which throws here. This text is never
// expected on screen — the fatal banner replaces it.
function App() {
const [txt] = useShinyInput("txt", "");
return h("p", { "data-testid": "body" }, `this should not render: ${txt}`);
}

ReactDOM.createRoot(document.getElementById("root")).render(h(App));
10 changes: 10 additions & 0 deletions pkg-py/tests/playwright/apps/protocol_mismatch/www/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<style>
body {
font-family: system-ui, sans-serif;
margin: 2rem;
max-width: 720px;
line-height: 1.5;
}
</style>
<div id="root"></div>
<script src="app.js" defer></script>
32 changes: 32 additions & 0 deletions pkg-py/tests/playwright/test_protocol_mismatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""A protocol handshake failure must be readable without opening DevTools.

The handshake throws during the first hook mount, so nothing the app renders
survives — before #213 the only artifact was a console message on a blank
page. The fixture app under ``apps/protocol_mismatch/`` claims protocol
``999.0``; the client should paint the mismatch onto the page.
"""

from playwright.sync_api import Page, expect
from shiny.pytest import create_app_fixture
from shiny.run import ShinyAppProc
from shinyreact._protocol import PROTOCOL_VERSION

mismatch_app = create_app_fixture("apps/protocol_mismatch/app.py")


def test_mismatch_paints_a_banner(page: Page, mismatch_app: ShinyAppProc) -> None:
page.goto(mismatch_app.url)

banner = page.locator("#shinyreact-fatal-error")
expect(banner).to_be_visible()
# Both versions and the remedy, matching the thrown error.
expect(banner).to_contain_text("999.0")
expect(banner).to_contain_text(PROTOCOL_VERSION)
expect(banner).to_contain_text("Upgrade the older side")
expect(banner).to_have_attribute("role", "alert")

# Both versions are marked up as <code> so they stand out from the prose.
expect(banner.locator("code")).to_have_text(["999.0", PROTOCOL_VERSION])

# Fail fast is unchanged: the app body never rendered.
expect(page.get_by_test_id("body")).to_have_count(0)
Loading
Loading