From 3722be06d1a4d139a1b66e133d7abcc738d98d13 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:13:29 +0000 Subject: [PATCH] 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 --- packages/app/e2e/browser/file-editing.spec.ts | 79 +++++++++++++++++-- .../app/src/file-panels/html-preview-csp.ts | 25 +----- .../app/src/file-panels/html-preview.web.tsx | 9 ++- 3 files changed, 79 insertions(+), 34 deletions(-) diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index cc57b5315c..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, @@ -851,9 +869,7 @@ test.describe("CodeMirror workspace file editing", () => { await expect( preview.document.getByRole("heading", { name: "Target section" }), ).toBeInViewport(); - expect(await preview.document.locator("body").evaluate(() => location.href)).toBe( - "about:srcdoc#section", - ); + expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section"); expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan( 0, ); @@ -880,12 +896,59 @@ test.describe("CodeMirror workspace file editing", () => { await expect( preview.document.getByRole("heading", { name: "Target section" }), ).not.toBeInViewport(); - expect(await preview.document.locator("body").evaluate(() => location.href)).toBe( - "about:srcdoc", - ); + 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, @@ -950,7 +1013,7 @@ window.addEventListener('contextmenu', modifiedClick); 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.href)).toBe("about:srcdoc#encoded%20section"); + 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(); @@ -963,7 +1026,7 @@ window.addEventListener('contextmenu', modifiedClick); } await nested.click({ button: "middle" }); await expect(preview.document.locator("#modified")).toHaveText("false"); - expect(await body.evaluate(() => location.href)).toBe("about:srcdoc#"); + expect(await body.evaluate(() => location.hash)).toBe(""); expect(await body.evaluate(() => window.scrollY)).toBe(0); }); diff --git a/packages/app/src/file-panels/html-preview-csp.ts b/packages/app/src/file-panels/html-preview-csp.ts index f9bdb535ae..3a5e9c644a 100644 --- a/packages/app/src/file-panels/html-preview-csp.ts +++ b/packages/app/src/file-panels/html-preview-csp.ts @@ -28,29 +28,6 @@ const POLICY = [ const META = ``; -// Native WebViews already handle fragments; only srcdoc inherits the app's base URL. -const FRAGMENT_NAVIGATION = ``; - // The policy must reach the parser before any markup the document declares, and it // only counts if it lands in `` — once the parser has moved on to ``, a // meta http-equiv CSP is ignored outright. @@ -68,7 +45,7 @@ if (location.href === "about:srcdoc") { // the parser ignores wherever it appears. Standards mode is guaranteed, the policy // is always the first element and therefore always in the head, and no part of the // document has to be parsed to place it. -const PROLOGUE = `${META}${FRAGMENT_NAVIGATION}`; +const PROLOGUE = `${META}`; // Left where it is, a BOM would sit mid-document and render as a zero-width space. const BOM = "\uFEFF"; 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 (