|
| 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 |
| 72 | +// dist/index.mjs: add dragstart/touchstart :2830/:2835, remove :2846/:2851 |
| 73 | +// Bug class: fresh function identity. onPressed = useCallback(t => () => {…}), |
| 74 | +// so onPressed('mouse') returns a NEW function every call. add registers one |
| 75 | +// instance; cleanup calls onPressed('mouse') AGAIN, producing a different |
| 76 | +// instance that removeEventListener cannot match. The drag/touch listeners are |
| 77 | +// never removed (the sibling onReleased listeners use a stable ref and are fine). |
| 78 | +// --------------------------------------------------------------------------- |
| 79 | +test("@reactuses/core@6.4.0: onPressed('mouse') returns a fresh fn each call", () => { |
| 80 | + const onPressed = (srcType) => () => { void srcType; }; |
| 81 | + assert.notEqual(onPressed("mouse"), onPressed("mouse"), |
| 82 | + "curried onPressed must yield a new identity per call (the root cause)"); |
| 83 | +}); |
| 84 | + |
| 85 | +test("@reactuses/core@6.4.0: add(onPressed('mouse')) + remove(onPressed('mouse')) leaks", () => { |
| 86 | + const { document, Event } = dom(); |
| 87 | + const element = document.createElement("div"); |
| 88 | + let fired = 0; |
| 89 | + // curried, as published: a new inner fn per invocation |
| 90 | + const onPressed = (srcType) => () => { void srcType; fired++; }; |
| 91 | + |
| 92 | + // effect body — the freshly-returned fn is what gets registered |
| 93 | + element.addEventListener("dragstart", onPressed("mouse")); |
| 94 | + // effect cleanup, as published — a *different* freshly-returned fn |
| 95 | + element.removeEventListener("dragstart", onPressed("mouse")); |
| 96 | + |
| 97 | + element.dispatchEvent(new Event("dragstart")); |
| 98 | + assert.equal(fired, 1, "listener survived cleanup → leak (expected 1 fire)"); |
| 99 | +}); |
| 100 | + |
| 101 | +test("@reactuses/core control: add & remove the SAME captured fn is clean", () => { |
| 102 | + const { document, Event } = dom(); |
| 103 | + const element = document.createElement("div"); |
| 104 | + let fired = 0; |
| 105 | + const onPressed = (srcType) => () => { void srcType; fired++; }; |
| 106 | + |
| 107 | + const handler = onPressed("mouse"); // capture once (the fix) |
| 108 | + element.addEventListener("dragstart", handler); |
| 109 | + element.removeEventListener("dragstart", handler); |
| 110 | + |
| 111 | + element.dispatchEvent(new Event("dragstart")); |
| 112 | + assert.equal(fired, 0, "correct cleanup must remove the listener"); |
| 113 | +}); |
0 commit comments