From b2a09d8bda5839b2578a005c4696973e97e99931 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Mon, 7 Sep 2026 16:01:41 +0300 Subject: [PATCH] fix(widget): share scheduler and modal runtime state Externalize scheduler and document-wide modal coordination packages from the component build. Require React 19 and align scroll-lock dependencies with pnpm. Add consumer and modal coordination regression checks. --- README.md | 2 +- packages/widget/knip.jsonc | 29 -------- packages/widget/knip.ts | 42 +++++++++++ packages/widget/package.json | 12 ++- .../check-production-host-consumption.ts | 24 ++++++ .../shared-modal-coordination.dom.test.tsx | 73 +++++++++++++++++++ packages/widget/vite/vite.config.package.ts | 30 +++++++- pnpm-lock.yaml | 62 ++++++++++------ pnpm-workspace.yaml | 10 +++ 9 files changed, 228 insertions(+), 56 deletions(-) delete mode 100644 packages/widget/knip.jsonc create mode 100644 packages/widget/knip.ts create mode 100644 packages/widget/tests/components/shared-modal-coordination.dom.test.tsx diff --git a/README.md b/README.md index 846c2c368..a39fd1c26 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 dde24b43e..000000000 --- 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 000000000..7b19760cf --- /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 381d85088..7f9f3c0c8 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 0280d8111..930fbcd9b 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 000000000..cb5d26ba8 --- /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 90446d688..ba8e4971b 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 52a81eb9b..8c564c1b7 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 6e5c8e0a9..0e3f89e03 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: