From 6d9dba901a3ba3711a3037af84459e24e6ef0acd Mon Sep 17 00:00:00 2001 From: Jack Musick Date: Fri, 7 Aug 2026 22:48:26 -0400 Subject: [PATCH 1/2] Fix embedded form dropdown scroll propagation --- client/e2e/forms-public.unauth.spec.ts | 173 +++++++++++++++++- .../forms/FormConfirmation.test.tsx | 2 + .../src/components/forms/FormConfirmation.tsx | 2 +- client/src/components/ui/command.test.tsx | 96 ++++++++++ client/src/components/ui/command.tsx | 47 +++++ 5 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 client/src/components/ui/command.test.tsx diff --git a/client/e2e/forms-public.unauth.spec.ts b/client/e2e/forms-public.unauth.spec.ts index c9e411322..a21d3f8fa 100644 --- a/client/e2e/forms-public.unauth.spec.ts +++ b/client/e2e/forms-public.unauth.spec.ts @@ -6,6 +6,7 @@ import { expect, request as playwrightRequest, type APIRequestContext, + type Locator, } from "@playwright/test"; const API_URL = process.env.TEST_API_URL || "http://api:8000"; @@ -33,8 +34,20 @@ async def ${PROVIDER_FN}(): const SUBMIT_SOURCE = `from bifrost import workflow @workflow(name="${SUBMIT_FN}") -async def ${SUBMIT_FN}(company: str, email: str, company_name: str = ""): - return {"company": company, "email": email, "company_name": company_name} +async def ${SUBMIT_FN}( + company: str, + email: str, + company_name: str = "", + company_size: str = "", + referral_source: str = "", +): + return { + "company": company, + "email": email, + "company_name": company_name, + "company_size": company_size, + "referral_source": referral_source, + } `; async function expectOk( @@ -47,6 +60,8 @@ test.describe.serial("Public form iframe", () => { let api: APIRequestContext; let formId: string; let publicKey: string; + let scrollFormId: string; + let scrollPublicKey: string; test.beforeAll(async () => { const credentials = JSON.parse( @@ -123,10 +138,82 @@ test.describe.serial("Public form iframe", () => { }); await expectOk(created); formId = ((await created.json()) as { id: string }).id; + + const scrollForm = await api.post("/api/forms", { + data: { + name: `Embedded scroll regression ${UNIQUE}`, + workflow_id: workflow!.id, + form_schema: { + fields: [ + { + name: "company", + label: "Company", + type: "select", + required: true, + data_provider_id: provider!.id, + auto_fill: { company_name: "company_name" }, + }, + { + name: "company_name", + label: "Company name", + type: "text", + }, + { + name: "company_size", + label: "Company size", + type: "select", + options: [ + { value: "small", label: "1–10 people" }, + { value: "medium", label: "11–100 people" }, + { value: "large", label: "101+ people" }, + ], + }, + { + name: "email", + label: "Email", + type: "email", + required: true, + }, + { + name: "referral_source", + label: "How did you hear about us", + type: "select", + options: [ + { value: "search", label: "Search engine" }, + { value: "referral", label: "Referral" }, + { value: "event", label: "Event" }, + ], + }, + ], + }, + }, + }); + await expectOk(scrollForm); + scrollFormId = ((await scrollForm.json()) as { id: string }).id; + + const scrollReview = await api.get( + `/api/forms/${scrollFormId}/publication-review`, + ); + await expectOk(scrollReview); + const review = (await scrollReview.json()) as { fingerprint: string }; + const scrollPublication = await api.put( + `/api/forms/${scrollFormId}/publication`, + { + data: { + reviewed_fingerprint: review.fingerprint, + allowed_origins: ["http://allowed-origin"], + }, + }, + ); + await expectOk(scrollPublication); + scrollPublicKey = ( + (await scrollPublication.json()) as { public_key: string } + ).public_key; }); test.afterAll(async () => { if (!api) return; + if (scrollFormId) await api.delete(`/api/forms/${scrollFormId}`); if (formId) await api.delete(`/api/forms/${formId}`); await api.delete( `/api/files/editor?path=${encodeURIComponent(PROVIDER_PATH)}`, @@ -283,7 +370,9 @@ test.describe.serial("Public form iframe", () => { name: `Public website form ${UNIQUE}`, }), ).toBeVisible({ timeout: 15_000 }); - await frame.getByRole("combobox", { name: "Company" }).click(); + await frame + .getByRole("combobox", { name: "Company *", exact: true }) + .click(); await frame.getByText("Acme Corporation", { exact: true }).click(); await expect(frame.getByLabel("Company name")).toHaveValue( "Acme Corporation", @@ -335,6 +424,80 @@ test.describe.serial("Public form iframe", () => { await expectOk(hmacSecret); }); + test("keeps the host page fixed while opening consecutive form dropdowns", async ({ + page, + }) => { + await page.route("http://allowed-origin/**", (route) => + route.request().resourceType() === "script" + ? route.abort() + : route.continue(), + ); + await page.goto("http://allowed-origin/"); + await page.setContent(` +
+ + `); + + const frame = page.frameLocator('iframe[title="Bottom form"]'); + await expect( + frame.getByRole("heading", { + name: `Embedded scroll regression ${UNIQUE}`, + }), + ).toBeVisible({ timeout: 15_000 }); + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight), + ); + const bottom = await page.evaluate(() => window.scrollY); + const clickVisibleControl = async (control: Locator) => { + const bounds = await control.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual( + page.viewportSize()!.height, + ); + await page.mouse.click( + bounds!.x + bounds!.width / 2, + bounds!.y + bounds!.height / 2, + ); + }; + const expectParentToRemainFixed = async () => { + const positions = await page.evaluate(async () => { + const samples: number[] = []; + for (let frame = 0; frame < 5; frame += 1) { + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + samples.push(window.scrollY); + } + return samples; + }); + expect(positions).toEqual(Array(5).fill(bottom)); + }; + + await clickVisibleControl( + frame.getByRole("combobox", { name: "Company size" }), + ); + await expect( + frame.getByRole("option", { name: "1–10 people" }), + ).toBeVisible(); + await expectParentToRemainFixed(); + await page.keyboard.press("Escape"); + + await clickVisibleControl( + frame.getByRole("combobox", { + name: "How did you hear about us", + }), + ); + await expect( + frame.getByRole("option", { name: "Search engine" }), + ).toBeVisible(); + await expectParentToRemainFixed(); + }); + test("shows only the signed session's execution result after an HMAC submission", async ({ page, }) => { @@ -362,7 +525,9 @@ test.describe.serial("Public form iframe", () => { name: `Public website form ${UNIQUE}`, }), ).toBeVisible(); - await frame.getByRole("combobox", { name: "Company" }).click(); + await frame + .getByRole("combobox", { name: "Company *", exact: true }) + .click(); await frame.getByText("Acme Corporation", { exact: true }).click(); await frame.getByLabel("Email").fill("hmac@example.com"); await expect( diff --git a/client/src/components/forms/FormConfirmation.test.tsx b/client/src/components/forms/FormConfirmation.test.tsx index e5192bd6a..80cb753c5 100644 --- a/client/src/components/forms/FormConfirmation.test.tsx +++ b/client/src/components/forms/FormConfirmation.test.tsx @@ -15,6 +15,7 @@ describe("FormConfirmation", () => { }); it("focuses and renders safe Markdown without raw HTML", () => { + const focus = vi.spyOn(HTMLElement.prototype, "focus"); render( { const status = screen.getByRole("status"); expect(status).toHaveFocus(); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect( screen.getByRole("heading", { name: "Thank you" }), ).toBeVisible(); diff --git a/client/src/components/forms/FormConfirmation.tsx b/client/src/components/forms/FormConfirmation.tsx index c06e079d8..2dc7c2d19 100644 --- a/client/src/components/forms/FormConfirmation.tsx +++ b/client/src/components/forms/FormConfirmation.tsx @@ -41,7 +41,7 @@ export function FormConfirmation({ formId, markdown }: FormConfirmationProps) { useEffect(() => { window.scrollTo({ top: 0, behavior: "smooth" }); - containerRef.current?.focus(); + containerRef.current?.focus({ preventScroll: true }); const targetOrigin = parentOrigin(); if (!targetOrigin || window.parent === window) return; diff --git a/client/src/components/ui/command.test.tsx b/client/src/components/ui/command.test.tsx new file mode 100644 index 000000000..78a006b23 --- /dev/null +++ b/client/src/components/ui/command.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Command, CommandGroup, CommandItem, CommandList } from "./command"; + +const nativeScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView", +); + +afterEach(() => { + if (nativeScrollIntoView) { + Object.defineProperty( + HTMLElement.prototype, + "scrollIntoView", + nativeScrollIntoView, + ); + } else { + delete HTMLElement.prototype.scrollIntoView; + } +}); + +describe("Command scrolling", () => { + it("keeps cmdk's selected-item scroll inside the command list", async () => { + const documentScroll = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: documentScroll, + }); + + render( + + + + Item one + + + , + ); + + const list = screen.getByRole("listbox"); + const item = screen.getByRole("option", { name: "Item one" }); + Object.defineProperty(list, "scrollTop", { + configurable: true, + writable: true, + value: 20, + }); + vi.spyOn(list, "getBoundingClientRect").mockReturnValue({ + top: 100, + bottom: 200, + left: 0, + right: 200, + width: 200, + height: 100, + x: 0, + y: 100, + toJSON: () => ({}), + }); + const itemRect = vi + .spyOn(item, "getBoundingClientRect") + .mockReturnValue({ + top: 210, + bottom: 240, + left: 0, + right: 200, + width: 200, + height: 30, + x: 0, + y: 210, + toJSON: () => ({}), + }); + + item.scrollIntoView({ block: "nearest" }); + + expect(list.scrollTop).toBe(60); + + itemRect.mockReturnValue({ + top: 70, + bottom: 100, + left: 0, + right: 200, + width: 200, + height: 30, + x: 0, + y: 70, + toJSON: () => ({}), + }); + item.scrollIntoView({ block: "nearest" }); + + expect(list.scrollTop).toBe(30); + await waitFor(() => + expect(item).toHaveAttribute("aria-selected", "true"), + ); + expect(documentScroll).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/ui/command.tsx b/client/src/components/ui/command.tsx index 0547a1466..2eb6fa3b1 100644 --- a/client/src/components/ui/command.tsx +++ b/client/src/components/ui/command.tsx @@ -17,6 +17,46 @@ import { } from "@/components/ui/input-group" import { SearchIcon, CheckIcon } from "lucide-react" +function scrollWithinCommandList(item: HTMLElement) { + const list = item.closest('[cmdk-list=""]') + if (!list) return + + const listRect = list.getBoundingClientRect() + const itemRect = item.getBoundingClientRect() + const styles = window.getComputedStyle(list) + const scrollPaddingTop = Number.parseFloat(styles.scrollPaddingTop) || 0 + const scrollPaddingBottom = Number.parseFloat(styles.scrollPaddingBottom) || 0 + const visibleTop = listRect.top + scrollPaddingTop + const visibleBottom = listRect.bottom - scrollPaddingBottom + + if (itemRect.top < visibleTop) { + list.scrollTop -= visibleTop - itemRect.top + } else if (itemRect.bottom > visibleBottom) { + list.scrollTop += itemRect.bottom - visibleBottom + } +} + +function setCommandItemRef( + ref: React.Ref | undefined, + item: HTMLDivElement | null +) { + if (item) { + // cmdk calls scrollIntoView when its selected item changes. The native + // method may scroll every ancestor, including a cross-origin iframe's host + // page. Keep that internal selection bookkeeping inside the command list. + Object.defineProperty(item, "scrollIntoView", { + configurable: true, + value: () => scrollWithinCommandList(item), + }) + } + + if (typeof ref === "function") { + ref(item) + } else if (ref) { + ref.current = item + } +} + function Command({ className, ...props @@ -192,10 +232,17 @@ function CommandSeparator({ function CommandItem({ className, children, + ref, ...props }: React.ComponentProps) { + const commandItemRef = React.useCallback( + (item: HTMLDivElement | null) => setCommandItemRef(ref, item), + [ref] + ) + return ( Date: Fri, 7 Aug 2026 23:42:04 -0400 Subject: [PATCH 2/2] Fix command test cleanup typecheck --- client/src/components/ui/command.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/ui/command.test.tsx b/client/src/components/ui/command.test.tsx index 78a006b23..deacf2011 100644 --- a/client/src/components/ui/command.test.tsx +++ b/client/src/components/ui/command.test.tsx @@ -16,7 +16,7 @@ afterEach(() => { nativeScrollIntoView, ); } else { - delete HTMLElement.prototype.scrollIntoView; + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); } });