diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts
index 0266d02691..cc57b5315c 100644
--- a/packages/app/e2e/browser/file-editing.spec.ts
+++ b/packages/app/e2e/browser/file-editing.spec.ts
@@ -826,6 +826,147 @@ 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.href)).toBe(
+ "about:srcdoc#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
+
+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.href)).toBe(
+ "about:srcdoc",
+ );
+ expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
+ });
+
+ 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.href)).toBe("about:srcdoc#encoded%20section");
+ 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.href)).toBe("about:srcdoc#");
+ 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-csp.ts b/packages/app/src/file-panels/html-preview-csp.ts
index 3a5e9c644a..f9bdb535ae 100644
--- a/packages/app/src/file-panels/html-preview-csp.ts
+++ b/packages/app/src/file-panels/html-preview-csp.ts
@@ -28,6 +28,29 @@ 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.
@@ -45,7 +68,7 @@ const META = ``;
// 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}`;
+const PROLOGUE = `${META}${FRAGMENT_NAVIGATION}`;
// Left where it is, a BOM would sit mid-document and render as a zero-width space.
const BOM = "\uFEFF";