From 7495c20f78176172ee7774a9471dd6966928a2dc Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:54:49 +0000 Subject: [PATCH] fix(app): keep HTML section links inside the preview (#8559) * fix(app): keep HTML preview fragment links inside the preview A file previewed through `srcDoc` resolves `` against the app's URL, so the link loaded Paseo inside the iframe and the preview became the Welcome screen. Handle same-document fragment clicks from a small script in the preview document, and let the page's own handlers go first. Closes #7465 Co-Authored-By: Claude Opus 5.5 * fix(app): preserve native HTML fragment navigation in previews Give each HTML preview its own document URL so section links resolve within the file. Browser default navigation preserves cancellation by page handlers without an injected click interceptor. Co-authored-by: 3ae3ae --------- Co-authored-by: 3ae3ae Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/file-editing.spec.ts | 204 ++++++++++++++++++ .../app/src/file-panels/html-preview.web.tsx | 9 +- 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 0266d02691..e9fc0b15ba 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -63,6 +63,24 @@ function htmlPreview(page: Page) { }; } +async function followHtmlPreviewLink(page: Page, name: string): Promise { + await htmlPreview(page).document.getByRole("link", { name, exact: true }).click(); +} + +async function expectPageHandlesFragment( + page: Page, + handler: "Document" | "Window", +): Promise { + const preview = htmlPreview(page); + await followHtmlPreviewLink(page, `${handler} handled jump`); + await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).not.toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe(""); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0); +} + async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { const option = page.getByTestId("file-panel-bar").getByRole("button", { name: view, @@ -826,6 +844,192 @@ test.describe("CodeMirror workspace file editing", () => { await expect(preview.host).toBeVisible(); }); + test("keeps HTML fragment links in the preview and scrolls to the target", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragments-" }); + await writeFile( + path.join(workspace.repoPath, "fragments.html"), + ` +Jump to section +
+

Target section

+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "fragments.html"); + + const preview = htmlPreview(page); + await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") }); + await preview.document.getByRole("link", { name: "Jump to section" }).click(); + + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section"); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan( + 0, + ); + await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") }); + }); + + test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-handler-" }); + await writeFile( + path.join(workspace.repoPath, "handled.html"), + ` +Handled jump +Waiting for click +

Target section

+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "handled.html"); + + const preview = htmlPreview(page); + await preview.document.getByRole("link", { name: "Handled jump" }).click(); + await expect(preview.document.locator("output")).toHaveText("Page handled click"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).not.toBeInViewport(); + expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe(""); + expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0); + }); + + test("respects delegated HTML fragment handlers and normal URL parsing", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" }); + await writeFile( + path.join(workspace.repoPath, "defaults.html"), + ` + +

Target section

+

Percent target

+
+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "defaults.html"); + + const preview = htmlPreview(page); + await expectPageHandlesFragment(page, "Document"); + await expectPageHandlesFragment(page, "Window"); + + await followHtmlPreviewLink(page, "Whitespace jump"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + await followHtmlPreviewLink(page, "Percent jump"); + await expect( + preview.document.getByRole("heading", { name: "Percent target" }), + ).toBeInViewport(); + }); + + test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "html-fragment-targets-" }); + await writeFile( + path.join(workspace.repoPath, "targets.html"), + ` + +

Target section

+
Named target +

Encoded target

+
+`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "targets.html"); + + const preview = htmlPreview(page); + const body = preview.document.locator("body"); + const nested = preview.document.getByText("Nested jump", { exact: true }); + await nested.click(); + await expect(preview.document.locator("output").first()).toHaveText("#section"); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)"); + + await body.evaluate(() => window.scrollTo(0, 0)); + await nested.click(); + await expect( + preview.document.getByRole("heading", { name: "Target section" }), + ).toBeInViewport(); + + await preview.document.getByRole("link", { name: "Named jump" }).click(); + await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport(); + await expect(preview.document.locator("output").first()).toHaveText("#legacy"); + + await preview.document.getByRole("link", { name: "Encoded jump" }).click(); + await expect( + preview.document.getByRole("heading", { name: "Encoded target" }), + ).toBeInViewport(); + await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section"); + const scrollY = await body.evaluate(() => window.scrollY); + await preview.document.getByRole("link", { name: "Missing jump" }).click(); + expect(await body.evaluate(() => location.hash)).toBe("#missing"); + expect(await body.evaluate(() => window.scrollY)).toBe(scrollY); + + await preview.document.getByRole("link", { name: "Empty jump" }).click(); + await expect(preview.document.locator("output").first()).toHaveText("(empty)"); + expect(await body.evaluate(() => window.scrollY)).toBe(0); + + for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) { + await nested.click({ modifiers: [modifier] }); + await expect(preview.document.locator("#modified")).toHaveText("false"); + } + await nested.click({ button: "middle" }); + await expect(preview.document.locator("#modified")).toHaveText("false"); + expect(await body.evaluate(() => location.hash)).toBe(""); + expect(await body.evaluate(() => window.scrollY)).toBe(0); + }); + test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => { test.setTimeout(90_000); const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" }); diff --git a/packages/app/src/file-panels/html-preview.web.tsx b/packages/app/src/file-panels/html-preview.web.tsx index 471362d8ca..eac275bfe8 100644 --- a/packages/app/src/file-panels/html-preview.web.tsx +++ b/packages/app/src/file-panels/html-preview.web.tsx @@ -25,12 +25,17 @@ const iframeStyle = { export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) { const { t } = useTranslation(); - const document = useMemo(() => withPreviewCsp(html), [html]); + // A document URL lets the browser resolve fragment links within the preview. + // srcDoc would inherit the app URL and navigate the frame back into Paseo. + const source = useMemo( + () => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`, + [html], + ); return (