makeReadOnly implementation

Added utility package to track mutations to objects marked as "read-only".
This commit is contained in:
Mofei Zhang
2022-09-29 14:17:38 -04:00
parent 3158146989
commit 72775fba10
8 changed files with 2659 additions and 0 deletions
@@ -0,0 +1,3 @@
node_modules/
dist/
tsconfig.tsbuildinfo
@@ -0,0 +1,5 @@
Utility package to track mutations to objects marked as "read-only".
Available commands:
- yarn build
- yarn test
@@ -0,0 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};
@@ -0,0 +1,24 @@
{
"name": "make-read-only-util",
"files": [
"src"
],
"scripts": {
"build": "tsc",
"test": "jest src"
},
"dependencies": {
"invariant": "^2.2.4",
"pretty-format": "^24"
},
"devDependencies": {
"@types/invariant": "^2.2.35",
"@types/jest": "^28.1.6",
"@types/node": "^18.7.13",
"jest": "^28.1.3",
"prettier": "2.7.1",
"ts-jest": "^28.0.7",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
}
}
@@ -0,0 +1,132 @@
import buildMakeReadOnly from "./makeReadOnly";
describe("makeReadOnly", () => {
let logger: jest.Func;
let makeReadOnly: <T>(value: T) => T;
beforeEach(() => {
logger = jest.fn();
makeReadOnly = buildMakeReadOnly(logger, [], () => true);
});
describe("Tracking mutations", () => {
it("can be called with all primitives", () => {
const a = 5;
const b = true;
const c = null;
expect(makeReadOnly(a)).toBe(a);
expect(makeReadOnly(b)).toBe(b);
expect(makeReadOnly(c)).toBe(c);
});
it("retains referential equality", () => {
const valA = {};
const valB = { a: valA, _: valA };
const o = { a: valA, b: valB, c: "c" };
expect(makeReadOnly(o)).toBe(o);
expect(makeReadOnly(o.a)).toBe(valA);
expect(makeReadOnly(o.b)).toBe(valB);
expect(makeReadOnly(o.b.a)).toBe(valA);
expect(makeReadOnly(o.b._)).toBe(valA);
expect(makeReadOnly(o.c)).toBe("c");
});
it("deals with cyclic references", () => {
const o: any = {};
o.self_ref = o;
expect(makeReadOnly(o)).toBe(o);
expect(makeReadOnly(o.self_ref)).toBe(o);
});
it("logs direct interior mutability", () => {
const o = { a: 0 };
makeReadOnly(o);
o.a = 42;
expect(logger).toBeCalledWith("FORGET_MUTATE_IMMUT", "a", 42);
});
it("tracks changes to known RO properties", () => {
const o: any = { a: {} };
makeReadOnly(o);
o.a = 42;
expect(logger).toBeCalledWith("FORGET_MUTATE_IMMUT", "a", 42);
expect(o.a).toBe(42);
const newVal = { x: 0 };
o.a = newVal;
expect(logger).toBeCalledWith("FORGET_MUTATE_IMMUT", "a", newVal);
expect(o.a).toBe(newVal);
});
it("logs aliased mutations", () => {
const o: any = { a: { x: 4 } };
const alias = o;
makeReadOnly(o);
const newVal = {};
alias.a = newVal;
expect(logger).toBeCalledWith("FORGET_MUTATE_IMMUT", "a", newVal);
expect(o.a).toBe(newVal);
});
it("logs transitive interior mutability", () => {
const o: any = { a: { x: 0 } };
makeReadOnly(o);
o.a.x = 42;
expect(logger).toBeCalledWith("FORGET_MUTATE_IMMUT", "x", 42);
});
describe("todo", () => {
it("does not track newly added or deleted vals if makeReadOnly is only called once", () => {
// this is a limitation of the current "proxy" approach,
// which overwrites object properties with getters and setters
const x: any = { a: {} };
makeReadOnly(x);
delete x.a;
x.b = 0;
expect(logger).toBeCalledTimes(0);
});
it("does not log aliased indirect mutations", () => {
// this could be easily implemented by making caching eager
const innerObj = { x: 0 };
const o = { a: innerObj };
makeReadOnly(o);
innerObj.x = 42;
expect(o.a.x).toBe(42);
const o1 = { a: { x: 0 } };
const innerObj1 = o1.a;
makeReadOnly(o1);
innerObj1.x = 42;
expect(o1.a.x).toBe(42);
expect(logger).toBeCalledTimes(0);
});
});
});
describe("Tracking adding or deleting properties", () => {
it("tracks new properties added between calls to makeReadOnly", () => {
const o: any = {};
makeReadOnly(o);
o.a = "new value";
makeReadOnly(o);
expect(logger).toBeCalledWith("FORGET_ADD_PROP_IMMUT", "a");
});
it("tracks properties deleted between calls to makeReadOnly", () => {
const o: any = { a: 0 };
makeReadOnly(o);
delete o.a;
makeReadOnly(o);
expect(logger).toBeCalledWith("FORGET_DELETE_PROP_IMMUT", "a");
});
it("tracks properties deleted and re-added between calls to makeReadOnly", () => {
const o: any = { a: 0 };
makeReadOnly(o);
delete o.a;
o.a = {};
makeReadOnly(o);
expect(logger).toBeCalledWith("FORGET_CHANGE_PROP_IMMUT", "a");
});
});
});
@@ -0,0 +1,136 @@
"use strict";
type ROViolationType =
| "FORGET_MUTATE_IMMUT"
| "FORGET_DELETE_PROP_IMMUT"
| "FORGET_CHANGE_PROP_IMMUT"
| "FORGET_ADD_PROP_IMMUT";
type ROModeChecker = () => boolean;
type ROViolationLogger = (
violation: ROViolationType,
key: string,
value?: any
) => void;
/**
* Represents a "proxy" of a read-only object property
* savedVal: underlying "source of truth" for a property value
* getter: hack, this lets us check whether we have already saved this property
* */
type SavedEntry = {
savedVal: unknown;
getter: () => unknown;
};
type SavedROObject = Map<string, SavedEntry>;
type SavedROObjects = WeakMap<Object, SavedROObject>;
// Utility functions
function isWriteable(desc: PropertyDescriptor) {
return (desc.writable || desc.set) && desc.configurable;
}
function getOrInsertDefault(
m: SavedROObjects,
k: object
): { existed: boolean; entry: SavedROObject } {
const entry = m.get(k);
if (entry) {
return { existed: true, entry };
} else {
const newEntry: SavedROObject = new Map();
m.set(k, newEntry);
return { existed: false, entry: newEntry };
}
}
function buildMakeReadOnly(
logger: ROViolationLogger,
skippedClasses: string[],
isInROMode: ROModeChecker
): <T>(val: T) => T {
// All saved proxys
const savedROObjects: SavedROObjects = new WeakMap();
// Overwrites an object property with its proxy and saves its original value
function addProperty(
obj: Object,
key: string,
prop: PropertyDescriptor,
savedEntries: Map<string, SavedEntry>
) {
const proxy: PropertyDescriptor & { get(): unknown } = {
get() {
// read from backing cache entry
return makeReadOnly(savedEntries.get(key)!.savedVal);
},
set(newVal: unknown) {
if (isInROMode()) {
logger("FORGET_MUTATE_IMMUT", key, newVal);
}
// update backing cache entry
savedEntries.get(key)!.savedVal = newVal;
},
};
if (prop.configurable != null) {
proxy.configurable = prop.configurable;
}
if (prop.enumerable != null) {
proxy.enumerable = prop.enumerable;
}
savedEntries.set(key, { savedVal: (obj as any)[key], getter: proxy.get });
Object.defineProperty(obj, key, proxy);
}
// Changes an object to be read-only, returns its input
function makeReadOnly<T>(o: T): T {
if (typeof o !== "object" || o == null) {
return o;
} else if (skippedClasses.includes(o.constructor.name)) {
return o;
}
const { existed, entry: cache } = getOrInsertDefault(savedROObjects, o);
for (const [k, entry] of cache.entries()) {
const currentProp = Object.getOwnPropertyDescriptor(o, k);
if (currentProp && !isWriteable(currentProp)) {
continue;
}
const currentPropGetter = currentProp?.get;
const cachedGetter = entry.getter;
if (currentPropGetter !== cachedGetter) {
// cache is currently holding an old property
// - it may have been deleted
// - it may have been deleted + re-set
// (meaning that new value is not proxied,
// and the current proxied value is stale)
cache.delete(k);
if (!currentProp && isInROMode()) {
logger("FORGET_DELETE_PROP_IMMUT", k);
} else if (currentProp) {
if (isInROMode()) {
logger("FORGET_CHANGE_PROP_IMMUT", k);
}
addProperty(o, k, currentProp, cache);
}
}
}
for (const [k, prop] of Object.entries(
Object.getOwnPropertyDescriptors(o)
)) {
if (!cache.has(k) && isWriteable(prop)) {
if (isInROMode() && existed) {
logger("FORGET_ADD_PROP_IMMUT", k);
}
addProperty(o, k, prop, cache);
}
}
return o;
}
return makeReadOnly;
}
export default buildMakeReadOnly;
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"allowJs": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": false,
"jsx": "preserve",
"incremental": true,
"outDir": "dist"
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
File diff suppressed because it is too large Load Diff