Skip to content

Commit f698ea2

Browse files
committed
fix(comment): address review feedback on pin-follow-scroll
- Fix first-mount race: WATCH_SELECTORS could be posted before the iframe overlay's message listener had installed. Thread an onIframeLoaded callback up from PreviewSlot into a load-tick state the effect depends on, so switching back to a design with existing pins now re-registers watchers and the pins track scroll from the first frame. - Clamp ELEMENT_RECTS.entries to MAX_ELEMENT_RECTS_ENTRIES (256) in the validator so hostile LLM HTML can't flood the parent's liveRects map by spoofing the message envelope. - Replace three inline zoom-scaling blocks with scaleRectForZoom. - Delete the legacy pinStyle(comment, zoom) wrapper (prod now uses pinStyleFromRect directly); migrate its tests to the new signature.
1 parent 87f996d commit f698ea2

5 files changed

Lines changed: 54 additions & 27 deletions

File tree

apps/desktop/src/renderer/src/components/PreviewPane.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,22 @@ describe('handlePreviewMessage trust boundary', () => {
149149
expect(outcome.status).toBe('rejected');
150150
expect(handlers.onElementRects).not.toHaveBeenCalled();
151151
});
152+
153+
it('rejects ELEMENT_RECTS whose entries array exceeds the hard cap', () => {
154+
// An LLM-controlled iframe script could try to flood liveRects. Validator
155+
// should drop the message before it reaches the handler.
156+
const handlers = makeHandlers();
157+
const entries = Array.from({ length: 257 }, (_, i) => ({
158+
selector: `#a${i}`,
159+
rect: { top: 0, left: 0, width: 1, height: 1 },
160+
}));
161+
const outcome = handlePreviewMessage(
162+
{ __codesign: true, type: 'ELEMENT_RECTS', entries },
163+
handlers,
164+
);
165+
expect(outcome.status).toBe('rejected');
166+
expect(handlers.onElementRects).not.toHaveBeenCalled();
167+
});
152168
});
153169

