@starci/eslint-canon-fe 5.1.2 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -2
- package/README.md +17 -18
- package/brand-values.mjs +1 -3
- package/class-names.mjs +9 -9
- package/client-boundary.mjs +125 -37
- package/comments.mjs +14 -21
- package/docs/client-boundary.md +26 -1
- package/docs/grammar-boundary.md +33 -0
- package/docs/hygiene.md +15 -4
- package/docs/lint-escape-hatch.md +3 -1
- package/docs/next-conventions.md +26 -0
- package/docs/size-growth.md +29 -0
- package/docs/spec-quality.md +15 -7
- package/docs/status-colors.md +32 -0
- package/docs/translation.md +4 -0
- package/docs/transport.md +36 -9
- package/e2e-shape.mjs +20 -23
- package/env-owner.mjs +1 -1
- package/file-layout.mjs +105 -103
- package/formatting.mjs +2 -4
- package/grammar-boundary.mjs +85 -9
- package/hooks-folder.mjs +17 -16
- package/hygiene.mjs +370 -92
- package/icon.mjs +21 -20
- package/index.mjs +27 -85
- package/landmark.mjs +0 -11
- package/lib/bindings.mjs +144 -0
- package/lib/config.mjs +79 -0
- package/lib/hfs.mjs +48 -0
- package/lib/next.mjs +6 -0
- package/lib/platform.mjs +66 -0
- package/lib/react.mjs +28 -0
- package/lib/scope.mjs +119 -19
- package/lib/status-tones.generated.mjs +9 -0
- package/lib/types.mjs +63 -0
- package/lib/why.mjs +41 -11
- package/lint-escape-hatch.mjs +17 -15
- package/loading.mjs +9 -16
- package/naming.mjs +8 -2
- package/next-conventions.mjs +109 -25
- package/package.json +9 -4
- package/props-and-slots.mjs +19 -13
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1072 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +42 -0
- package/runtime/scripts/lib/hfs-slots.mjs +722 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/next-contract.mjs +14 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/served-locale.mjs +30 -16
- package/shape-slot.mjs +22 -20
- package/size-and-state-budget.mjs +41 -23
- package/size-growth.mjs +58 -0
- package/spec-quality.mjs +95 -16
- package/status-colors.mjs +139 -0
- package/the-split.mjs +13 -13
- package/tokens.mjs +26 -34
- package/translation.mjs +52 -19
- package/transport.mjs +437 -89
- package/type-safety.mjs +8 -17
- package/typography.mjs +7 -11
- package/vendor-boundary.mjs +5 -27
- package/lint-adoption.mjs +0 -79
package/hygiene.mjs
CHANGED
|
@@ -1,114 +1,345 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules that hold `hygiene.md`: the small runtime habits that pass every type check and fail in a
|
|
3
|
-
* reader's browser (HFS R65 `FE_SIZE_AND_STATE_BUDGET` for the
|
|
3
|
+
* reader's browser (HFS R65 `FE_SIZE_AND_STATE_BUDGET` for the subscription, catch and console rules, R50
|
|
4
4
|
* `FE_TRANSPORT_OWNER` for the fetch rule).
|
|
5
5
|
*
|
|
6
6
|
* FOUR HABITS, ONE CAUSE: work started by a component that nobody owns the end of.
|
|
7
|
-
* - a timer started
|
|
8
|
-
* gone and sets state on nothing;
|
|
7
|
+
* - a timer, listener, observer, socket or subscription started by an effect that never releases it
|
|
8
|
+
* keeps running after the component is gone and sets state on nothing;
|
|
9
9
|
* - a fetch started in an effect has no cache, no dedupe, no error state and no cancel, which is
|
|
10
10
|
* what SWR and the server readers already give for free (R50: client reads use SWR);
|
|
11
11
|
* - an empty `catch` turns a failure into a blank screen with no trace;
|
|
12
12
|
* - `console.*` is a log nobody reads, in a place no log pipeline sees.
|
|
13
|
+
*
|
|
14
|
+
* DECIDED BY RESOLUTION, NOT BY SPELLING. An effect is a call whose callee IS React's `useEffect` / `useLayoutEffect`
|
|
15
|
+
* export (the import is followed); `setTimeout`, `addEventListener`, `new ResizeObserver` are the platform's when their
|
|
16
|
+
* symbol is declared by the default library, not by a homonym in the repository; "the same handle" is the same scope
|
|
17
|
+
* binding (or member path), not the same identifier text; "a promise" is what the type checker says.
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
20
|
+
import { walk } from "./lib/ast.mjs"
|
|
21
|
+
import { calleeParts, functionOf, isFn, keyOf, parentOf, unwrap } from "./lib/bindings.mjs"
|
|
22
|
+
import { isPlatform, isUnresolvedType, propertyNamesOf } from "./lib/platform.mjs"
|
|
23
|
+
import { EFFECT_HOOKS, reactHookOf } from "./lib/react.mjs"
|
|
24
|
+
import { isProductSource } from "./lib/scope.mjs"
|
|
25
|
+
import { isPromiseValued } from "./lib/types.mjs"
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
const isGoverned = (filename) => {
|
|
21
|
-
const file = normalizePath(filename)
|
|
22
|
-
return file.includes("/src/") && !isSpecFile(file)
|
|
23
|
-
}
|
|
27
|
+
// -- HYGIENE-1 -------------------------------------------------------------------------------------
|
|
24
28
|
|
|
25
|
-
/** The
|
|
26
|
-
const
|
|
29
|
+
/** The platform functions that start a timer or a frame callback and hand back a handle. */
|
|
30
|
+
const HANDLE_STARTERS = new Map([
|
|
31
|
+
["setTimeout", "timer"],
|
|
32
|
+
["setInterval", "timer"],
|
|
33
|
+
["requestAnimationFrame", "frame"],
|
|
34
|
+
])
|
|
27
35
|
|
|
28
|
-
|
|
36
|
+
/** The platform functions that release such a handle, per kind. */
|
|
37
|
+
const HANDLE_RELEASERS = { timer: new Set(["clearTimeout", "clearInterval"]), frame: new Set(["cancelAnimationFrame"]) }
|
|
38
|
+
|
|
39
|
+
/** The observers that hold their targets until they are disconnected. */
|
|
40
|
+
const OBSERVERS = new Set(["ResizeObserver", "IntersectionObserver", "MutationObserver"])
|
|
41
|
+
|
|
42
|
+
/** The connections that stay open until they are closed. */
|
|
43
|
+
const CHANNELS = new Set(["WebSocket", "EventSource", "BroadcastChannel"])
|
|
44
|
+
|
|
45
|
+
/** The methods a subscription object releases itself with. */
|
|
46
|
+
const SUBSCRIPTION_RELEASES = ["unsubscribe", "off", "close"]
|
|
47
|
+
|
|
48
|
+
/** The global object's names: a timer or listener called through any of them is the same platform call. */
|
|
49
|
+
const GLOBAL_OBJECTS = new Set(["window", "globalThis", "self"])
|
|
50
|
+
|
|
51
|
+
/** `receiver` is absent or names the global object: `setTimeout(...)`, `window.setTimeout(...)`. */
|
|
52
|
+
const onGlobalObject = (receiver) => {
|
|
53
|
+
if (!receiver) return true
|
|
54
|
+
const inner = unwrap(receiver)
|
|
55
|
+
return inner.type === "Identifier" && GLOBAL_OBJECTS.has(inner.name)
|
|
56
|
+
}
|
|
29
57
|
|
|
30
|
-
/** The
|
|
31
|
-
const
|
|
32
|
-
for (let current = node.parent; current; current = current.parent)
|
|
33
|
-
|
|
34
|
-
|
|
58
|
+
/** The nearest function around a node, or null at module level. */
|
|
59
|
+
const nearestFunction = (node) => {
|
|
60
|
+
for (let current = node.parent; current; current = current.parent) if (isFn(current)) return current
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where the value of a start call is kept: `{ key }` for `const id = ...` / `id = ...` / `ref.current = ...`,
|
|
66
|
+
* `{ container }` for `list.push(...)` / `set.add(...)`, or null when the handle is dropped.
|
|
67
|
+
*/
|
|
68
|
+
const handleOf = (context, call) => {
|
|
69
|
+
const parent = parentOf(call)
|
|
70
|
+
if (!parent) return null
|
|
71
|
+
if (parent.type === "VariableDeclarator" && unwrap(parent.init) === call && parent.id.type === "Identifier") return { key: keyOf(context, parent.id) }
|
|
72
|
+
if (parent.type === "AssignmentExpression" && unwrap(parent.right) === call) {
|
|
73
|
+
const key = keyOf(context, parent.left)
|
|
74
|
+
return key ? { key } : null
|
|
75
|
+
}
|
|
76
|
+
if (parent.type === "CallExpression" && parent.arguments.some((argument) => unwrap(argument) === call)) {
|
|
77
|
+
const parts = calleeParts(parent)
|
|
78
|
+
if (parts?.receiver && (parts.name === "push" || parts.name === "add")) {
|
|
79
|
+
const container = keyOf(context, parts.receiver)
|
|
80
|
+
return container ? { container } : null
|
|
35
81
|
}
|
|
36
82
|
}
|
|
37
83
|
return null
|
|
38
84
|
}
|
|
39
85
|
|
|
40
|
-
/** The
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
86
|
+
/** The static key of an event type: `s:resize` for a string literal, the binding key for an identifier. */
|
|
87
|
+
const typeKeyOf = (context, node) => {
|
|
88
|
+
const inner = unwrap(node)
|
|
89
|
+
if (inner?.type === "Literal" && typeof inner.value === "string") return `s:${inner.value}`
|
|
90
|
+
if (inner?.type === "TemplateLiteral" && inner.expressions.length === 0) return `s:${inner.quasis[0].value.cooked}`
|
|
91
|
+
return keyOf(context, inner)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The key of the object whose `.signal` an `addEventListener` option object passes (`{ signal: controller.signal }`), or null. */
|
|
95
|
+
const signalOwnerOf = (context, options) => {
|
|
96
|
+
const object = unwrap(options)
|
|
97
|
+
if (object?.type !== "ObjectExpression") return null
|
|
98
|
+
const property = object.properties.find((entry) => entry.type === "Property" && !entry.computed && entry.key.type === "Identifier" && entry.key.name === "signal")
|
|
99
|
+
if (!property) return null
|
|
100
|
+
let value = unwrap(property.value)
|
|
101
|
+
if (value.type === "Identifier") {
|
|
102
|
+
const definition = context.sourceCode.getScope(value).set.get(value.name)?.defs[0]
|
|
103
|
+
if (definition?.type === "Variable" && definition.node.init) value = unwrap(definition.node.init)
|
|
104
|
+
}
|
|
105
|
+
return value.type === "MemberExpression" && !value.computed && value.property.type === "Identifier" && value.property.name === "signal" ? keyOf(context, value.object) : null
|
|
45
106
|
}
|
|
46
107
|
|
|
47
|
-
|
|
108
|
+
/** Every resource a call or `new` starts that must be released, or an empty list. */
|
|
109
|
+
const resourcesOf = (context, node) => {
|
|
110
|
+
const found = []
|
|
111
|
+
if (node.type === "NewExpression") {
|
|
112
|
+
const callee = unwrap(node.callee)
|
|
113
|
+
if (callee.type !== "Identifier" || !(OBSERVERS.has(callee.name) || CHANNELS.has(callee.name)) || !isPlatform(context, callee)) return found
|
|
114
|
+
found.push({ kind: OBSERVERS.has(callee.name) ? "observer" : "channel", node, name: callee.name, handle: handleOf(context, node) })
|
|
115
|
+
return found
|
|
116
|
+
}
|
|
117
|
+
const parts = calleeParts(node)
|
|
118
|
+
if (!parts) return found
|
|
119
|
+
const callee = unwrap(node.callee)
|
|
120
|
+
if (HANDLE_STARTERS.has(parts.name) && onGlobalObject(parts.receiver) && isPlatform(context, callee)) {
|
|
121
|
+
found.push({ kind: HANDLE_STARTERS.get(parts.name), node, name: parts.name, handle: handleOf(context, node) })
|
|
122
|
+
} else if (parts.name === "addEventListener" && node.arguments.length >= 2 && isPlatform(context, callee)) {
|
|
123
|
+
const listener = unwrap(node.arguments[1])
|
|
124
|
+
found.push({
|
|
125
|
+
kind: "listener",
|
|
126
|
+
node,
|
|
127
|
+
name: "addEventListener",
|
|
128
|
+
target: parts.receiver ? keyOf(context, parts.receiver) : "g:window",
|
|
129
|
+
type: typeKeyOf(context, node.arguments[0]),
|
|
130
|
+
typeText: context.sourceCode.getText(node.arguments[0]),
|
|
131
|
+
listener: listener && !isFn(listener) ? keyOf(context, listener) : null,
|
|
132
|
+
listenerText: context.sourceCode.getText(node.arguments[1]),
|
|
133
|
+
signalOwner: signalOwnerOf(context, node.arguments[2]),
|
|
134
|
+
receiverText: parts.receiver ? context.sourceCode.getText(parts.receiver) : null,
|
|
135
|
+
})
|
|
136
|
+
} else if (!reactHookOf(context, node)) {
|
|
137
|
+
// a subscription object is decided by its type, and only once the call is known to sit in an effect or a subscribe
|
|
138
|
+
const handle = handleOf(context, node)
|
|
139
|
+
if (handle?.key) found.push({ kind: "subscription", node, name: context.sourceCode.getText(node.callee), handle })
|
|
140
|
+
}
|
|
141
|
+
return found
|
|
142
|
+
}
|
|
48
143
|
|
|
49
|
-
/**
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
if (parent?.type === "AssignmentExpression" && parent.right === call && parent.left.type === "Identifier") return parent.left.name
|
|
54
|
-
return null
|
|
144
|
+
/** True when the value of a call is a subscription object: it carries `unsubscribe`, `off` or `close` and is neither a promise, a DOM node nor a window. */
|
|
145
|
+
const isSubscriptionObject = (context, call) => {
|
|
146
|
+
const names = propertyNamesOf(context, call)
|
|
147
|
+
return SUBSCRIPTION_RELEASES.some((name) => names.has(name)) && !names.has("then") && !names.has("nodeType") && !names.has("document") && !isUnresolvedType(context, call)
|
|
55
148
|
}
|
|
56
149
|
|
|
57
|
-
/** The
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
150
|
+
/** The ids of the statements' identifiers around a release: what a `list.forEach(clearTimeout)` mentions besides the releaser. */
|
|
151
|
+
const nearKeys = (context, node) => {
|
|
152
|
+
let root = node
|
|
153
|
+
for (let current = node; current; current = current.parent) {
|
|
154
|
+
root = current
|
|
155
|
+
if (current.type.endsWith("Statement") || current.type === "VariableDeclaration" || isFn(current.parent)) break
|
|
156
|
+
}
|
|
157
|
+
const keys = new Set()
|
|
158
|
+
walk(context.sourceCode, root, (child) => {
|
|
159
|
+
if (child.type === "Identifier" || child.type === "MemberExpression") {
|
|
160
|
+
const key = keyOf(context, child)
|
|
161
|
+
if (key) keys.add(key)
|
|
162
|
+
}
|
|
163
|
+
return undefined
|
|
164
|
+
})
|
|
165
|
+
return keys
|
|
62
166
|
}
|
|
63
167
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
168
|
+
/** Every release the given functions perform (following calls to same-file functions), as comparable records. */
|
|
169
|
+
const releasesIn = (context, roots) => {
|
|
170
|
+
const releases = []
|
|
171
|
+
const visited = new Set()
|
|
172
|
+
const queue = [...roots]
|
|
173
|
+
while (queue.length > 0) {
|
|
174
|
+
const fn = queue.shift()
|
|
175
|
+
if (!fn || visited.has(fn)) continue
|
|
176
|
+
visited.add(fn)
|
|
177
|
+
walk(context.sourceCode, fn, (node) => {
|
|
178
|
+
if (node.type === "Identifier" && [...Object.values(HANDLE_RELEASERS)].some((set) => set.has(node.name))) {
|
|
179
|
+
const parent = parentOf(node)
|
|
180
|
+
const asCallee = parent?.type === "CallExpression" && unwrap(parent.callee) === node
|
|
181
|
+
if (!asCallee && !(parent?.type === "MemberExpression" && parent.property === node)) releases.push({ kind: "release-ref", name: node.name, near: nearKeys(context, node) })
|
|
182
|
+
}
|
|
183
|
+
if (node.type !== "CallExpression") return undefined
|
|
184
|
+
const parts = calleeParts(node)
|
|
185
|
+
if (!parts) return undefined
|
|
186
|
+
const args = node.arguments
|
|
187
|
+
const isHandleReleaser = Object.values(HANDLE_RELEASERS).some((set) => set.has(parts.name))
|
|
188
|
+
if (isHandleReleaser && onGlobalObject(parts.receiver)) releases.push({ kind: "release", name: parts.name, arg: args[0] ? keyOf(context, args[0]) : null })
|
|
189
|
+
else if (parts.name === "removeEventListener" && args.length >= 2) {
|
|
190
|
+
releases.push({ kind: "remove", target: parts.receiver ? keyOf(context, parts.receiver) : "g:window", type: typeKeyOf(context, args[0]), listener: keyOf(context, args[1]) })
|
|
191
|
+
} else if (parts.receiver && ["disconnect", "close", "unsubscribe", "off", "abort"].includes(parts.name)) {
|
|
192
|
+
releases.push({ kind: "method", name: parts.name, receiver: keyOf(context, parts.receiver) })
|
|
193
|
+
} else if (!parts.receiver) {
|
|
194
|
+
const target = functionOf(context, node.callee)
|
|
195
|
+
if (target) queue.push(target)
|
|
196
|
+
}
|
|
197
|
+
return undefined
|
|
78
198
|
})
|
|
79
|
-
if (clears) return true
|
|
80
199
|
}
|
|
81
|
-
return
|
|
200
|
+
return releases
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** True when the releases contain the one that ends this resource. */
|
|
204
|
+
const isReleased = (resource, releases) => {
|
|
205
|
+
const { handle } = resource
|
|
206
|
+
switch (resource.kind) {
|
|
207
|
+
case "timer":
|
|
208
|
+
case "frame": {
|
|
209
|
+
if (!handle) return false
|
|
210
|
+
const names = HANDLE_RELEASERS[resource.kind]
|
|
211
|
+
return releases.some((release) => {
|
|
212
|
+
if (release.kind === "release") return names.has(release.name) && handle.key !== undefined && release.arg === handle.key
|
|
213
|
+
if (release.kind === "release-ref") return names.has(release.name) && handle.container !== undefined && release.near.has(handle.container)
|
|
214
|
+
return false
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
case "listener":
|
|
218
|
+
if (resource.signalOwner) return releases.some((release) => release.kind === "method" && release.name === "abort" && release.receiver === resource.signalOwner)
|
|
219
|
+
if (!resource.listener || !resource.target || !resource.type) return false
|
|
220
|
+
return releases.some((release) => release.kind === "remove" && release.target === resource.target && release.type === resource.type && release.listener === resource.listener)
|
|
221
|
+
case "observer":
|
|
222
|
+
return Boolean(handle?.key) && releases.some((release) => release.kind === "method" && release.name === "disconnect" && release.receiver === handle.key)
|
|
223
|
+
case "channel":
|
|
224
|
+
return Boolean(handle?.key) && releases.some((release) => release.kind === "method" && release.name === "close" && release.receiver === handle.key)
|
|
225
|
+
default:
|
|
226
|
+
return Boolean(handle?.key) && releases.some((release) => release.kind === "method" && SUBSCRIPTION_RELEASES.includes(release.name) && release.receiver === handle.key)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What to write to release a resource, for the message. */
|
|
231
|
+
const releaseHint = (resource) => {
|
|
232
|
+
switch (resource.kind) {
|
|
233
|
+
case "timer":
|
|
234
|
+
return "keep the handle (`const id = " + resource.name + "(...)`) and return `() => clearTimeout(id)` (or `clearInterval`)"
|
|
235
|
+
case "frame":
|
|
236
|
+
return "keep the id (`const id = requestAnimationFrame(...)`) and return `() => cancelAnimationFrame(id)`"
|
|
237
|
+
case "listener":
|
|
238
|
+
return resource.signalOwner
|
|
239
|
+
? "return `() => controller.abort()` on the controller whose signal you passed"
|
|
240
|
+
: `name the listener once and return \`() => ${resource.receiverText ? `${resource.receiverText}.` : ""}removeEventListener(${resource.typeText}, handler)\` with the same target, type and handler (or pass \`{ signal }\` and abort it)`
|
|
241
|
+
case "observer":
|
|
242
|
+
return "return `() => observer.disconnect()` on the observer you stored"
|
|
243
|
+
case "channel":
|
|
244
|
+
return "return `() => connection.close()` on the connection you stored"
|
|
245
|
+
default:
|
|
246
|
+
return "return `() => subscription.unsubscribe()` (or `off` / `close`) on the subscription you stored"
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** True when the observer is `.observe`d somewhere under the owner (an observer that observes nothing holds nothing). */
|
|
251
|
+
const isObserved = (context, resource, owner) => {
|
|
252
|
+
if (!resource.handle?.key) {
|
|
253
|
+
const parent = parentOf(resource.node)
|
|
254
|
+
return parent?.type === "MemberExpression" && parent.property.type === "Identifier" && parent.property.name === "observe"
|
|
255
|
+
}
|
|
256
|
+
let observed = false
|
|
257
|
+
walk(context.sourceCode, owner, (node) => {
|
|
258
|
+
if (observed) return false
|
|
259
|
+
if (node.type === "CallExpression") {
|
|
260
|
+
const parts = calleeParts(node)
|
|
261
|
+
if (parts?.receiver && parts.name === "observe" && keyOf(context, parts.receiver) === resource.handle.key) observed = true
|
|
262
|
+
}
|
|
263
|
+
return undefined
|
|
264
|
+
})
|
|
265
|
+
return observed
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The functions an owner returns as its cleanup (every top-level `return`, or the expression body of an arrow). */
|
|
269
|
+
const cleanupsOf = (context, owner) => {
|
|
270
|
+
const cleanups = []
|
|
271
|
+
const push = (expression) => {
|
|
272
|
+
const inner = unwrap(expression)
|
|
273
|
+
if (!inner) return
|
|
274
|
+
if (inner.type === "ConditionalExpression") {
|
|
275
|
+
push(inner.consequent)
|
|
276
|
+
push(inner.alternate)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
const fn = functionOf(context, inner)
|
|
280
|
+
if (fn) cleanups.push(fn)
|
|
281
|
+
}
|
|
282
|
+
if (owner.body.type !== "BlockStatement") {
|
|
283
|
+
push(owner.body)
|
|
284
|
+
return cleanups
|
|
285
|
+
}
|
|
286
|
+
walk(context.sourceCode, owner.body, (node) => {
|
|
287
|
+
if (node !== owner.body && isFn(node)) return false
|
|
288
|
+
if (node.type === "ReturnStatement" && node.argument) push(node.argument)
|
|
289
|
+
return undefined
|
|
290
|
+
})
|
|
291
|
+
return cleanups
|
|
82
292
|
}
|
|
83
293
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
294
|
+
/**
|
|
295
|
+
* Every effect and `useSyncExternalStore` subscribe in the file starts things that must end with it: a resource started
|
|
296
|
+
* by one is released by the cleanup it returns, against the same handle, target and listener.
|
|
297
|
+
*/
|
|
298
|
+
export const effectSubscriptionNeedsCleanup = {
|
|
86
299
|
meta: {
|
|
87
300
|
type: "problem",
|
|
88
|
-
docs: { description: "
|
|
301
|
+
docs: { description: "A timer, frame, listener, observer, socket or subscription started in an effect or a store subscribe is released, against the same handle, by the cleanup it returns." },
|
|
89
302
|
schema: [],
|
|
90
303
|
messages: {
|
|
304
|
+
unreleased:
|
|
305
|
+
"`{{what}}` is started here but the cleanup this {{owner}} returns does not release it against the same {{same}}. On unmount, and before the next run, the old one is still live: it fires later and sets state on a component that is gone, or keeps a listener, observer or connection open. In the cleanup: {{hint}}.",
|
|
91
306
|
orphan:
|
|
92
|
-
"`{{
|
|
93
|
-
noCleanup:
|
|
94
|
-
"`{{timer}}` is started in an effect that returns no cleanup which clears it. On unmount or on the next run the old timer is still pending. Return `() => clearTimeout(id)` (or `clearInterval`).",
|
|
307
|
+
"`{{what}}` is started outside an effect and nothing in this function releases it. Nothing ends it when the component unmounts, so it fires later and sets state on a component that is gone. Start it in a `useEffect` and release it in the returned cleanup, or keep the handle and clear it in this same function.",
|
|
95
308
|
},
|
|
96
309
|
},
|
|
97
310
|
create(context) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const source = context.sourceCode ?? context.getSourceCode()
|
|
311
|
+
if (!isProductSource(context)) return {}
|
|
312
|
+
const owners = new Map()
|
|
313
|
+
const resources = []
|
|
102
314
|
return {
|
|
103
315
|
CallExpression(node) {
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
316
|
+
const hook = reactHookOf(context, node)
|
|
317
|
+
if (hook && (EFFECT_HOOKS.has(hook) || hook === "useSyncExternalStore") && node.arguments[0]) {
|
|
318
|
+
const fn = functionOf(context, node.arguments[0])
|
|
319
|
+
if (fn) owners.set(fn, EFFECT_HOOKS.has(hook) ? "effect" : "subscribe")
|
|
320
|
+
}
|
|
321
|
+
resources.push(...resourcesOf(context, node))
|
|
322
|
+
},
|
|
323
|
+
NewExpression(node) {
|
|
324
|
+
resources.push(...resourcesOf(context, node))
|
|
325
|
+
},
|
|
326
|
+
"Program:exit"(program) {
|
|
327
|
+
for (const resource of resources) {
|
|
328
|
+
let owner = null
|
|
329
|
+
for (let current = resource.node.parent; current && !owner; current = current.parent) if (isFn(current) && owners.has(current)) owner = current
|
|
330
|
+
const what = resource.kind === "listener" ? `addEventListener(${resource.typeText}, ${resource.listenerText})` : resource.kind === "subscription" ? resource.name : resource.kind === "observer" || resource.kind === "channel" ? `new ${resource.name}(...)` : `${resource.name}(...)`
|
|
331
|
+
if (!owner) {
|
|
332
|
+
if (resource.kind !== "timer" && resource.kind !== "frame") continue
|
|
333
|
+
const scope = nearestFunction(resource.node) ?? program
|
|
334
|
+
if (!isReleased(resource, releasesIn(context, [scope]))) context.report({ node: resource.node, messageId: "orphan", data: { what } })
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
if (resource.kind === "observer" && !isObserved(context, resource, owner)) continue
|
|
338
|
+
if (resource.kind === "subscription" && !isSubscriptionObject(context, resource.node)) continue
|
|
339
|
+
if (isReleased(resource, releasesIn(context, cleanupsOf(context, owner)))) continue
|
|
340
|
+
const same = resource.kind === "listener" ? "target, event type and listener" : "handle"
|
|
341
|
+
context.report({ node: resource.node, messageId: "unreleased", data: { what, owner: owners.get(owner) === "effect" ? "effect" : "subscribe", same, hint: releaseHint(resource) } })
|
|
342
|
+
}
|
|
112
343
|
},
|
|
113
344
|
}
|
|
114
345
|
},
|
|
@@ -116,19 +347,66 @@ export const timerNeedsEffectCleanup = {
|
|
|
116
347
|
|
|
117
348
|
// -- HYGIENE-2 -------------------------------------------------------------------------------------
|
|
118
349
|
|
|
119
|
-
/**
|
|
120
|
-
const
|
|
121
|
-
|
|
350
|
+
/** The call at the bottom of a chain: `a().b().c()` starts at `a()`. */
|
|
351
|
+
const rootCallOf = (call) => {
|
|
352
|
+
let current = call
|
|
353
|
+
for (;;) {
|
|
354
|
+
const callee = unwrap(current.callee)
|
|
355
|
+
if (callee.type === "MemberExpression") {
|
|
356
|
+
const object = unwrap(callee.object)
|
|
357
|
+
if (object.type === "CallExpression") {
|
|
358
|
+
current = object
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return current
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** True when a `.then` / `.catch` / `.finally` sits anywhere in the call chain. */
|
|
367
|
+
const isThenChained = (call) => {
|
|
368
|
+
let current = call
|
|
369
|
+
for (;;) {
|
|
370
|
+
const callee = unwrap(current.callee)
|
|
371
|
+
if (callee.type !== "MemberExpression") return false
|
|
372
|
+
if (!callee.computed && callee.property.type === "Identifier" && ["then", "catch", "finally"].includes(callee.property.name)) return true
|
|
373
|
+
const object = unwrap(callee.object)
|
|
374
|
+
if (object.type !== "CallExpression") return false
|
|
375
|
+
current = object
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* True when a call started by the effect body is a data load: it yields a promise, is `.then`-ed, or is `void`-ed
|
|
381
|
+
* while nothing says what it is. A platform call other than `fetch` (`audio.play()`, `clipboard.writeText(...)`) is not
|
|
382
|
+
* a data load and is left alone.
|
|
383
|
+
*/
|
|
384
|
+
const startsLoad = (context, call) => {
|
|
385
|
+
const root = rootCallOf(call)
|
|
386
|
+
const rootCallee = unwrap(root.callee)
|
|
387
|
+
const isPlatformCall = (rootCallee.type === "Identifier" || rootCallee.type === "MemberExpression") && isPlatform(context, rootCallee)
|
|
388
|
+
if (isPlatformCall && calleeParts(root)?.name !== "fetch") return false
|
|
389
|
+
if (isPromiseValued(context, call) || isThenChained(call)) return true
|
|
390
|
+
const parent = parentOf(call)
|
|
391
|
+
return parent?.type === "UnaryExpression" && parent.operator === "void" && isUnresolvedType(context, call)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The first load the effect callback starts synchronously (an immediately-invoked function counts; a listener, timer callback or the cleanup does not). */
|
|
395
|
+
const loadStartedBy = (context, effect) => {
|
|
122
396
|
let found = null
|
|
123
|
-
walk(sourceCode, effect.body, (node) => {
|
|
397
|
+
walk(context.sourceCode, effect.body, (node) => {
|
|
124
398
|
if (found) return false
|
|
125
|
-
if (node
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
399
|
+
if (node !== effect.body && isFn(node)) {
|
|
400
|
+
const parent = parentOf(node)
|
|
401
|
+
return parent?.type === "CallExpression" && unwrap(parent.callee) === node ? undefined : false
|
|
402
|
+
}
|
|
403
|
+
if (node.type === "AwaitExpression") {
|
|
404
|
+
found = node
|
|
405
|
+
return false
|
|
406
|
+
}
|
|
407
|
+
if (node.type === "CallExpression" && startsLoad(context, node)) {
|
|
408
|
+
found = node
|
|
409
|
+
return false
|
|
132
410
|
}
|
|
133
411
|
return undefined
|
|
134
412
|
})
|
|
@@ -139,22 +417,22 @@ const fetchesIn = (sourceCode, effect) => {
|
|
|
139
417
|
export const noDataFetchInEffect = {
|
|
140
418
|
meta: {
|
|
141
419
|
type: "problem",
|
|
142
|
-
docs: { description: "
|
|
420
|
+
docs: { description: "An effect body starts no promise: no `await`, `fetch`, `.then`, `void load()`, `mutate()` or `refresh()`; read through SWR or a server reader." },
|
|
143
421
|
schema: [],
|
|
144
422
|
messages: {
|
|
145
423
|
fetch:
|
|
146
|
-
"This effect
|
|
424
|
+
"This effect starts a load: it awaits, calls `fetch`, chains `.then`, or calls a function that returns a promise (`void load()`, an SWR `mutate()` or `refresh()` too). An effect has no cache, no request dedupe, no error or loading state and no cancel, so two components asking for the same thing fetch twice and a slow first answer overwrites a fast second one. Data freshness comes from the SWR key, not from an effect: call the app client through SWR (`hooks/`) keyed on what changes, run `mutate()` from the event that changed the data (a handler, a socket message), and read what a route needs in a server reader (`modules/api/<domain>/read-*.ts`).",
|
|
147
425
|
},
|
|
148
426
|
},
|
|
149
427
|
create(context) {
|
|
150
|
-
if (!
|
|
151
|
-
const source = context.sourceCode ?? context.getSourceCode()
|
|
428
|
+
if (!isProductSource(context)) return {}
|
|
152
429
|
return {
|
|
153
430
|
CallExpression(node) {
|
|
154
|
-
|
|
155
|
-
|
|
431
|
+
const hook = reactHookOf(context, node)
|
|
432
|
+
if (!hook || !EFFECT_HOOKS.has(hook) || !node.arguments[0]) return
|
|
433
|
+
const effect = functionOf(context, node.arguments[0])
|
|
156
434
|
if (!effect) return
|
|
157
|
-
const found =
|
|
435
|
+
const found = loadStartedBy(context, effect)
|
|
158
436
|
if (found) context.report({ node: found, messageId: "fetch" })
|
|
159
437
|
},
|
|
160
438
|
}
|
|
@@ -177,7 +455,7 @@ export const noEmptyCatch = {
|
|
|
177
455
|
},
|
|
178
456
|
},
|
|
179
457
|
create(context) {
|
|
180
|
-
if (!
|
|
458
|
+
if (!isProductSource(context)) return {}
|
|
181
459
|
return {
|
|
182
460
|
CatchClause(node) {
|
|
183
461
|
if (node.body.body.length === 0) context.report({ node, messageId: "empty" })
|
|
@@ -186,7 +464,7 @@ export const noEmptyCatch = {
|
|
|
186
464
|
const callee = node.callee
|
|
187
465
|
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || callee.property.name !== "catch") return
|
|
188
466
|
const handler = node.arguments[0]
|
|
189
|
-
if (!handler || !
|
|
467
|
+
if (!handler || !isFn(handler)) return
|
|
190
468
|
const empty =
|
|
191
469
|
(handler.body.type === "BlockStatement" && handler.body.body.length === 0) ||
|
|
192
470
|
(handler.body.type === "Identifier" && handler.body.name === "undefined") ||
|
|
@@ -211,7 +489,7 @@ export const noConsole = {
|
|
|
211
489
|
},
|
|
212
490
|
},
|
|
213
491
|
create(context) {
|
|
214
|
-
if (!
|
|
492
|
+
if (!isProductSource(context)) return {}
|
|
215
493
|
return {
|
|
216
494
|
CallExpression(node) {
|
|
217
495
|
const callee = node.callee
|
|
@@ -225,7 +503,7 @@ export const noConsole = {
|
|
|
225
503
|
|
|
226
504
|
/** The rules this law contributes to the plugin. */
|
|
227
505
|
export const rules = {
|
|
228
|
-
"
|
|
506
|
+
"effect-subscription-needs-cleanup": effectSubscriptionNeedsCleanup,
|
|
229
507
|
"no-data-fetch-in-effect": noDataFetchInEffect,
|
|
230
508
|
"no-empty-catch": noEmptyCatch,
|
|
231
509
|
"no-console": noConsole,
|