Skip to content

Commit e96940a

Browse files
fix: guard Node.contains() against non-Node event targets (#101) (#103)
Co-authored-by: Ona <no-reply@ona.com>
1 parent da2c37a commit e96940a

4 files changed

Lines changed: 106 additions & 6 deletions

File tree

.agents/conventions.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,29 @@ For the full automation roster and workflow details, see `.ona/skills/developmen
504504
- No `as` casts unless unavoidable (add a comment explaining why)
505505
- Prefer interfaces for object shapes, types for unions/intersections
506506

507+
### DOM event target safety
508+
509+
`MouseEvent.target`, `MouseEvent.relatedTarget`, and `FocusEvent.relatedTarget` are
510+
typed as `EventTarget | null`. They can be non-Node objects (e.g. when the mouse
511+
leaves to a cross-origin iframe or the browser window). Never cast them with
512+
`as Node` or `as HTMLElement` — use `instanceof` guards instead.
513+
514+
```typescript
515+
// ✅ Correct — instanceof guard before DOM API call
516+
const target = event.relatedTarget;
517+
if (target instanceof Node && container.contains(target)) { ... }
518+
519+
// ✅ Correct — instanceof guard before property access
520+
const target = event.target;
521+
if (!(target instanceof HTMLElement)) return;
522+
target.closest(".menu");
523+
524+
// ❌ Wrong — unsafe cast, throws TypeError if target is not a Node
525+
container.contains(event.target as Node)
526+
```
527+
528+
A static analysis test (`node-contains-safety.test.ts`) enforces this convention.
529+
507530
## shadcn/ui (base-nova style)
508531

509532
This project uses shadcn/ui v4 with the `base-nova` style, which uses `@base-ui/react`

src/components/editor/draggable-block-plugin.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ export function DraggableBlockPlugin({
164164
// Track the hovered block element for showing the drag handle
165165
const handleMouseMove = useCallback(
166166
(event: MouseEvent) => {
167-
const target = event.target as HTMLElement;
167+
const target = event.target;
168+
if (!(target instanceof HTMLElement)) return;
168169
if (isOnMenu(target) || isDraggingRef.current) return;
169170

170171
const blockElem = getBlockElement(anchorElem, editor, event);
@@ -182,8 +183,9 @@ export function DraggableBlockPlugin({
182183
const handleMouseLeave = useCallback((event: MouseEvent) => {
183184
if (menuRef.current && !isDraggingRef.current) {
184185
// Don't hide if the mouse moved to the drag handle itself
185-
const relatedTarget = event.relatedTarget as HTMLElement | null;
186-
if (relatedTarget && isOnMenu(relatedTarget)) return;
186+
const relatedTarget = event.relatedTarget;
187+
if (relatedTarget instanceof HTMLElement && isOnMenu(relatedTarget))
188+
return;
187189

188190
menuRef.current.style.opacity = "0";
189191
targetBlockElemRef.current = null;
@@ -203,8 +205,10 @@ export function DraggableBlockPlugin({
203205
(event: React.MouseEvent) => {
204206
if (isDraggingRef.current) return;
205207
// Don't hide if the mouse moved back into the anchor element
206-
const relatedTarget = event.relatedTarget as HTMLElement | null;
207-
if (relatedTarget && anchorElem.contains(relatedTarget)) return;
208+
// relatedTarget can be a non-Node EventTarget (e.g. cross-origin iframe)
209+
const relatedTarget = event.relatedTarget;
210+
if (relatedTarget instanceof Node && anchorElem.contains(relatedTarget))
211+
return;
208212

209213
if (menuRef.current) {
210214
menuRef.current.style.opacity = "0";
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readFileSync, readdirSync, statSync } from "fs";
3+
import { resolve, join } from "path";
4+
5+
/**
6+
* Regression tests for issue #101: TypeError in Node.contains() from event handlers.
7+
*
8+
* MouseEvent.relatedTarget and Event.target are typed as EventTarget | null,
9+
* which can be a non-Node object (e.g. when mouse leaves to a cross-origin
10+
* iframe). Casting these to Node/HTMLElement and passing them to Node.contains()
11+
* or Element.closest() throws a TypeError.
12+
*
13+
* These tests scan source files for unsafe `as Node` or `as HTMLElement` casts
14+
* on event target properties, ensuring instanceof guards are used instead.
15+
*/
16+
17+
/** Recursively collect .ts/.tsx files under a directory */
18+
function collectSourceFiles(dir: string): string[] {
19+
const files: string[] = [];
20+
for (const entry of readdirSync(dir)) {
21+
const full = join(dir, entry);
22+
if (statSync(full).isDirectory()) {
23+
files.push(...collectSourceFiles(full));
24+
} else if (/\.tsx?$/.test(entry) && !entry.includes(".test.")) {
25+
files.push(full);
26+
}
27+
}
28+
return files;
29+
}
30+
31+
/**
32+
* Matches patterns like:
33+
* event.target as Node
34+
* event.relatedTarget as HTMLElement
35+
* e.target as HTMLElement | null
36+
* event.relatedTarget as HTMLElement | null
37+
*
38+
* These are unsafe because EventTarget is not necessarily a Node.
39+
*/
40+
const UNSAFE_EVENT_TARGET_CAST =
41+
/\.\s*(?:target|relatedTarget)\s+as\s+(?:Node|HTMLElement)(?:\s*\|\s*null)?/g;
42+
43+
describe("DOM API type safety — no unsafe event target casts", () => {
44+
const srcRoot = resolve(__dirname, "../..");
45+
const sourceFiles = collectSourceFiles(join(srcRoot, "components"));
46+
47+
it("no files cast event.target or event.relatedTarget as Node/HTMLElement", () => {
48+
const violations: string[] = [];
49+
50+
for (const filePath of sourceFiles) {
51+
const content = readFileSync(filePath, "utf-8");
52+
const lines = content.split("\n");
53+
54+
for (let i = 0; i < lines.length; i++) {
55+
const matches = lines[i].match(UNSAFE_EVENT_TARGET_CAST);
56+
if (matches) {
57+
const relative = filePath.replace(srcRoot + "/", "");
58+
violations.push(`${relative}:${i + 1}: ${matches[0].trim()}`);
59+
}
60+
}
61+
}
62+
63+
expect(
64+
violations,
65+
"Use `instanceof Node` or `instanceof HTMLElement` guards instead of `as` casts on event targets. " +
66+
"See issue #101.\n\nViolations:\n" +
67+
violations.join("\n")
68+
).toHaveLength(0);
69+
});
70+
});

src/components/sidebar/page-search.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,12 @@ export function PageSearch() {
110110
// Close on click outside
111111
useEffect(() => {
112112
function handleClickOutside(e: MouseEvent) {
113+
// e.target can be a non-Node EventTarget (e.g. cross-origin iframe)
114+
const target = e.target;
113115
if (
114116
containerRef.current &&
115-
!containerRef.current.contains(e.target as Node)
117+
(!(target instanceof Node) ||
118+
!containerRef.current.contains(target))
116119
) {
117120
setOpen(false);
118121
}

0 commit comments

Comments
 (0)