154170
describe('postModeToPreviewWindow', () => {

apps/desktop/src/renderer/src/components/PreviewPane.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
isIframeErrorMessage,
99
isOverlayMessage,
1010
} from '@open-codesign/runtime';
11-
import { useCallback, useEffect, useMemo, useRef } from 'react';
11+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1212
import { EmptyState } from '../preview/EmptyState';
1313
import { ErrorState } from '../preview/ErrorState';
1414
import { useCodesignStore } from '../store';
@@ -134,6 +134,7 @@ interface PreviewSlotProps {
134134
interactionMode: string;
135135
registerIframe: (designId: string, el: HTMLIFrameElement | null) => void;
136136
onIframeError: (message: string) => void;
137+
onIframeLoaded: (designId: string) => void;
137138
}
138139

139140
// One iframe per pool entry. Hidden (display:none) when not active, but kept
@@ -153,6 +154,7 @@ function PreviewSlot({
153154
interactionMode,
154155
registerIframe,
155156
onIframeError,
157+
onIframeLoaded,
156158
}: PreviewSlotProps) {
157159
const srcDocStableKey = useMemo(() => {
158160
return html
@@ -193,6 +195,10 @@ function PreviewSlot({
193195
if (!active) return;
194196
const target = e.currentTarget as HTMLIFrameElement;
195197
postModeToPreviewWindow(target.contentWindow, interactionMode, onIframeError);
198+
// The parent's WATCH_SELECTORS post can race past a freshly-mounted
199+
// iframe before its message listener installs. Ping the parent so it
200+
// re-broadcasts after load has confirmed the overlay is live.
201+
onIframeLoaded(designId);
196202
}}
197203
className={
198204
isMobile
@@ -294,6 +300,10 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
294300
// or the active iframe element re-mounts.
295301
const iframeRef = useRef<HTMLIFrameElement | null>(null);
296302
const iframesByDesign = useRef<Map<string, HTMLIFrameElement>>(new Map());
303+
// Bumped every time the active iframe fires onLoad — used to re-trigger
304+
// the WATCH_SELECTORS effect so we don't race past overlay installation
305+
// on first mount.
306+
const [iframeLoadTick, setIframeLoadTick] = useState(0);
297307

298308
const registerIframe = useCallback((designId: string, el: HTMLIFrameElement | null) => {
299309
if (el) {
@@ -303,6 +313,13 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
303313
}
304314
}, []);
305315

316+
const handleIframeLoaded = useCallback(
317+
(designId: string) => {
318+
if (designId === currentDesignId) setIframeLoadTick((t) => t + 1);
319+
},
320+
[currentDesignId],
321+
);
322+
306323
// When the active design changes, retarget iframeRef and re-broadcast the
307324
// current interaction mode. Background iframes keep their last mode — fine,
308325
// they're inert until reactivated.
@@ -325,7 +342,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
325342
// Selectors: all comments on the current snapshot + the active bubble's
326343
// selector (usually the freshly-pinned one, included for the moment
327344
// between click and save).
328-
// biome-ignore lint/correctness/useExhaustiveDependencies: currentDesignId is intentional — iframeRef.current is a ref so biome can't see that it changes with the active design. When the design switches we MUST resend WATCH_SELECTORS to the newly-active iframe; the currentDesignId dependency is what triggers that re-run.
345+
// biome-ignore lint/correctness/useExhaustiveDependencies: currentDesignId and iframeLoadTick are deliberate triggers — iframeRef.current is a ref so biome can't see it swap when the active design changes, and we must wait for the iframe's onLoad before the overlay's message listener exists (otherwise the post is dropped).
329346
useEffect(() => {
330347
const win = iframeRef.current?.contentWindow;
331348
if (!win) return;
@@ -344,7 +361,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
344361
} catch {
345362
/* sandbox gone — retry happens next render */
346363
}
347-
}, [comments, currentSnapshotId, commentBubble, currentDesignId]);
364+
}, [comments, currentSnapshotId, commentBubble, currentDesignId, iframeLoadTick]);
348365

349366
useEffect(() => {
350367
function onMessage(event: MessageEvent): void {
@@ -429,12 +446,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
429446
selector: c.selector,
430447
tag: c.tag,
431448
outerHTML: c.outerHTML,
432-
rect: {
433-
top: live.top * (previewZoom / 100),
434-
left: live.left * (previewZoom / 100),
435-
width: live.width * (previewZoom / 100),
436-
height: live.height * (previewZoom / 100),
437-
},
449+
rect: scaleRectForZoom(live, previewZoom),
438450
existingCommentId: c.id,
439451
initialText: c.text,
440452
});
@@ -494,6 +506,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
494506
interactionMode={interactionMode}
495507
registerIframe={registerIframe}
496508
onIframeError={pushIframeError}
509+
onIframeLoaded={handleIframeLoaded}
497510
/>
498511
))}
499512
{!activeHasHtml ? (
@@ -530,12 +543,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
530543
? (() => {
531544
const liveForBubble = liveRects[commentBubble.selector];
532545
const scaled = liveForBubble
533-
? {
534-
top: liveForBubble.top * (previewZoom / 100),
535-
left: liveForBubble.left * (previewZoom / 100),
536-
width: liveForBubble.width * (previewZoom / 100),
537-
height: liveForBubble.height * (previewZoom / 100),
538-
}
546+
? scaleRectForZoom(liveForBubble, previewZoom)
539547
: commentBubble.rect;
540548
return (
541549
<CommentBubble

apps/desktop/src/renderer/src/components/comment/PinOverlay.test.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CommentRow } from '@open-codesign/shared';
22
import { describe, expect, it } from 'vitest';
3-
import { pinStyle, pinStyleFromRect, variantFor } from './PinOverlay';
3+
import { pinStyleFromRect, variantFor } from './PinOverlay';
44

55
function comment(overrides: Partial<CommentRow> = {}): CommentRow {
66
return {
@@ -38,23 +38,22 @@ describe('variantFor', () => {
3838
});
3939
});
4040

41-
describe('pinStyle', () => {
41+
describe('pinStyleFromRect', () => {
4242
it('anchors to the top-right corner of the rect, offset by 10px', () => {
43-
const pos = pinStyle(comment({ rect: { top: 100, left: 50, width: 80, height: 40 } }), 100);
43+
const pos = pinStyleFromRect({ top: 100, left: 50, width: 80, height: 40 }, 100);
4444
// top = 100 - 10 = 90; left = 50 + 80 - 10 = 120
4545
expect(pos).toEqual({ top: '90px', left: '120px' });
4646
});
4747

4848
it('scales with zoom', () => {
49-
const pos = pinStyle(comment({ rect: { top: 100, left: 50, width: 80, height: 40 } }), 50);
49+
const pos = pinStyleFromRect({ top: 100, left: 50, width: 80, height: 40 }, 50);
5050
// scale = 0.5; top = 100*0.5 - 10 = 40; left = 50*0.5 + 80*0.5 - 10 = 55
5151
expect(pos).toEqual({ top: '40px', left: '55px' });
5252
});
5353

54-
it('pinStyleFromRect uses the supplied rect instead of the stored one', () => {
55-
// This is the path PinOverlay takes when liveRects has an entry for the
56-
// comment's selector: the badge stays glued to the element's current
57-
// position in the iframe viewport, even after the user scrolls.
54+
it('takes a rect argument so the overlay can substitute a live rect', () => {
55+
// PinOverlay calls this with liveRects[selector] ?? stored — what keeps
56+
// the badge glued to the element after iframe scroll.
5857
const live = pinStyleFromRect({ top: 10, left: 5, width: 80, height: 40 }, 100);
5958
expect(live).toEqual({ top: '0px', left: '75px' });
6059
});

apps/desktop/src/renderer/src/components/comment/PinOverlay.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,6 @@ export function pinStyleFromRect(rect: CommentRect, zoom: number): { top: string
6161
return { top: `${top}px`, left: `${left}px` };
6262
}
6363

64-
export function pinStyle(comment: CommentRow, zoom: number): { top: string; left: string } {
65-
return pinStyleFromRect(comment.rect, zoom);
66-
}
67-
6864
export function PinOverlay({ comments, zoom, onPinClick, liveRects }: PinOverlayProps) {
6965
const t = useT();
7066
if (comments.length === 0) return null;

packages/runtime/src/overlay.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,11 +334,19 @@ export interface ElementRectsMessage {
334334
}>;
335335
}
336336

337+
/** Hard ceiling on entries per ELEMENT_RECTS message. The iframe runs LLM
338+
* HTML; even though our overlay is trusted, untrusted in-iframe code can
339+
* synthesise a matching envelope. Cap worst-case memory growth in the
340+
* parent's liveRects store. Chosen generously — a design with 256 tracked
341+
* pins is already beyond any realistic review session. */
342+
export const MAX_ELEMENT_RECTS_ENTRIES = 256;
343+
337344
export function isElementRectsMessage(data: unknown): data is ElementRectsMessage {
338345
if (typeof data !== 'object' || data === null) return false;
339346
const d = data as { __codesign?: boolean; type?: string; entries?: unknown };
340347
if (d.__codesign !== true || d.type !== 'ELEMENT_RECTS') return false;
341348
if (!Array.isArray(d.entries)) return false;
349+
if (d.entries.length > MAX_ELEMENT_RECTS_ENTRIES) return false;
342350
for (const e of d.entries) {
343351
if (typeof e !== 'object' || e === null) return false;
344352
const entry = e as { selector?: unknown; rect?: unknown };

0 commit comments

Comments
 (0)