diff --git a/README.md b/README.md
index 846c2c36..a39fd1c2 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ browser renderer.
pnpm add @stakekit/widget
```
-React 18 or newer is required when using the component entrypoint.
+React 19 or newer is required when using the component entrypoint.
## React
diff --git a/packages/widget/knip.jsonc b/packages/widget/knip.jsonc
deleted file mode 100644
index dde24b43..00000000
--- a/packages/widget/knip.jsonc
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "$schema": "https://unpkg.com/knip@6/schema-jsonc.json",
- "entry": [
- "src/index.package.ts!",
- "src/index.bundle.ts!",
- "src/public-api/index.package.ts!",
- "src/public-api/index.bundle.ts!",
- "vite/*.ts!",
- "tests/package-types/*.ts",
- "tests/utils/setup.browser.ts",
- "tests/utils/setup.dom.ts",
- "tests/**/*.test.ts",
- "tests/**/*.test.tsx"
- ],
- "project": [
- "src/**/*.{ts,tsx}!",
- "scripts/**/*.{ts,mts}",
- "tests/**/*.{ts,tsx}",
- "vite/**/*.ts!"
- ],
- "ignoreIssues": {
- "src/generated/**": ["exports", "types"]
- },
- "ignoreDependencies": [
- "@effect/language-service",
- "@effect/openapi-generator",
- "@effect/platform-node"
- ]
-}
diff --git a/packages/widget/knip.ts b/packages/widget/knip.ts
new file mode 100644
index 00000000..7b19760c
--- /dev/null
+++ b/packages/widget/knip.ts
@@ -0,0 +1,42 @@
+import type { KnipConfig } from "knip";
+
+export default {
+ entry: [
+ "src/index.package.ts!",
+ "src/index.bundle.ts!",
+ "src/public-api/index.package.ts!",
+ "src/public-api/index.bundle.ts!",
+ "vite/*.ts!",
+ "tests/package-types/*.ts",
+ "tests/utils/setup.browser.ts",
+ "tests/utils/setup.dom.ts",
+ "tests/**/*.test.ts",
+ "tests/**/*.test.tsx",
+ ],
+ project: [
+ "src/**/*.{ts,tsx}!",
+ "scripts/**/*.{ts,mts}",
+ "tests/**/*.{ts,tsx}",
+ "vite/**/*.ts!",
+ ],
+ ignoreIssues: {
+ "src/generated/**": ["exports", "types"],
+ },
+ ignoreDependencies: [
+ "@effect/language-service",
+ "@effect/openapi-generator",
+ "@effect/platform-node",
+ // Production imports of these packages come from bundled third-party code.
+ // vite.config.package.ts checks they stay external; DOM tests use them directly.
+ ...(process.argv.includes("--production")
+ ? [
+ "@radix-ui/react-dismissable-layer",
+ "@radix-ui/react-focus-guards",
+ "@radix-ui/react-focus-scope",
+ "aria-hidden",
+ "react-remove-scroll",
+ "scheduler",
+ ]
+ : []),
+ ],
+} satisfies KnipConfig;
diff --git a/packages/widget/package.json b/packages/widget/package.json
index 381d8508..7f9f3c0c 100644
--- a/packages/widget/package.json
+++ b/packages/widget/package.json
@@ -72,8 +72,8 @@
"check-hygiene:production": "knip --production --include files,dependencies,unlisted --no-progress --treat-config-hints-as-errors"
},
"peerDependencies": {
- "react": ">=18",
- "react-dom": ">=18"
+ "react": ">=19",
+ "react-dom": ">=19"
},
"peerDependenciesMeta": {
"react": {
@@ -84,7 +84,13 @@
}
},
"dependencies": {
- "effect": "catalog:"
+ "@radix-ui/react-dismissable-layer": "catalog:",
+ "@radix-ui/react-focus-guards": "catalog:",
+ "@radix-ui/react-focus-scope": "catalog:",
+ "aria-hidden": "catalog:",
+ "effect": "catalog:",
+ "react-remove-scroll": "catalog:",
+ "scheduler": "catalog:"
},
"devDependencies": {
"@cosmjs/amino": "catalog:",
diff --git a/packages/widget/scripts/smoke/check-production-host-consumption.ts b/packages/widget/scripts/smoke/check-production-host-consumption.ts
index 0280d811..930fbcd9 100644
--- a/packages/widget/scripts/smoke/check-production-host-consumption.ts
+++ b/packages/widget/scripts/smoke/check-production-host-consumption.ts
@@ -130,6 +130,26 @@ const assertHostsResolveBuiltWidget = () => {
}
};
+// An external import only shares React's work queue when the consumer resolves
+// it to the renderer's scheduler. Equal version ranges alone do not ensure this.
+const assertSharedScheduler = () => {
+ const widgetRequire = createRequire(join(widgetRoot, "package.json"));
+ const widgetScheduler = widgetRequire.resolve("scheduler");
+ for (const host of [hosts.vitePackage, hosts.next]) {
+ const hostRequire = createRequire(join(host.directory, "package.json"));
+ const rendererRequire = createRequire(hostRequire.resolve("react-dom"));
+ const rendererScheduler = rendererRequire.resolve("scheduler");
+ if (widgetScheduler !== rendererScheduler) {
+ throw new Error(
+ `${host.label} resolves a different scheduler from the widget. Align scheduler with react-dom before publishing. Widget: ${widgetScheduler}; renderer: ${rendererScheduler}`
+ );
+ }
+ console.log(
+ `[smoke] ${host.label} resolves the same scheduler as the widget`
+ );
+ }
+};
+
const assertBuiltWidgetArtifacts = async () => {
const artifactPaths = [
"dist/package/index.package.js",
@@ -520,6 +540,10 @@ const withServer = async ({
};
const main = async () => {
+ assertSharedScheduler();
+ if (process.argv.includes("--check-dependencies")) {
+ return;
+ }
const apiKey = await resolveApiKey();
if (process.argv.includes("--check-key")) {
diff --git a/packages/widget/tests/components/shared-modal-coordination.dom.test.tsx b/packages/widget/tests/components/shared-modal-coordination.dom.test.tsx
new file mode 100644
index 00000000..cb5d26ba
--- /dev/null
+++ b/packages/widget/tests/components/shared-modal-coordination.dom.test.tsx
@@ -0,0 +1,73 @@
+import { DismissableLayer } from "@radix-ui/react-dismissable-layer";
+import { FocusGuards } from "@radix-ui/react-focus-guards";
+import { FocusScope } from "@radix-ui/react-focus-scope";
+import { hideOthers } from "aria-hidden";
+import { RemoveScroll } from "react-remove-scroll";
+import { expect, it } from "vitest";
+import { render } from "../utils/test-utils.dom";
+
+// Separate React roots model a host modal and a widget modal. The package build
+// checks that their document-wide coordination modules are not embedded in dist.
+it("restores pointer events when the older modal closes first", async () => {
+ const original = document.body.style.pointerEvents;
+ const host = await render();
+ const widget = await render();
+ expect(document.body.style.pointerEvents).toBe("none");
+ host.unmount();
+ expect(document.body.style.pointerEvents).toBe("none");
+ widget.unmount();
+ expect(document.body.style.pointerEvents).toBe(original);
+});
+
+it("pauses the host focus trap while the widget trap is active", async () => {
+ const host = await render(
+
+
+
+ );
+ const widget = await render(
+
+
+
+ );
+ const hostButton = host.container.querySelector("button");
+ const widgetButton = widget.container.querySelector("button");
+ expect(document.activeElement).toBe(widgetButton);
+ hostButton?.focus();
+ expect(document.activeElement).toBe(widgetButton);
+ widget.unmount();
+ await expect.poll(() => document.activeElement).toBe(hostButton);
+});
+
+it("retains focus guards and scroll locking until the last modal closes", async () => {
+ const modal = (
+
+ Modal
+
+ );
+ const host = await render(modal);
+ const widget = await render(modal);
+ host.unmount();
+ expect(document.querySelectorAll("[data-radix-focus-guard]")).toHaveLength(2);
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(true);
+ widget.unmount();
+ expect(document.querySelectorAll("[data-radix-focus-guard]")).toHaveLength(0);
+ expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
+});
+
+it("restores accessibility attributes after overlapping modal lifetimes", () => {
+ const background = document.createElement("main");
+ const modal = document.createElement("div");
+ document.body.append(background, modal);
+ try {
+ const closeHost = hideOthers(modal);
+ const closeWidget = hideOthers(modal);
+ closeHost();
+ expect(background.getAttribute("aria-hidden")).toBe("true");
+ closeWidget();
+ expect(background.hasAttribute("aria-hidden")).toBe(false);
+ } finally {
+ background.remove();
+ modal.remove();
+ }
+});
diff --git a/packages/widget/vite/vite.config.package.ts b/packages/widget/vite/vite.config.package.ts
index 90446d68..ba8e4971 100644
--- a/packages/widget/vite/vite.config.package.ts
+++ b/packages/widget/vite/vite.config.package.ts
@@ -2,6 +2,17 @@ import path from "node:path";
import { defineConfig, esmExternalRequirePlugin } from "vite";
import { getConfig } from "./vite.config.base.ts";
+// These packages coordinate work or DOM state with the embedding application.
+// Bundling a private copy separates React's cleanup queue and modal lock stacks.
+const sharedDependencies = [
+ "scheduler",
+ "@radix-ui/react-dismissable-layer",
+ "@radix-ui/react-focus-guards",
+ "@radix-ui/react-focus-scope",
+ "aria-hidden",
+ "react-remove-scroll",
+];
+
const config = getConfig({
define: {
// Drop dead AMD branches from bundled UMD dependencies so Next Turbopack
@@ -35,8 +46,25 @@ const config = getConfig({
// Keep React and ReactDOM external for the host. Bundle
// `react/compiler-runtime`: it is CommonJS, and hosts that exclude this
// package from optimizeDeps otherwise fail to prebundle that subpath.
- external: [/^react(-dom)?(?!\/compiler-runtime)(\/.+)?$/],
+ external: [
+ /^react(-dom)?(?!\/compiler-runtime)(\/.+)?$/,
+ ...sharedDependencies.map((name) => new RegExp(`^${name}(/.*)?$`)),
+ ],
}),
+ {
+ name: "check-shared-dependencies",
+ generateBundle() {
+ for (const id of this.getModuleIds()) {
+ if (
+ sharedDependencies.some((name) =>
+ id.replaceAll("\\", "/").includes(`/node_modules/${name}/`)
+ )
+ ) {
+ this.error(`Shared dependency was bundled: ${id}`);
+ }
+ }
+ },
+ },
],
},
copyPublicDir: false,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 52a81eb9..8c564c1b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -280,9 +280,18 @@ catalogs:
'@radix-ui/react-dialog':
specifier: ^1.1.23
version: 1.1.23
+ '@radix-ui/react-dismissable-layer':
+ specifier: ^1.1.19
+ version: 1.1.19
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.24
version: 2.1.24
+ '@radix-ui/react-focus-guards':
+ specifier: ^1.1.6
+ version: 1.1.6
+ '@radix-ui/react-focus-scope':
+ specifier: ^1.1.16
+ version: 1.1.16
'@radix-ui/react-tooltip':
specifier: ^1.2.16
version: 1.2.16
@@ -379,6 +388,9 @@ catalogs:
'@wallet-standard/base':
specifier: ^1.1.1
version: 1.1.1
+ aria-hidden:
+ specifier: ^1.2.6
+ version: 1.2.6
autoprefixer:
specifier: ^10.5.4
version: 10.5.4
@@ -451,12 +463,18 @@ catalogs:
react-loading-skeleton:
specifier: ^3.5.0
version: 3.5.0
+ react-remove-scroll:
+ specifier: ^2.7.2
+ version: 2.7.2
react-router:
specifier: ^7.15.0
version: 7.15.0
recharts:
specifier: ^3.10.1
version: 3.10.1
+ scheduler:
+ specifier: '>=0.25.0 <0.28.0'
+ version: 0.27.0
serve:
specifier: ^14.2.6
version: 14.2.6
@@ -494,6 +512,9 @@ catalogs:
specifier: ^2.9.0
version: 2.9.0
+overrides:
+ '@stakekit/rainbowkit@2.2.11>react-remove-scroll': 2.7.2
+
packageExtensionsChecksum: sha256-FZ9pA8g2+w7cg8p8CU69qpPAEhtGVHP/kzcJxOvNvcM=
patchedDependencies:
@@ -602,9 +623,27 @@ importers:
packages/widget:
dependencies:
+ '@radix-ui/react-dismissable-layer':
+ specifier: 'catalog:'
+ version: 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-focus-guards':
+ specifier: 'catalog:'
+ version: 1.1.6(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-focus-scope':
+ specifier: 'catalog:'
+ version: 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ aria-hidden:
+ specifier: 'catalog:'
+ version: 1.2.6
effect:
specifier: 'catalog:'
version: 4.0.0-rc.112
+ react-remove-scroll:
+ specifier: 'catalog:'
+ version: 2.7.2(@types/react@19.2.18)(react@19.2.8)
+ scheduler:
+ specifier: 'catalog:'
+ version: 0.27.0
devDependencies:
'@cosmjs/amino':
specifier: 'catalog:'
@@ -10090,16 +10129,6 @@ packages:
'@types/react':
optional: true
- react-remove-scroll@2.6.2:
- resolution: {integrity: sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==}
- engines: {node: '>=10'}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
react-remove-scroll@2.7.2:
resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
engines: {node: '>=10'}
@@ -18614,7 +18643,7 @@ snapshots:
cuer: 0.0.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
- react-remove-scroll: 2.6.2(@types/react@19.2.18)(react@19.2.8)
+ react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8)
ua-parser-js: 1.0.41
viem: 2.55.16(bufferutil@4.0.9)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@3.25.76)
wagmi: 3.7.5(599d54e35e22cc79907e1e2327fb1205)
@@ -25504,17 +25533,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
- react-remove-scroll@2.6.2(@types/react@19.2.18)(react@19.2.8):
- dependencies:
- react: 19.2.8
- react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8)
- react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8)
- tslib: 2.8.1
- use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8)
- use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8)
- optionalDependencies:
- '@types/react': 19.2.18
-
react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8):
dependencies:
react: 19.2.8
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6e5c8e0a..0e3f89e0 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -45,6 +45,9 @@ catalog:
"@meshsdk/wallet": 1.9.1
"@polkadot/types": ^16.5.6
"@polkadot/util": ^14.0.3
+ "@radix-ui/react-dismissable-layer": ^1.1.19
+ "@radix-ui/react-focus-guards": ^1.1.6
+ "@radix-ui/react-focus-scope": ^1.1.16
"@radix-ui/react-dialog": ^1.1.23
"@radix-ui/react-dropdown-menu": ^2.1.24
"@radix-ui/react-tooltip": ^1.2.16
@@ -80,6 +83,7 @@ catalog:
"@vitest/browser-playwright": ^4.1.11
"@wallet-standard/app": ^1.1.1
"@wallet-standard/base": ^1.1.1
+ aria-hidden: ^1.2.6
autoprefixer: ^10.5.4
bignumber.js: ^11.1.5
chain-registry: 1.69.674
@@ -105,8 +109,10 @@ catalog:
react-i18next: ^17.0.12
react-is: ^19.2.0
react-loading-skeleton: ^3.5.0
+ react-remove-scroll: ^2.7.2
react-router: ^7.15.0
recharts: ^3.10.1
+ scheduler: ">=0.25.0 <0.28.0"
serve: ^14.2.6
tsx: ^4.23.12
turbo: ^2.10.11
@@ -129,6 +135,10 @@ minimumReleaseAgeExclude:
- effect
- typescript
+overrides:
+ # Share Radix's scroll-lock implementation with the wallet modal.
+ "@stakekit/rainbowkit@2.2.11>react-remove-scroll": 2.7.2
+
packageExtensions:
"@fractalwagmi/solana-wallet-adapter@0.1.1":
peerDependencies: