diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index adcac045c8..a4467c0a07 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -1293,6 +1293,10 @@ export function DesignCanvas({ [probeBridgeReadinessUntilDrained], ); const [renderedContent, setRenderedContent] = useState(content); + // What a freshly loaded document already contains, since srcdoc is built from + // it. The load handler below needs this to skip redundant pushes. + const renderedContentRef = useRef(renderedContent); + renderedContentRef.current = renderedContent; // True while a drawing send is capturing/compositing/uploading the // annotated screenshot (see design-canvas/annotation-snapshot.ts). Drives // SharedDrawOverlay's busy Send state so a slow capture can't be triggered @@ -4043,6 +4047,9 @@ export function DesignCanvas({ const replaceLatestRuntimeContent = () => { const nextContent = runtimeReplacementContentRef.current; if (nextContent === undefined) return; + // The document that just loaded was built from these bytes; swapping it + // for itself only costs a blank frame. + if (renderedContentRef.current === nextContent) return; if (replaceRuntimeContentInPlace(nextContent)) { lastRuntimeReplacementKeyRef.current = runtimeReplacementKeyRef.current; lastRuntimeReplacementContentRef.current = nextContent; diff --git a/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts b/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts index 346cb4d8b7..017ead5aa4 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts +++ b/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts @@ -243,6 +243,7 @@ describe("board surface pointer capture", () => { expect( shouldRenderBoardSurfaceStaticPreview({ zoom: 2, + hasSurfaceContent: true, viewportGeometry: viewport, renderGeometry: active, }), @@ -312,6 +313,33 @@ describe("board surface pointer capture", () => { } }); + it("keeps the opaque board replica off when the board has nothing on it", () => { + // A board file can be a full HTML document with an empty — truthy as + // a string, nothing to show. The replica paints itself in the board colour, + // so rendering it there covers the canvas in a full-board slab at low zoom. + const logical = makeGeom(-65536, -65536, 131072, 131072); + const active = makeGeom(-12288, -12288, 24576, 24576); + const viewport = makeGeom(-36000, -22500, 72000, 45000); + + expect( + shouldRenderBoardSurfaceStaticPreview({ + zoom: 2, + hasSurfaceContent: false, + viewportGeometry: viewport, + renderGeometry: active, + }), + ).toBe(false); + // Also below the pre-measurement zoom fallback. + expect( + shouldRenderBoardSurfaceStaticPreview({ + zoom: 2, + hasSurfaceContent: false, + viewportGeometry: null, + renderGeometry: active, + }), + ).toBe(false); + }); + it("treats empty board documents as having no surface content", () => { expect( hasBoardSurfaceContent( diff --git a/templates/design/app/components/design/MultiScreenCanvas.tsx b/templates/design/app/components/design/MultiScreenCanvas.tsx index 2acd776a24..821d219621 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.tsx @@ -243,7 +243,10 @@ import { sameResolvedMetadata, type ResolvedMetadataCacheEntry, } from "./multi-screen/screen-content-cache"; -import { setWheelCameraGestureActive } from "./multi-screen/wheel-gesture-state"; +import { + isWheelCameraGestureActive, + setWheelCameraGestureActive, +} from "./multi-screen/wheel-gesture-state"; // Figma parity: a plain click (no drag) with the rectangle or ellipse tool // places a 100x100 shape. Both tools share this default via the "else" @@ -339,6 +342,13 @@ import { screenPxToCanvasPx, stopPercentFromDraggedPoint, } from "./multi-screen/gradient-overlay-geometry"; +import { + applyScreenPaintSuppression, + collectScreenPaintTargets, + resolveSuppressedScreenIds, + type ScreenPaintCandidate, + type ScreenPaintTarget, +} from "./multi-screen/paint-suppression"; import { getPrimitiveDropTargetForPoint, getPrimitiveLowZoomHitRect, @@ -648,26 +658,33 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ : null, [boardFrameGeometry], ); + // Both board layers must ask this one question: an empty is a truthy + // string, and the replica is opaque, so a string-only gate slabs the board. + const boardSurfaceHtml = hasBoardSurfaceContent(boardFileContent) + ? boardFileContent + : undefined; + const boardHasSurfaceContent = boardSurfaceHtml !== undefined; const boardStaticPreviewContent = useMemo(() => { if ( !boardFrameGeometry || !boardStaticPreviewViewport || - !boardFileContent + !boardSurfaceHtml ) { return null; } return getBoardSurfaceStaticPreviewContent({ - html: boardFileContent, + html: boardSurfaceHtml, logicalGeometry: boardFrameGeometry, viewport: boardStaticPreviewViewport, }); - }, [boardFileContent, boardFrameGeometry, boardStaticPreviewViewport]); + }, [boardSurfaceHtml, boardFrameGeometry, boardStaticPreviewViewport]); const showBoardStaticPreview = Boolean( boardFrameGeometry && boardSurfaceRenderGeometry && boardStaticPreviewContent && shouldRenderBoardSurfaceStaticPreview({ zoom: canvasZoom, + hasSurfaceContent: boardHasSurfaceContent, viewportGeometry: boardViewportGeometry, renderGeometry: boardSurfaceRenderGeometry, }), @@ -957,6 +974,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const liveScreenIdsRef = useRef>(new Set()); const lastVisibleEpochByScreenIdRef = useRef>(new Map()); const cullAccessEpochRef = useRef(0); + const wheelGestureFilteredIframesRef = useRef([]); + // Paint suppression is resolved imperatively against the live camera, so its + // inputs live in refs readable from applyViewToDom's render-free tick. + const screenPaintCandidatesRef = useRef([]); + const screenPaintTargetsRef = useRef([]); + const surfaceSizeRef = useRef({ width: 0, height: 0 }); useEffect(() => { const liveScreenIds = new Set(screens.map((screen) => screen.id)); for (const id of hasBeenVisibleScreenIdsRef.current) { @@ -989,6 +1012,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const surface = surfaceRef.current; if (!surface) return; const updateSize = (width: number, height: number) => { + surfaceSizeRef.current = { width, height }; setSurfaceSize((current) => current.width === width && current.height === height ? current @@ -6636,6 +6660,24 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ [activeTool, localActiveTool, updateAltHoverMeasurement, updatePenPointer], ); + // Overscan is deliberately excluded: this answers "is it on screen right + // now", and anything off screen costs nothing to leave unpainted. + const syncScreenPaintSuppression = useCallback(() => { + applyScreenPaintSuppression( + screenPaintTargetsRef.current, + resolveSuppressedScreenIds( + screenPaintCandidatesRef.current, + getOverscannedViewportCanvasBounds( + surfaceSizeRef.current, + panRef.current, + zoomRef.current, + 0, + ), + ), + { relaxOnly: isWheelCameraGestureActive() }, + ); + }, []); + // Push the current pan/zoom straight to the DOM. A wheel/pinch gesture must // NEVER re-render React's canvas tree during the gesture: each render re-runs // renderScreenContent (which re-creates the active screen's live DesignCanvas @@ -6681,7 +6723,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ marqueeOverlay.style.width = `${Math.max(1, activeMarquee.width * nextScale)}px`; marqueeOverlay.style.height = `${Math.max(1, activeMarquee.height * nextScale)}px`; } - }, []); + // Same tick as the transform: a screen this move brings on screen must + // paint in the frame it becomes visible, not at the debounced commit. + syncScreenPaintSuppression(); + }, [syncScreenPaintSuppression]); const startChromeSettle = useCallback(() => { if (chromeSettleTimerRef.current !== null) { @@ -6707,6 +6752,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ if (wheelGestureActiveRef.current) { wheelGestureActiveRef.current = false; setWheelCameraGestureActive(false); + wheelGestureFilteredIframesRef.current.forEach((iframe) => { + if (iframe.isConnected) iframe.style.filter = ""; + }); + wheelGestureFilteredIframesRef.current = []; const muted = wheelGestureMutedElementsRef.current; wheelGestureMutedElementsRef.current = null; if (muted) { @@ -6857,6 +6906,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ element.style.pointerEvents = "none"; }); wheelGestureMutedElementsRef.current = muted; + // A nested frame re-rasters on every scale change no matter what layer the + // canvas gets, starving the renderer. A filter — a no-op at this radius — + // gives each one a surface the compositor scales from cache. commitView must + // clear it, or they stay soft at rest. + const filtered: HTMLElement[] = []; + surface + .querySelectorAll("[data-screen-content] iframe") + .forEach((iframe) => { + iframe.style.filter = "blur(0.001px)"; + filtered.push(iframe); + }); + wheelGestureFilteredIframesRef.current = filtered; }, [cancelPendingStaticBoardSelection]); const flushPendingWheelGesture = useCallback(() => { @@ -7607,23 +7668,23 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ pan, canvasZoom, ); + const candidates = canvasFrames.map(({ screen, metadata, geometry }) => ({ + id: screen.id, + geometry: getResponsiveScreenCullGeometry( + screen, + geometry, + (widthPx) => + measuredIframeHeights[getBreakpointIframeId(screen.id, widthPx)], + ), + // Count only the breakpoint frames actually mounted (the row filters + // duplicates of the device width) so the iframe budget isn't + // over-consumed, prematurely evicting visible frames. + iframeCount: + 1 + + visibleBreakpointWidths(screen.breakpointWidths, metadata.width).length, + })); const next = computeBoundedScreenCullState({ - candidates: canvasFrames.map(({ screen, metadata, geometry }) => ({ - id: screen.id, - geometry: getResponsiveScreenCullGeometry( - screen, - geometry, - (widthPx) => - measuredIframeHeights[getBreakpointIframeId(screen.id, widthPx)], - ), - // Count only the breakpoint frames actually mounted (the row filters - // duplicates of the device width) so the iframe budget isn't - // over-consumed, prematurely evicting visible frames. - iframeCount: - 1 + - visibleBreakpointWidths(screen.breakpointWidths, metadata.width) - .length, - })), + candidates, viewport, protectedScreenIds: protectedLiveScreenIds, previousLiveScreenIds: liveScreenIdsRef.current, @@ -7634,6 +7695,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ liveScreenIdsRef.current = next.liveScreenIds; hasBeenVisibleScreenIdsRef.current = next.everVisibleScreenIds; lastVisibleEpochByScreenIdRef.current = next.lastVisibleEpochByScreenId; + screenPaintCandidatesRef.current = candidates.map(({ id, geometry }) => ({ + id, + geometry, + tier: next.tierByScreenId.get(id) ?? "visible", + })); return next.tierByScreenId; }, [ canvasFrames, @@ -7643,6 +7709,16 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ protectedLiveScreenIds, surfaceSize, ]); + // No dependency array on purpose: any render can mount, unmount, or reorder a + // screen's content wrapper, and a wrapper React just mounted carries none of + // the suppression state this owns. + useEffect(() => { + screenPaintTargetsRef.current = collectScreenPaintTargets( + surfaceRef.current, + ); + syncScreenPaintSuppression(); + }); + const topScreenId = useMemo( () => selectedIds.find((id) => @@ -7889,7 +7965,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ {boardFileId && boardFileContent !== undefined && - hasBoardSurfaceContent(boardFileContent) && + boardHasSurfaceContent && (() => { const boardGeo = boardSurfaceRenderGeometry ?? { x: 0, @@ -9440,18 +9516,10 @@ const Screen = memo(function Screen({ const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content); const previewViewport = getScreenPreviewViewport(metadata, geometry); const suppressNextClick = useRef(false); - // Overview viewport culling (PF22): a "culled" screen keeps its content - // (iframe/DesignCanvas) fully mounted — unmounting would lose all internal - // iframe state (scroll position, form input, in-progress Alpine/JS state) - // — but skips paint/layout cost for it via visibility:hidden + - // contentVisibility:"hidden" on the content wrapper. Deliberately NOT - // display:none (can drop layout/scroll state on some engines) and NOT - // will-change (a prior perf attempt using will-change on this same overview - // caused permanent blur by pinning a low-res compositor layer — see the - // world-transform comment near applyViewToDom). "placeholder" and - // "evicted" tiers have no mounted browsing context, so they render the - // chrome-only placeholder below regardless of this flag. - const { shouldMount: shouldMountContent, isHidden: isCulled } = + // Overview viewport culling (PF22): mounting only. Unmounting a culled screen + // would lose its iframe's scroll/form/Alpine state, so it stays mounted and + // paint-suppression.ts decides paint against the live camera instead. + const { shouldMount: shouldMountContent } = getScreenContentCullState(cullTier); const [directlyHovered, setDirectlyHovered] = useState(false); const frameDirectlyHovered = @@ -9778,16 +9846,6 @@ const Screen = memo(function Screen({ )} style={{ pointerEvents: screenContentInteractive ? "auto" : "none", - // Tier B (PF22): keep the mounted iframe/DesignCanvas alive (see - // the isCulled comment above) but skip its paint/layout cost. - // visibility:hidden (not display:none) keeps the box in the - // layout/measurement tree; contentVisibility:"hidden" skips - // rendering its subtree entirely until it's shown again. Neither - // property touches will-change/compositor layers, so this can't - // reproduce the permanent-blur regression a prior will-change - // attempt on this same surface caused. - visibility: isCulled ? "hidden" : undefined, - contentVisibility: isCulled ? "hidden" : undefined, }} > {!shouldMountContent ? ( @@ -10159,7 +10217,7 @@ function BreakpointPreviewRow({ // draft value of its width input. const [menuOpenForWidth, setMenuOpenForWidth] = useState(null); const [widthDraft, setWidthDraft] = useState(""); - const { shouldMount: shouldMountContent, isHidden: isCulled } = + const { shouldMount: shouldMountContent } = getScreenContentCullState(cullTier); return ( @@ -10468,10 +10526,6 @@ function BreakpointPreviewRow({ data-screen-content data-cull-tier={cullTier} className="relative block h-full w-full overflow-hidden rounded-[inherit] bg-white ring-1 ring-inset ring-border" - style={{ - visibility: isCulled ? "hidden" : undefined, - contentVisibility: isCulled ? "hidden" : undefined, - }} > {!shouldMountContent ? (
args.renderGeometry.width || diff --git a/templates/design/app/components/design/multi-screen/paint-suppression.test.ts b/templates/design/app/components/design/multi-screen/paint-suppression.test.ts new file mode 100644 index 0000000000..a30df85986 --- /dev/null +++ b/templates/design/app/components/design/multi-screen/paint-suppression.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment happy-dom + +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import type { OverscannedViewportBounds } from "./culling"; +import { + applyScreenPaintSuppression, + resolveSuppressedScreenIds, + type ScreenPaintCandidate, +} from "./paint-suppression"; + +const frame = (x: number): ScreenPaintCandidate["geometry"] => ({ + x, + y: 0, + width: 100, + height: 100, +}); + +const viewport = (left: number, right: number): OverscannedViewportBounds => ({ + left, + top: -1_000, + right, + bottom: 1_000, +}); + +describe("overview paint suppression", () => { + it("keeps a culled screen painting once the live camera brings it on screen", () => { + const candidates: ScreenPaintCandidate[] = [ + { id: "on-screen", geometry: frame(0), tier: "culled" }, + { id: "far-away", geometry: frame(10_000), tier: "culled" }, + ]; + + expect([ + ...resolveSuppressedScreenIds(candidates, viewport(-50, 400)), + ]).toEqual(["far-away"]); + }); + + it("never suppresses a tier that owns no hideable content", () => { + const outside = viewport(50_000, 60_000); + for (const tier of ["visible", "placeholder", "evicted"] as const) { + expect( + resolveSuppressedScreenIds( + [{ id: "screen", geometry: frame(0), tier }], + outside, + ).size, + ).toBe(0); + } + }); + + it("suppresses nothing until the surface has been measured", () => { + expect( + resolveSuppressedScreenIds( + [{ id: "screen", geometry: frame(10_000), tier: "culled" }], + null, + ).size, + ).toBe(0); + }); + + it("writes a freshly mounted wrapper and leaves an unchanged one alone", () => { + const element = document.createElement("span"); + const targets = [{ element, screenId: "screen" }]; + + applyScreenPaintSuppression(targets, new Set(["screen"])); + expect(element.style.visibility).toBe("hidden"); + // content-visibility discards rendering state, so un-hiding would blank the + // iframe for a frame. visibility alone skips paint and restores instantly. + expect(element.style.getPropertyValue("content-visibility")).toBe(""); + + element.style.setProperty("visibility", "visible"); + applyScreenPaintSuppression(targets, new Set(["screen"])); + expect(element.style.visibility).toBe("visible"); + + applyScreenPaintSuppression(targets, new Set()); + expect(element.style.visibility).toBe(""); + expect(element.style.getPropertyValue("content-visibility")).toBe(""); + }); + + it("leaves paint suppression to one owner outside React's render path", () => { + const source = readFileSync( + "app/components/design/MultiScreenCanvas.tsx", + "utf8", + ); + // Two owners drift: React's style diff stops re-applying a value it thinks + // is unchanged, so suppression silently dies with nothing visibly broken. + expect(source).not.toContain("contentVisibility:"); + expect(source).not.toContain("visibility: isCulled"); + // A camera move that skips this leaves screens hidden until the debounced + // commit — the zoom flicker this whole module exists for. + const applyViewToDom = source.slice( + source.indexOf("const applyViewToDom = useCallback("), + ); + expect(applyViewToDom.slice(0, applyViewToDom.indexOf("}, ["))).toContain( + "syncScreenPaintSuppression();", + ); + }); +}); diff --git a/templates/design/app/components/design/multi-screen/paint-suppression.ts b/templates/design/app/components/design/multi-screen/paint-suppression.ts new file mode 100644 index 0000000000..1687be579a --- /dev/null +++ b/templates/design/app/components/design/multi-screen/paint-suppression.ts @@ -0,0 +1,85 @@ +import { + getScreenContentCullState, + isFrameWithinOverscannedViewport, + type OverscannedViewportBounds, + type ScreenCullTier, +} from "./culling"; +import type { FrameGeometry } from "./types"; + +// Zoom scales the world viewport rather than translating it, so no overscan +// covers a gesture's lag. React must never also write visibility here: its style +// diff would stop re-applying a value this owner has since cleared. + +/** Lets a freshly mounted wrapper still be written while an unchanged one is + * skipped. */ +const SUPPRESSED_MARKER_ATTRIBUTE = "screenPaintSuppressed"; + +export interface ScreenPaintCandidate { + id: string; + geometry: FrameGeometry; + tier: ScreenCullTier; +} + +export interface ScreenPaintTarget { + element: HTMLElement; + screenId: string; +} + +/** Every content wrapper belonging to a screen, including its breakpoint + * previews — paint is decided per screen, so the whole shell moves together. */ +export function collectScreenPaintTargets( + surface: HTMLElement | null, +): ScreenPaintTarget[] { + if (!surface) return []; + const targets: ScreenPaintTarget[] = []; + surface + .querySelectorAll("[data-screen-content]") + .forEach((element) => { + const screenId = element.closest("[data-screen-shell]") + ?.dataset.frameId; + if (screenId) targets.push({ element, screenId }); + }); + return targets; +} + +/** Screens whose paint the browser may skip for the camera passed in. A + * `liveViewport` of `null` (surface not measured yet) suppresses nothing. */ +export function resolveSuppressedScreenIds( + candidates: readonly ScreenPaintCandidate[], + liveViewport: OverscannedViewportBounds | null, +): Set { + const suppressed = new Set(); + if (!liveViewport) return suppressed; + for (const candidate of candidates) { + if (!getScreenContentCullState(candidate.tier).isHidden) continue; + if (isFrameWithinOverscannedViewport(candidate.geometry, liveViewport)) { + continue; + } + suppressed.add(candidate.id); + } + return suppressed; +} + +export function applyScreenPaintSuppression( + targets: readonly ScreenPaintTarget[], + suppressedScreenIds: ReadonlySet, + options?: { relaxOnly?: boolean }, +): void { + for (const { element, screenId } of targets) { + const suppress = suppressedScreenIds.has(screenId); + const applied = element.dataset[SUPPRESSED_MARKER_ATTRIBUTE] === "true"; + if (applied === suppress) continue; + // Mid-gesture, only relax: hiding a screen the camera is still crossing + // guarantees an unpainted frame when it returns. + if (options?.relaxOnly && suppress) continue; + if (suppress) { + // Never content-visibility: it discards the subtree's rendering state, so + // un-hiding blanks the iframe for a frame. + element.style.setProperty("visibility", "hidden"); + element.dataset[SUPPRESSED_MARKER_ATTRIBUTE] = "true"; + } else { + element.style.removeProperty("visibility"); + delete element.dataset[SUPPRESSED_MARKER_ATTRIBUTE]; + } + } +} diff --git a/templates/design/changelog/2026-08-03-zoom-screen-blink.md b/templates/design/changelog/2026-08-03-zoom-screen-blink.md new file mode 100644 index 0000000000..b19e80cb3b --- /dev/null +++ b/templates/design/changelog/2026-08-03-zoom-screen-blink.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-03 +--- + +Screens no longer blink out and back in while zooming a multi-screen canvas.