Skip to content

Commit 72504ae

Browse files
React support (#6)
* Add explicit support for React-specific objects * Semver bump * Remove obsolete commented code
1 parent e3786cb commit 72504ae

File tree

7 files changed

+138
-36
lines changed

7 files changed

+138
-36
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/).
66

7+
## [2.2.0] - 2024-04-29
8+
9+
- MINOR: Add explicit support for React-specific objects in `readOnly`
10+
711
## [2.1.0] - 2024-04-24
812

913
- MINOR: Accept inline non-function wrapped parameters to `readOnly`

package-lock.json

+56-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/eslint-plugin/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lifetimes/eslint-plugin",
3-
"version": "2.1.0",
3+
"version": "2.2.0",
44
"author": "Marcus Armstrong <marcusdarmstrong@gmail.com>",
55
"homepage": "https://github.com/marcusdarmstrong/lifetimes",
66
"repository": "github:marcusdarmstrong/lifetimes",

packages/lifetimes/lifetimes.test.ts

+37
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import assert from "node:assert";
22
import { test } from "node:test";
33

4+
import { lazy, createElement } from "react";
5+
46
import {
57
readOnly,
68
requestLocal,
@@ -118,6 +120,41 @@ test("readOnly", async (t) => {
118120
}, "cannot be modified");
119121
});
120122

