Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "dev",
"image": "node:stable"
}
36 changes: 36 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# react-document-events

A declarative React component for binding event handlers to `document` and `window`, with automatic cleanup on unmount.

## Commands

```bash
# Install dependencies (uses yarn)
yarn

# Run tests
yarn test

# Run tests in watch mode
yarn test:watch

# Run linting
yarn lint

# Build (transpile src/ to build/)
yarn build
```

## Architecture

- **src/ReactDocumentEvents.js** - Main component. Binds/unbinds event handlers to document or window targets. Uses `addEventListener`/`removeEventListener` with support for passive events.
- **src/events.js** - Lists of valid React and window event names used for prop validation in development.
- **build/** - Babel-transpiled output (generated, not committed).
- **index.js** - Entry point, exports the built component.

## Key Details

- Uses Babel for transpilation (`@babel/preset-env`, `@babel/preset-react`)
- Tests use Mocha + Chai + jsdom
- Pre-commit hooks run: test, lint, build
- Peer dependencies: React >0.14, ReactDOM >0.14
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
"version": "1.5.1",
"description": "Declarative component for binding handlers to document and window - and cleaning them up.",
"main": "index.js",
"exports": {
".": "./index.js",
"./package.json": "./package.json"
},
"engines": {
"node": ">=4"
"node": ">=20"
},
"scripts": {
"prepublishOnly": "npm run build",
Expand Down Expand Up @@ -44,8 +48,8 @@
"react-dom": "^19.2.3"
},
"peerDependencies": {
"react": ">0.14",
"react-dom": ">0.14"
"react": ">=16.8",
"react-dom": ">=16.8"
},
"pre-commit": [
"test",
Expand Down
24 changes: 3 additions & 21 deletions src/ReactDocumentEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,7 @@ class DocumentEvents extends React.Component {
_adjustHandlers(fn, props) {
const target = this.getTarget(props);
if (!target) return;
// If `passive` is not supported, the third param is `useCapture`, which is a bool - and we won't
// be able to use passive at all. Otherwise, it's safe to use an object.
// eslint-disable-next-line no-use-before-define
const options = SUPPORTS_PASSIVE ? {passive: props.passive, capture: props.capture} : props.capture;
const options = {passive: props.passive, capture: props.capture};
this.getKeys(props).forEach(([eventHandlerName, eventName]) => {
// Note that this is a function that looks up the latest handler on `this.props`.
// This ensures that if the function in `props` changes, the most recent handler will
Expand All @@ -110,28 +107,13 @@ class DocumentEvents extends React.Component {
}

function on(element, event, callback, options) {
!element.addEventListener && (event = 'on' + event);
(element.addEventListener || element.attachEvent).call(element, event, callback, options);
return callback;
element.addEventListener(event, callback, options);
}

function off(element, event, callback, options) {
!element.removeEventListener && (event = 'on' + event);
(element.removeEventListener || element.detachEvent).call(element, event, callback, options);
return callback;
element.removeEventListener(event, callback, options);
}

const SUPPORTS_PASSIVE = (function passiveFeatureTest() {
try {
let support = false;
// eslint-disable-next-line getter-return
document.createElement("div").addEventListener("test", function() {}, { get passive() { support = true; }});
return support;
} catch (e) {
return false;
}
})();

// Generate and assign propTypes from all possible events
if (NODE_ENV !== 'production') {
const propTypes = EventKeys.allEvents.reduce(function(result, key) {
Expand Down
64 changes: 19 additions & 45 deletions test/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,6 @@ DummyTarget.prototype.removeEventListener = function(name, callback) {
delete this.eventListenerOptions[name];
};

// Target that uses legacy IE attachEvent/detachEvent API
const LegacyTarget = function() {
this.eventListenerCount = {};
this.eventListeners = {};
};
LegacyTarget.prototype.attachEvent = function(name, callback) {
// IE uses 'onclick' format
const eventName = name.replace(/^on/, '');
if (typeof this.eventListenerCount[eventName] !== "number") {
this.eventListenerCount[eventName] = 0;
}
this.eventListenerCount[eventName]++;
this.eventListeners[eventName] = callback;
};
LegacyTarget.prototype.detachEvent = function(name, callback) {
const eventName = name.replace(/^on/, '');
if (this.eventListeners[eventName] !== callback) return;
this.eventListenerCount[eventName]--;
delete this.eventListeners[eventName];
};

class DummyComponent extends React.Component {
renderCount = 0;
getDocumentEvents() {
Expand Down Expand Up @@ -342,9 +321,7 @@ describe('react-document-events', function () {
});
});

it("should pass capture option to addEventListener (fallback mode)", () => {
// In JSDOM, passive event listeners are not supported, so the component
// falls back to passing just the capture boolean as the third argument
it("should pass capture option to addEventListener", () => {
const target = new DummyTarget();
const container = document.createElement("div");
const root = createRoot(container);
Expand All @@ -353,14 +330,28 @@ describe('react-document-events', function () {
<ReactDocumentEvents target={target} capture={true} onClick={() => {}} />
);
});
// When passive is not supported, options is just the capture boolean
expect(target.eventListenerOptions.click).to.equal(true);
expect(target.eventListenerOptions.click).to.deep.equal({ passive: false, capture: true });
act(() => {
root.unmount();
});
});

it("should pass passive option to addEventListener", () => {
const target = new DummyTarget();
const container = document.createElement("div");
const root = createRoot(container);
act(() => {
root.render(
<ReactDocumentEvents target={target} passive={true} onClick={() => {}} />
);
});
expect(target.eventListenerOptions.click).to.deep.equal({ passive: true, capture: false });
act(() => {
root.unmount();
});
});

it("should default capture to false", () => {
it("should default capture and passive to false", () => {
const target = new DummyTarget();
const container = document.createElement("div");
const root = createRoot(container);
Expand All @@ -369,8 +360,7 @@ describe('react-document-events', function () {
<ReactDocumentEvents target={target} onClick={() => {}} />
);
});
// Default capture is false
expect(target.eventListenerOptions.click).to.equal(false);
expect(target.eventListenerOptions.click).to.deep.equal({ passive: false, capture: false });
act(() => {
root.unmount();
});
Expand Down Expand Up @@ -481,22 +471,6 @@ describe('react-document-events', function () {
});
});

it("should use attachEvent/detachEvent when addEventListener not available", () => {
const target = new LegacyTarget();
const container = document.createElement("div");
const root = createRoot(container);

act(() => {
root.render(<ReactDocumentEvents target={target} onClick={() => {}} />);
});
expect(target.eventListenerCount).to.deep.equal({ click: 1 });

act(() => {
root.unmount();
});
expect(target.eventListenerCount).to.deep.equal({ click: 0 });
});

it("should handle rapid enable/disable toggling without leaking listeners", () => {
const target = new DummyTarget();
const container = document.createElement("div");
Expand Down