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 (