123+
await t.test("ignores lazy react components", () => {
124+
function LazyComponent() {
125+
return createElement("span");
126+
}
127+
128+
const lazyRenderable = readOnly(() =>
129+
lazy(() => Promise.resolve({ default: LazyComponent })),
130+
);
131+
132+
// Prototype is the only TS-writable property defined on this type.
133+
assert(
134+
(lazyRenderable.prototype = Function),
135+
"lazy components can be written",
136+
);
137+
138+
// This is a typescript assertion.
139+
createElement(lazyRenderable);
140+
});
141+
142+
await t.test("ignores react elements", () => {
143+
const elm = readOnly(createElement("div"));
144+
// @ts-expect-error: React internally mutates this property. We need to assert that we haven't prevented it from doing so.
145+
assert((elm._store.foo = "bar"), "react elements can be written");
146+
147+
function Component() {
148+
return "hello world";
149+
}
150+
151+
// These are typescript assertions.
152+
createElement(readOnly(() => Component));
153+
154+
// @ts-expect-error: Components, as callables, need to be passed to `readOnly` with a closure wrapper.
155+
readOnly<typeof Component>(Component);
156+
});
157+
121158
await t.test("arrays", () => {
122159
const arr = readOnly(() => [1, 2, 3, 4, 5, [6, 7]] as const);
123160
assert(arr[0], "can be read");

packages/lifetimes/lifetimes.ts

+36-31
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { AsyncLocalStorage } from "node:async_hooks";
22
import { createScope } from "#scope";
33

4+
import type { ReactElement } from "react";
5+
46
const MUTABLE_SET_METHODS = new Set<string | symbol>([
57
"add",
68
"delete",
@@ -26,13 +28,22 @@ const MUTABLE_DATE_METHODS = new Set<string | symbol>([
2628
"setYear",
2729
]);
2830

29-
function immutableProxy<T>(value: T): T {
31+
function isReactObject(value: unknown): boolean {
32+
return (
33+
(typeof value === "object" || typeof value === "function") &&
34+
value !== null &&
35+
"$$typeof" in value
36+
);
37+
}
38+
39+
function immutableProxy<T>(value: T): Immutable<T> {
3040
if (
41+
isReactObject(value) ||
3142
(typeof value !== "object" && typeof value !== "function") ||
3243
value === null
3344
) {
3445
// Primitive values are implicitly immutable.
35-
return value;
46+
return value as Immutable<T>;
3647
}
3748

3849
if (value instanceof Promise) {
@@ -41,13 +52,7 @@ function immutableProxy<T>(value: T): T {
4152
);
4253
}
4354

44-
// This effectively prevents mutation of arrays and objects.
4555
Object.preventExtensions(value);
46-
Reflect.ownKeys(value).forEach((property) => {
47-
Object.defineProperty(value, property, {
48-
writable: false,
49-
});
50-
});
5156

5257
return new Proxy(value, {
5358
get(target, property, receiver) {
@@ -69,30 +74,28 @@ function immutableProxy<T>(value: T): T {
6974
: returnValue,
7075
);
7176
},
72-
});
77+
}) as Immutable<T>;
7378
}
7479

75-
function makeImmutable<T>(value: T): T {
80+
function makeImmutable<T>(value: T): Immutable<T> {
7681
if (
7782
value instanceof Promise ||
7883
value instanceof Set ||
7984
value instanceof Map ||
80-
value instanceof Date
85+
value instanceof Date ||
86+
isReactObject(value)
8187
) {
8288
return immutableProxy(value);
8389
}
8490

8591
if (typeof value === "object" && value !== null) {
86-
Object.preventExtensions(value);
8792
Reflect.ownKeys(value).forEach((property) => {
88-
Object.defineProperty(value, property, {
89-
writable: false,
90-
});
9193
makeImmutable(value[property as keyof typeof value]);
9294
});
95+
Object.freeze(value);
9396
}
9497

95-
return value;
98+
return value as Immutable<T>;
9699
}
97100

98101
export type ReadonlyDate = Readonly<
@@ -124,24 +127,26 @@ export type Immutable<T> = T extends (...args: infer Ks) => infer V
124127
? ReadonlySet<Immutable<S>>
125128
: T extends Map<infer K, infer V>
126129
? ReadonlyMap<Immutable<K>, Immutable<V>>
127-
: {
128-
readonly [K in keyof T]: Immutable<T[K]>;
129-
};
130-
131-
function isCallable(value: unknown): value is (...args: unknown[]) => unknown {
130+
: T extends ReactElement<unknown>
131+
? T
132+
: {
133+
readonly [K in keyof T]: Immutable<T[K]>;
134+
};
135+
136+
function isCallable<T>(
137+
value: ReadOnlyInitializer<T>,
138+
): value is ClosureInitializer<T> {
132139
return typeof value === "function";
133140
}
134141

135-
export function readOnly<T>(
136-
initializer: () => T extends Promise<unknown> ? never : T,
137-
): Immutable<T>;
138-
export function readOnly<T>(
139-
initializer: T extends (...args: unknown[]) => unknown
142+
type ClosureInitializer<T> = () => T extends Promise<unknown> ? never : T;
143+
type InlineInitializer<T> = T extends (...args: unknown[]) => unknown
144+
? never
145+
: T extends Promise<unknown>
140146
? never
141-
: T extends Promise<unknown>
142-
? never
143-
: T,
144-
): Immutable<T>;
147+
: T;
148+
149+
type ReadOnlyInitializer<T> = ClosureInitializer<T> | InlineInitializer<T>;
145150

146151
/**
147152
* Mark the lifetime of a provided block of code as "forever" with safety
@@ -151,7 +156,7 @@ export function readOnly<T>(
151156
* @return The provided callback's return value, frozen, and wrapped in a
152157
* readOnly-enforcing Proxy.
153158
*/
154-
export function readOnly<T>(initializer: T | (() => T)): T {
159+
export function readOnly<T>(initializer: ReadOnlyInitializer<T>): Immutable<T> {
155160
if (isCallable(initializer)) {
156161
return makeImmutable(initializer());
157162
}

packages/lifetimes/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lifetimes",
3-
"version": "2.1.0",
3+
"version": "2.2.0",
44
"description": "A utility for explicit specification and enforcement of module scope variable lifetimes",
55
"license": "MIT",
66
"author": "Marcus Armstrong <marcusdarmstrong@gmail.com>",
@@ -43,11 +43,13 @@
4343
"@tsconfig/strictest": "2.0.3",
4444
"@tsconfig/node16": "16.1.1",
4545
"@types/node": "20.11.24",
46+
"@types/react": "18.2.79",
4647
"@typescript-eslint/eslint-plugin": "7.1.1",
4748
"@typescript-eslint/parser": "7.1.1",
4849
"eslint": "8.57.0",
4950
"eslint-config-prettier": "9.1.0",
5051
"eslint-plugin-prettier": "5.1.3",
52+
"react": "18.2.0",
5153
"rollup": "4.12.0",
5254
"typescript": "5.3.3"
5355
},

packages/lifetimes/rollup.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,5 @@ export default [{
4444
output: output("test"),
4545
plugins: plugins("node"),
4646
// plugin-node-resolve uses an out-of-date builtins list.
47-
external: ["node:test"],
47+
external: ["node:test", "react"],
4848
}];

0 commit comments

Comments
 (0)