diff --git a/.tegami/editor-preview-document-preparation.md b/.tegami/editor-preview-document-preparation.md new file mode 100644 index 0000000..256ef16 --- /dev/null +++ b/.tegami/editor-preview-document-preparation.md @@ -0,0 +1,12 @@ +--- +packages: + npm:@samva/editor: + type: minor +--- + +## Hosts can prepare preview documents before parsing + +`EditorProvider` accepts `preparePreviewDocument`, and `@samva/editor/shell` exports +`PreparedPreviewDocument`. Canvas and Preview write the prepared HTML, then mount host resources +before measurement and paint. Resources are cleaned up for their exact document on replacement, +unmount, and StrictMode replay. Replacing the preparation callback replaces the preview frames. diff --git a/packages/editor/README.md b/packages/editor/README.md index fd070b2..e7c5f31 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -73,6 +73,43 @@ exact source replacement saved like any other edit. Expressions, conditionals an inside a body are locked segments, so a form edit cannot rewrite them, and an edit that would leave the static profile is refused with its reason. +## Preparing preview documents + +`EditorProvider` accepts `preparePreviewDocument?: (html: string) => PreparedPreviewDocument`. +Both the editing canvas and Preview call it before writing to the attached iframe. Only the +returned `html` reaches that parser. Canvas passes the original host HTML; Preview applies its +forced light/dark scheme before calling preparation. + +```tsx +import { EditorProvider, EditorShell, type PreparedPreviewDocument } from "@samva/editor/shell"; + +const preparePreviewDocument = (html: string): PreparedPreviewDocument => ({ + html, + mount(document) { + const style = document.createElement("style"); + style.textContent = "body { min-height: 200px; }"; + document.head.appendChild(style); + return () => style.remove(); + }, +}); + + + +; +``` + +Preparation must be pure and synchronous: React can repeat or discard it during render. +Install document resources in `mount`, which runs synchronously after `document.close()` and +before measurement, canvas overlays, or paint. Its returned cleanup belongs to that exact +document and runs on replacement, unmount, and StrictMode effect replay. Mount and cleanup must +support that replay. Asynchronous resource work remains the host's responsibility; the editor +does not wait for it before measuring or painting. + +Keep the preparation callback stable while its policy stays the same. Changing its identity +replaces the frames and their resources without resetting the editor session. Without the prop, +the editor writes the host HTML as supplied (with Preview's existing scheme simulation). +Preparation affects iframe previews only; exported HTML and the HTML view retain host output. + ## Host contract Every host provides a scoped `DocumentReader`. Editable hosts additionally diff --git a/packages/editor/browser-tests/preview-document.spec.ts b/packages/editor/browser-tests/preview-document.spec.ts new file mode 100644 index 0000000..291f068 --- /dev/null +++ b/packages/editor/browser-tests/preview-document.spec.ts @@ -0,0 +1,218 @@ +import { expect, test, type Page } from "@playwright/test"; + +declare global { + interface Window { + previewParser: { + writes: string[]; + violations: string[]; + stages: Array<{ kind: string; document: number; mounted: boolean }>; + }; + } +} + +const installParserProbe = async (page: Page) => { + await page.addInitScript(() => { + window.previewParser = { writes: [], violations: [], stages: [] }; + const descriptor = Object.getOwnPropertyDescriptor( + HTMLIFrameElement.prototype, + "contentDocument", + )!; + const seen = new WeakSet(); + let nextId = 0; + Object.defineProperty(HTMLIFrameElement.prototype, "contentDocument", { + ...descriptor, + get() { + const document: Document | null = descriptor.get!.call(this); + if (document !== null && !seen.has(document)) { + seen.add(document); + const id = ++nextId; + const write = document.write.bind(document); + const close = document.close.bind(document); + document.write = (...html) => { + document.addEventListener("securitypolicyviolation", (event) => { + window.previewParser.violations.push(event.violatedDirective); + }); + window.previewParser.writes.push(html.join("")); + window.previewParser.stages.push({ kind: "write", document: id, mounted: false }); + write(...html); + const body = document.body; + const measure = body.getBoundingClientRect.bind(body); + body.getBoundingClientRect = () => { + window.previewParser.stages.push({ + kind: "measure", + document: id, + mounted: body.dataset.preparationPolicy !== undefined, + }); + return measure(); + }; + }; + document.close = () => { + close(); + window.previewParser.stages.push({ kind: "close", document: id, mounted: false }); + }; + } + return document; + }, + }); + }); +}; + +const settleFrames = async (page: Page) => { + await page.evaluate(async () => { + await Promise.all( + Array.from( + document.querySelectorAll("iframe"), + (frame) => frame.contentDocument!.fonts.ready, + ), + ); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); +}; + +const openFixture = async (page: Page, prepare = true) => { + await installParserProbe(page); + await page.goto(`/preview-document.html${prepare ? "" : "?prepare=off"}`); + await expect(page.locator("iframe")).toHaveCount(1); + await expect(page.locator("iframe").contentFrame().locator("#preparation-target")).toBeVisible(); +}; + +test("prewrite preparation prevents parser font requests and CSP events in canvas and both Preview schemes", async ({ + page, +}) => { + const requests: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/preview-font")) requests.push(request.url()); + }); + await openFixture(page); + await expect(page.locator("iframe")).toHaveJSProperty("clientHeight", 240); + await page.getByTestId("topbar.preview").click(); + await expect(page.locator("iframe")).toHaveCount(2); + await expect( + page.locator("iframe").nth(1).contentFrame().locator("#preparation-target"), + ).toHaveCSS("color", "rgb(255, 0, 0)"); + await page.getByTestId("preview-overlay.scheme.dark").click(); + await expect( + page.locator("iframe").nth(1).contentFrame().locator("#preparation-target"), + ).toHaveCSS("color", "rgb(0, 0, 255)"); + await page.getByTestId("preview-overlay.scheme.light").click(); + await expect( + page.locator("iframe").nth(1).contentFrame().locator("#preparation-target"), + ).toHaveCSS("color", "rgb(255, 0, 0)"); + await settleFrames(page); + const evidence = await page.evaluate(() => ({ + parser: window.previewParser, + lifecycle: window.previewLifecycle, + })); + expect(evidence.parser.writes.length).toBeGreaterThan(0); + expect(evidence.parser.writes.every((html) => !html.includes("/preview-font"))).toBe(true); + expect(requests).toEqual([]); + expect(evidence.parser.violations).toEqual([]); + expect( + evidence.parser.stages + .filter((stage) => stage.kind === "measure") + .every((stage) => stage.mounted), + ).toBe(true); + for (const stage of evidence.parser.stages.filter((candidate) => candidate.kind === "measure")) { + const stages = evidence.parser.stages.filter( + (candidate) => candidate.document === stage.document, + ); + expect(stages.findIndex((candidate) => candidate.kind === "close")).toBeLessThan( + stages.indexOf(stage), + ); + } + expect( + evidence.lifecycle.events + .filter((event) => event.kind === "mount") + .map((event) => event.scheme), + ).toEqual(expect.arrayContaining(["auto", "light", "dark"])); + expect(evidence.lifecycle.duplicates).toBe(0); +}); + +test("default parsing is a positive control for font network and CSP instrumentation", async ({ + page, +}) => { + const requests: string[] = []; + await page.route("**/preview-font-import.css", (route) => + route.fulfill({ + contentType: "text/css", + body: "@font-face{font-family:ImportedFont;src:url('/preview-font-imported.woff2')} h1{font-family:ImportedFont}", + }), + ); + page.on("request", (request) => { + if (request.url().includes("/preview-font")) requests.push(request.url()); + }); + await openFixture(page, false); + await expect.poll(() => requests.length).toBeGreaterThan(0); + await expect + .poll(() => page.evaluate(() => window.previewParser.violations.length)) + .toBeGreaterThan(0); + const evidence = await page.evaluate(() => window.previewParser); + expect(evidence.writes.some((html) => html.includes("/preview-font"))).toBe(true); + expect(evidence.violations).toContain("font-src"); + expect(await page.evaluate(() => window.previewLifecycle.events)).toEqual([]); +}); + +test("StrictMode, selection overlays, revision changes, and callback identity preserve exact resource ownership", async ({ + page, +}) => { + await openFixture(page); + let events = await page.evaluate(() => window.previewLifecycle.events); + expect(events.filter((event) => event.kind === "cleanup").length).toBeGreaterThan(0); + expect(await page.evaluate(() => window.previewLifecycle.live)).toBe(1); + await page.locator("iframe").contentFrame().locator("#preparation-target").click(); + await expect(page.locator("iframe").contentFrame().locator("[data-samva-chip]")).toBeVisible(); + const before = events.length; + await page.getByTestId("preparation.unrelated").click(); + await settleFrames(page); + expect(await page.evaluate(() => window.previewLifecycle.events.length)).toBe(before); + await page.getByTestId("preparation.revision").click(); + await expect + .poll(() => page.evaluate(() => window.previewLifecycle.events.length)) + .toBeGreaterThan(before); + events = await page.evaluate(() => window.previewLifecycle.events); + const revisionMount = events.findLast((event) => event.kind === "mount")!; + await page.getByTestId("preparation.policy").click(); + await expect(page.locator("iframe").contentFrame().locator("body")).toHaveAttribute( + "data-preparation-policy", + "2", + ); + events = await page.evaluate(() => window.previewLifecycle.events); + expect(events.findLast((event) => event.kind === "mount")!.document).not.toBe( + revisionMount.document, + ); + expect( + events.some((event) => event.kind === "cleanup" && event.document === revisionMount.document), + ).toBe(true); + await page.getByTestId("topbar.preview").click(); + await expect.poll(() => page.evaluate(() => window.previewLifecycle.live)).toBe(2); + await page.getByTestId("preview-overlay.close").click(); + await expect.poll(() => page.evaluate(() => window.previewLifecycle.live)).toBe(1); + await page.getByTestId("preparation.unmount").click(); + await expect(page.locator("iframe")).toHaveCount(0); + const lifecycle = await page.evaluate(() => window.previewLifecycle); + expect(lifecycle.live).toBe(0); + expect(lifecycle.duplicates).toBe(0); + expect(lifecycle.events.every((event) => event.intact)).toBe(true); + for (const id of new Set(lifecycle.events.map((event) => event.document))) { + const owned = lifecycle.events.filter((event) => event.document === id); + expect(owned.filter((event) => event.kind === "mount").length).toBe( + owned.filter((event) => event.kind === "cleanup").length, + ); + } +}); + +test("removing preparation disposes resources and restores the original host HTML", async ({ + page, +}) => { + await openFixture(page); + await page.getByTestId("preparation.disable").click(); + await expect( + page.locator("iframe").contentFrame().locator("style[data-test-fonts]"), + ).toBeAttached(); + await expect.poll(() => page.evaluate(() => window.previewLifecycle.live)).toBe(0); + expect( + (await page.evaluate(() => window.previewLifecycle.events)).every((event) => event.intact), + ).toBe(true); +}); diff --git a/packages/editor/dev/preview-document.html b/packages/editor/dev/preview-document.html new file mode 100644 index 0000000..ca65f4f --- /dev/null +++ b/packages/editor/dev/preview-document.html @@ -0,0 +1,11 @@ + + + + + Preview document contract + + +
+ + + diff --git a/packages/editor/dev/preview-document.tsx b/packages/editor/dev/preview-document.tsx new file mode 100644 index 0000000..4532748 --- /dev/null +++ b/packages/editor/dev/preview-document.tsx @@ -0,0 +1,188 @@ +import type { AsyncEditableEditorHost, EmailRender } from "@samva/editor/host"; +import { EditorProvider, EditorShell, type PreparedPreviewDocument } from "@samva/editor/shell"; +import { StrictMode, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; + +import { useEditorStoreApi } from "../src/state/context"; +import { createPromiseHost } from "./promise-host"; + +// oxlint-disable-next-line import/no-unassigned-import -- The browser fixture uses the shell's real styles. +import "./styles.css"; + +interface LifecycleEvent { + readonly kind: "mount" | "cleanup"; + readonly document: number; + readonly policy: number; + readonly intact: boolean; + readonly scheme: string; +} + +declare global { + interface Window { + previewLifecycle: { events: LifecycleEvent[]; live: number; duplicates: number }; + } +} + +window.previewLifecycle = { events: [], live: 0, duplicates: 0 }; +const live = new Set(); +const documentIds = new WeakMap(); +let nextDocumentId = 0; + +const fixtureHtml = (revision: string) => ` + + + +

Prepared preview

+`; + +const withFixture = (render: EmailRender | null): EmailRender | null => + render === null ? null : { ...render, html: fixtureHtml(render.revision) }; + +const createHost = (): AsyncEditableEditorHost => { + const host = createPromiseHost(); + return { + ...host, + document: { + open: async (onChange) => { + const session = await host.document.open((change) => + onChange( + change.channel === "email" ? { ...change, render: withFixture(change.render) } : change, + ), + ); + return { + ...session, + initial: + session.initial.channel === "email" + ? { ...session.initial, render: withFixture(session.initial.render) } + : session.initial, + }; + }, + }, + }; +}; + +function RevisionButton() { + const store = useEditorStoreApi(); + return ( + + ); +} + +const host = createHost(); + +function PreviewDocumentFixture() { + const [policy, setPolicy] = useState(1); + const [mounted, setMounted] = useState(true); + const [unrelated, setUnrelated] = useState(0); + const [enabled, setEnabled] = useState( + new URLSearchParams(location.search).get("prepare") !== "off", + ); + const prepare = useMemo( + () => + (html: string): PreparedPreviewDocument => ({ + html: html.replace(/

Original

"; + +describe("preview document preparation", () => { + it("writes prepared HTML and mounts before the first frame measurement", async () => { + const { host } = await mockEditor(); + const measured: string[] = []; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( + this: HTMLElement, + ) { + if (this.tagName === "BODY") measured.push(this.dataset.mounted ?? "missing"); + return new DOMRect(0, 0, 600, 123); + }); + const prepare = vi.fn((html: string): PreparedPreviewDocument => ({ + html: html.replace("Original", "Prepared"), + mount: (document) => { + expect(document.body.textContent).toBe("Prepared"); + document.body.dataset.mounted = "yes"; + return () => delete document.body.dataset.mounted; + }, + })); + const view = render( + + + , + ); + expect(prepare).toHaveBeenCalledWith(HTML); + expect(measured).toEqual(["yes"]); + expect(view.container.querySelector("iframe")?.style.height).toBe("123px"); + }); + + it("replaces resources for callback identity and original HTML even if preparation is identical", async () => { + const { host } = await mockEditor(); + const mounted: Document[] = []; + const cleaned: Document[] = []; + const prepare = (_html: string): PreparedPreviewDocument => ({ + html: "Same prepared output", + mount: (document) => { + mounted.push(document); + return () => { + cleaned.push(document); + }; + }, + }); + const tree = (html: string, preparation = prepare, width = 600) => ( + + } /> + + ); + const view = render(tree(HTML)); + const firstFrame = view.container.querySelector("iframe"); + view.rerender(tree(HTML, prepare, 320)); + expect(view.container.querySelector("iframe")).toBe(firstFrame); + expect(mounted).toHaveLength(1); + view.rerender(tree(`${HTML}`)); + expect(cleaned).toEqual([mounted[0]]); + expect(mounted[1]).not.toBe(mounted[0]); + view.rerender(tree(`${HTML}`, (html) => prepare(html))); + expect(cleaned).toEqual(mounted.slice(0, 2)); + expect(mounted[2]?.querySelector("[data-overlay]")).not.toBeNull(); + view.unmount(); + expect(cleaned).toEqual(mounted); + }); + + it("balances StrictMode replay and omission restores default HTML", async () => { + const { host } = await mockEditor(); + const live = new Set(); + const mounts = vi.fn((document: Document) => { + expect(live.has(document)).toBe(false); + live.add(document); + return () => { + expect(live.delete(document)).toBe(true); + }; + }); + const prepare = (_html: string): PreparedPreviewDocument => ({ + html: "Prepared", + mount: mounts, + }); + const tree = (preparation?: typeof prepare) => ( + + + + + + ); + const view = render(tree(prepare)); + expect(mounts.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(live.size).toBe(1); + view.rerender(tree()); + expect(live.size).toBe(0); + expect(view.container.querySelector("iframe")?.contentDocument?.body.textContent).toBe( + "Original", + ); + view.unmount(); + expect(live.size).toBe(0); + }); + + it("prepares each forced scheme before parsing and cleans each scheme document", async () => { + const { host } = await mockEditor(); + const cleaned: Document[] = []; + const prepare = vi.fn((html: string): PreparedPreviewDocument => ({ + html, + mount: (document) => () => { + cleaned.push(document); + }, + })); + const tree = (scheme: "light" | "dark") => ( + + + + ); + const view = render(tree("light")); + const light = view.container.querySelector("iframe")!.contentDocument!; + expect(light.head.textContent).toContain("@media not all"); + view.rerender(tree("dark")); + const dark = view.container.querySelector("iframe")!.contentDocument!; + expect(dark.head.textContent).toContain("@media all"); + expect(prepare).toHaveBeenCalledTimes(2); + expect(prepare.mock.calls[0]?.[0]).toContain("@media not all"); + expect(prepare.mock.calls[1]?.[0]).toContain("@media all"); + expect(cleaned).toEqual([light]); + view.unmount(); + expect(cleaned).toEqual([light, dark]); + }); +});