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
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
29 changes: 0 additions & 29 deletions packages/widget/knip.jsonc

This file was deleted.

42 changes: 42 additions & 0 deletions packages/widget/knip.ts
Original file line numberDiff line numberDiff line change
@@ -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;
12 changes: 9 additions & 3 deletions packages/widget/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand All@@ -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:",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand DownExpand Up@@ -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")) {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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(<DismissableLayer disableOutsidePointerEvents />);
const widget = await render(<DismissableLayer disableOutsidePointerEvents />);
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(
<FocusScope trapped>
<button type="button">Host</button>
</FocusScope>
);
const widget = await render(
<FocusScope trapped>
<button type="button">Widget</button>
</FocusScope>
);
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 = (
<FocusGuards>
<RemoveScroll>Modal</RemoveScroll>
</FocusGuards>
);
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();
}
});
30 changes: 29 additions & 1 deletion packages/widget/vite/vite.config.package.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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,
Expand Down
62 changes: 40 additions & 22 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading