|
| 1 | +// Runtime ground-truth confirmation for the two OwnTS OSS *true positives* |
| 2 | +// recorded in docs/notes/ownts-oss-benchmark.md ("First confirmed TRUE positive"). |
| 3 | +// |
| 4 | +// The analyzer already flags these shapes statically (see |
| 5 | +// frontend/ownts/examples/EffectFunctionCallback.tsx for the capture-mismatch pin). |
| 6 | +// This test is the INDEPENDENT half: it proves the flagged shapes genuinely leak a |
| 7 | +// listener when executed in a real DOM — so the "true positive" label rests on |
| 8 | +// observed behaviour, not on the analyzer's own say-so. |
| 9 | +// |
| 10 | +// How it proves a leak: register the listener, run the effect's *cleanup*, then |
| 11 | +// dispatch the event. If the handler still fires, cleanup failed to remove it — a |
| 12 | +// leak. Each case is paired with a CONTROL that cleans up correctly and must go |
| 13 | +// silent, so a passing leak assertion cannot be an artefact of the harness. |
| 14 | +// |
| 15 | +// Repo policy (.gitignore: "Never commit other projects' code") is respected: the |
| 16 | +// register/cleanup helpers below are MINIMAL REDUCED CASES authored here, not copies |
| 17 | +// of the upstream source. Provenance for each is the pkg@version : file : line and |
| 18 | +// the upstream file sha256 recorded in README.md, so a maintainer can re-derive them |
| 19 | +// from a fresh `npm pack`. |
| 20 | +// |
| 21 | +// DOM engine: jsdom (hermetic, no browser binary — CI-friendly). The exact same |
| 22 | +// assertions were cross-checked in real Chromium; the numbers are in README.md. |
| 23 | + |
| 24 | +import { test } from "node:test"; |
| 25 | +import assert from "node:assert/strict"; |
| 26 | +import { JSDOM } from "jsdom"; |
| 27 | + |
| 28 | +/** A fresh, isolated document + Event constructor per case. */ |
| 29 | +function dom() { |
| 30 | + const { window } = new JSDOM("<!doctype html><body></body>"); |
| 31 | + return { document: window.document, Event: window.Event }; |
| 32 | +} |
| 33 | + |
| 34 | +// --------------------------------------------------------------------------- |
| 35 | +// Case 1 — react-scroll-to-bottom@4.2.0 |
| 36 | +// lib/esm/ScrollToBottom/Composer.js:574 (add) / :579 (remove) |
| 37 | +// Bug class: capture-flag mismatch. Added with { capture: true }, removed with |
| 38 | +// the default (capture: false). removeEventListener matches on the capture flag, |
| 39 | +// so the listener is never removed — a new one piles up every time `target` |
| 40 | +// changes. Reduced shape mirrors examples/EffectFunctionCallback.tsx. |
| 41 | +// --------------------------------------------------------------------------- |
| 42 | +test("RSTB@4.2.0: capture:true add + default-capture remove leaks the listener", () => { |
| 43 | + const { document, Event } = dom(); |
| 44 | + const target = document.createElement("div"); |
| 45 | + let fired = 0; |
| 46 | + const handleFocus = () => { fired++; }; |
| 47 | + |
| 48 | + // effect body |
| 49 | + target.addEventListener("focus", handleFocus, { capture: true, passive: true }); |
| 50 | + // effect cleanup, as published: no options → capture defaults to false |
| 51 | + target.removeEventListener("focus", handleFocus); |
| 52 | + |
| 53 | + target.dispatchEvent(new Event("focus")); // capture listeners also fire at target |
| 54 | + assert.equal(fired, 1, "listener survived cleanup → leak (expected 1 fire)"); |
| 55 | +}); |
| 56 | + |
| 57 | +test("RSTB control: removing with the matching capture flag is clean", () => { |
| 58 | + const { document, Event } = dom(); |
| 59 | + const target = document.createElement("div"); |
| 60 | + let fired = 0; |
| 61 | + const handleFocus = () => { fired++; }; |
| 62 | + |
| 63 | + target.addEventListener("focus", handleFocus, { capture: true, passive: true }); |
| 64 | + target.removeEventListener("focus", handleFocus, { capture: true }); // the fix |
| 65 | + |
| 66 | + target.dispatchEvent(new Event("focus")); |
| 67 | + assert.equal(fired, 0, "correct cleanup must remove the listener"); |
| 68 | +}); |
| 69 | + |
| 70 | +// --------------------------------------------------------------------------- |
| 71 | +// Case 2 — @reactuses/core@6.4.0, hook `useMousePressed` |
| 72 | +// dist/index.mjs: `const listenerOptions$2 = { passive: true }` (:2809); |
| 73 | +// add dragstart/touchstart :2830/:2835 (WITH listenerOptions$2), remove |
| 74 | +// :2846/:2851 (WITHOUT it). |
| 75 | +// Bug class: fresh function identity. onPressed = useCallback(t => () => {…}), |
| 76 | +// so onPressed('mouse') returns a NEW function every call. add registers one |
| 77 | +// instance; cleanup calls onPressed('mouse') AGAIN, producing a different |
| 78 | +// instance that removeEventListener cannot match. The drag/touch listeners are |
| 79 | +// never removed (the sibling onReleased listeners use a stable ref and are fine). |
| 80 | +// |
| 81 | +// The published add passes { passive: true } and the remove omits it, but that |
| 82 | +// dropped option is NOT the cause: a listener's removal key is (type, callback, |
| 83 | +// CAPTURE) — `passive` is not part of it, and no `capture` is set (defaults to |
| 84 | +// false on both sides). So the identity mismatch is the sole reason it leaks; |
| 85 | +// the control below proves the option-drop alone stays clean. (Reduced shape |
| 86 | +// keeps the { passive: true } on add so this is the package's exact shape.) |
| 87 | +// --------------------------------------------------------------------------- |
| 88 | +const REACTUSES_LISTENER_OPTIONS = { passive: true }; // = listenerOptions$2 |
| 89 | + |
| 90 | +test("@reactuses/core@6.4.0: onPressed('mouse') returns a fresh fn each call", () => { |
| 91 | + const onPressed = (srcType) => () => { void srcType; }; |
| 92 | + assert.notEqual(onPressed("mouse"), onPressed("mouse"), |
| 93 | + "curried onPressed must yield a new identity per call (the root cause)"); |
| 94 | +}); |
| 95 | + |
| 96 | +test("@reactuses/core@6.4.0: add(onPressed('mouse')) + remove(onPressed('mouse')) leaks", () => { |
| 97 | + const { document, Event } = dom(); |
| 98 | + const element = document.createElement("div"); |
| 99 | + let fired = 0; |
| 100 | + // curried, as published: a new inner fn per invocation |
| 101 | + const onPressed = (srcType) => () => { void srcType; fired++; }; |
| 102 | + |
| 103 | + // effect body — the freshly-returned fn is registered, with the published options |
| 104 | + element.addEventListener("dragstart", onPressed("mouse"), REACTUSES_LISTENER_OPTIONS); |
| 105 | + // effect cleanup, as published — a *different* freshly-returned fn, options omitted |
| 106 | + element.removeEventListener("dragstart", onPressed("mouse")); |
| 107 | + |
| 108 | + element.dispatchEvent(new Event("dragstart")); |
| 109 | + assert.equal(fired, 1, "listener survived cleanup → leak (expected 1 fire)"); |
| 110 | +}); |
| 111 | + |
| 112 | +test("@reactuses/core control: same captured fn, options dropped on remove, is clean", () => { |
| 113 | + const { document, Event } = dom(); |
| 114 | + const element = document.createElement("div"); |
| 115 | + let fired = 0; |
| 116 | + const onPressed = (srcType) => () => { void srcType; fired++; }; |
| 117 | + |
| 118 | + const handler = onPressed("mouse"); // capture once (the fix) |
| 119 | + // Add WITH the published { passive: true }, remove WITHOUT it — exactly the |
| 120 | + // option-drop Codex flagged. It removes cleanly, proving the drop is not the |
| 121 | + // leak: `passive` is not part of the removal key, and capture is false on both. |
| 122 | + element.addEventListener("dragstart", handler, REACTUSES_LISTENER_OPTIONS); |
| 123 | + element.removeEventListener("dragstart", handler); |
| 124 | + |
| 125 | + element.dispatchEvent(new Event("dragstart")); |
| 126 | + assert.equal(fired, 0, "correct cleanup must remove the listener despite the dropped option"); |
| 127 | +}); |
0 commit comments