whynotjs
v0.1.0
Published
Track why, when, and where your JavaScript objects changed — like Git blame for plain objects.
Maintainers
Readme
WhyNotJS
You're staring at a bug. Some property is wrong. You have no idea who changed it, when, or from where.
console.log everywhere. Breakpoints. git blame on a file that touches the object in 6 places. Thirty minutes later you find it.
There's a better way.
import { track, why } from "whynotjs";
const user = track({ name: "Hritik", age: 25 });
// …mutations happen across your entire codebase…
why(user, "name");
// Changed 2 times
//
// 1. Old → Hritik New → John
// Source: ProfileForm.tsx:24 (handleSubmit)
// Time: 10:22 PM
//
// 2. Old → John New → Jane
// Source: UserSettings.tsx:87 (onSave)
// Time: 10:45 PMOne line to opt in. No store. No actions. No boilerplate. Works on any plain JavaScript object.
Install
npm install whynotjsHow it works
WhyNotJS wraps your object in a native Proxy. Every mutation fires the set trap, which captures an Error stack trace at that exact moment, parses out the call-site, and stores a ChangeRecord in a WeakMap keyed to your object.
track(obj)
└─ new Proxy(obj, { set, deleteProperty })
└─ mutation fires → new Error().stack
└─ parse call-site (file · line · col · fn)
└─ push ChangeRecord into WeakMap<target, Map<prop, history>>
└─ why(obj, prop) reads it backFour deliberate design decisions:
WeakMapas the store — your object can be garbage-collected normally. No leaks, no cleanup required (unless you want it withuntrack).Error.stackfor call-sites — no bundler plugin, no source-map server, no build step. It's just a standard V8/SpiderMonkey stack trace, parsed at runtime.ReflectalongsideProxy— every trap delegates throughReflectso prototype chains, getters, and class instances all behave correctly.- No-op on identical values —
obj.x = obj.xrecords nothing. The guard isoldValue !== newValuebefore any write.
This is the same primitive Vue 3's reactive(), MobX, and Immer are built on. WhyNotJS just exposes the audit trail instead of hiding it.
API
track(obj, options?)
Wrap an object. Returns a Proxy — use it everywhere you'd use the original.
const user = track({ name: "Hritik", age: 25 });
const config = track({ theme: "dark" }, { verbose: true });| Option | Type | Default | Description |
|---|---|---|---|
| maxHistory | number | 50 | Sliding-window cap per property. Oldest records are evicted. |
| onchange | (record: ChangeRecord) => void | noop | Fires on every mutation. |
| verbose | boolean | false | Logs every change to the console automatically. |
| ignore | Array<string \| RegExp> | [] | Properties to skip. |
| watch | Array<string \| symbol> | [] | When set, only these properties are tracked. |
why(obj, property)
Full change history for one property.
const report = why(user, "name");
report.count // 2
report.changes[0].oldValue // "Hritik"
report.changes[0].newValue // "John"
report.changes[0].source // { file: "ProfileForm.tsx", line: 24, col: 14, fn: "handleSubmit" }
report.changes[0].time // "10:22 PM"
report.changes[0].timestamp // "2024-01-15T22:22:00.000Z"whyAll(obj)
History for every property at once.
const all = whyAll(user);
// { name: WhyReport, age: WhyReport }print(obj, property)
Pretty-prints to the console using console.group.
print(user, "name");
// [WhyNotJS] .name changed 2 times
// 1. 10:22 PM
// Old → Hritik / New → John
// ProfileForm.tsx:24 (handleSubmit)
// 2. 10:45 PM
// Old → John / New → Jane
// UserSettings.tsx:87 (onSave)reset(obj, property?)
Clear history for one property, or all of them.
reset(user, "name"); // clears .name only
reset(user); // clears everythinguntrack(obj)
Stop tracking entirely and free the WeakMap entry.
untrack(user);Recipes
React — debug a form without touching state
import { useRef } from "react";
import { track, print } from "whynotjs";
function ProfileForm() {
const user = useRef(track({ name: "", email: "" })).current;
return (
<input
onChange={e => {
user.name = e.target.value; // tracked
print(user, "name"); // log it any time
}}
/>
);
}Reactive callback — build a mini event system
const store = track(
{ status: "idle", retries: 0 },
{
onchange({ property, oldValue, newValue, source, time }) {
logger.info(`[${time}] ${String(property)}: ${oldValue} → ${newValue}`, source);
},
}
);Audit trail — know exactly what your async code touched
const order = track({ status: "pending", total: 0 });
await processPayment(order); // black box
print(order, "status");
// Changed 3 times: pending → validating → charging → complete
// Each with source file + lineBounded history — keep only the last N changes
const sensor = track({ temp: 0 }, { maxHistory: 10 });
// Stream in 1000 readings — only the last 10 are kept
readings.forEach(r => (sensor.temp = r));
why(sensor, "temp").changes.length // 10Ignore internals with a regex
const model = track(obj, { ignore: [/^_/, /^__/] });
// _id, __proto__, _rev — all ignored
// Only public properties are trackedTypeScript
Written in TypeScript. Full types included, no @types/ package needed.
import type { ChangeRecord, WhyReport, WhyNotOptions, SourceInfo } from "whynotjs";Compatibility
Requires native Proxy (ES2015+). Cannot be polyfilled.
| Runtime | Minimum version | |---|---| | Node.js | 14 | | Chrome / Edge | 49 | | Firefox | 18 | | Safari | 10 |
License
MIT
