@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +17 -18
  3. package/brand-values.mjs +1 -3
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +125 -37
  6. package/comments.mjs +14 -21
  7. package/docs/client-boundary.md +26 -1
  8. package/docs/grammar-boundary.md +33 -0
  9. package/docs/hygiene.md +15 -4
  10. package/docs/lint-escape-hatch.md +3 -1
  11. package/docs/next-conventions.md +26 -0
  12. package/docs/size-growth.md +29 -0
  13. package/docs/spec-quality.md +15 -7
  14. package/docs/status-colors.md +32 -0
  15. package/docs/translation.md +4 -0
  16. package/docs/transport.md +36 -9
  17. package/e2e-shape.mjs +20 -23
  18. package/env-owner.mjs +1 -1
  19. package/file-layout.mjs +105 -103
  20. package/formatting.mjs +2 -4
  21. package/grammar-boundary.mjs +85 -9
  22. package/hooks-folder.mjs +17 -16
  23. package/hygiene.mjs +370 -92
  24. package/icon.mjs +21 -20
  25. package/index.mjs +27 -85
  26. package/landmark.mjs +0 -11
  27. package/lib/bindings.mjs +144 -0
  28. package/lib/config.mjs +79 -0
  29. package/lib/hfs.mjs +48 -0
  30. package/lib/next.mjs +6 -0
  31. package/lib/platform.mjs +66 -0
  32. package/lib/react.mjs +28 -0
  33. package/lib/scope.mjs +119 -19
  34. package/lib/status-tones.generated.mjs +9 -0
  35. package/lib/types.mjs +63 -0
  36. package/lib/why.mjs +41 -11
  37. package/lint-escape-hatch.mjs +17 -15
  38. package/loading.mjs +9 -16
  39. package/naming.mjs +8 -2
  40. package/next-conventions.mjs +109 -25
  41. package/package.json +9 -4
  42. package/props-and-slots.mjs +19 -13
  43. package/runtime/engine/plain-object.mjs +5 -0
  44. package/runtime/engine/runtime-root.mjs +32 -0
  45. package/runtime/engine/yaml.mjs +161 -0
  46. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  47. package/runtime/scripts/lib/git.mjs +53 -0
  48. package/runtime/scripts/lib/glob.mjs +23 -0
  49. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  50. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  51. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  52. package/runtime/scripts/lib/next-contract.mjs +14 -0
  53. package/runtime/scripts/lib/path-key.mjs +17 -0
  54. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  55. package/served-locale.mjs +30 -16
  56. package/shape-slot.mjs +22 -20
  57. package/size-and-state-budget.mjs +41 -23
  58. package/size-growth.mjs +58 -0
  59. package/spec-quality.mjs +95 -16
  60. package/status-colors.mjs +139 -0
  61. package/the-split.mjs +13 -13
  62. package/tokens.mjs +26 -34
  63. package/translation.mjs +52 -19
  64. package/transport.mjs +437 -89
  65. package/type-safety.mjs +8 -17
  66. package/typography.mjs +7 -11
  67. package/vendor-boundary.mjs +5 -27
  68. 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 timer, catch and console rules, R50
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 outside an effect, or in one that never clears it, fires after the component is
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 { calleeName, effectCallback, isEffectCall, isFunction, walk } from "./lib/ast.mjs"
16
- import { isSpecFile } from "./lib/scope.mjs"
17
- import { normalizePath } from "./lib/path.mjs"
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
- /** Product source outside specs. */
20
- const isGoverned = (filename) => {
21
- const file = normalizePath(filename)
22
- return file.includes("/src/") && !isSpecFile(file)
23
- }
27
+ // -- HYGIENE-1 -------------------------------------------------------------------------------------
24
28
 
25
- /** The timer functions, bare or on the global. */
26
- const TIMERS = new Set(["setTimeout", "setInterval", "window.setTimeout", "window.setInterval", "globalThis.setTimeout", "globalThis.setInterval"])
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
- // -- HYGIENE-1 -------------------------------------------------------------------------------------
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 effect callback that encloses `node`, crossing any function boundary, or null. */
31
- const enclosingEffect = (node) => {
32
- for (let current = node.parent; current; current = current.parent) {
33
- if (isFunction(current) && current.parent?.type === "CallExpression" && isEffectCall(current.parent) && effectCallback(current.parent) === current) {
34
- return current
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 value an effect callback returns at its own top level (the cleanup), or null. */
41
- const cleanupOf = (effect) => {
42
- if (effect.body.type !== "BlockStatement") return null
43
- const last = [...effect.body.body].reverse().find((statement) => statement.type === "ReturnStatement")
44
- return last?.argument ?? null
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
- const CLEARERS = new Set(["clearTimeout", "clearInterval", "window.clearTimeout", "window.clearInterval", "globalThis.clearTimeout", "globalThis.clearInterval"])
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
- /** The identifier a timer call's handle is stored in (`const id = ...` or `id = ...`), or null. */
50
- const handleOf = (call) => {
51
- const parent = call.parent
52
- if (parent?.type === "VariableDeclarator" && parent.init === call && parent.id.type === "Identifier") return parent.id.name
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 function a function directly returns (arrow expression body, or its last top-level `return`), or null. */
58
- const returnedFunction = (fn) => {
59
- if (fn.body.type !== "BlockStatement") return isFunction(fn.body) ? fn.body : null
60
- const last = [...fn.body.body].reverse().find((statement) => statement.type === "ReturnStatement")
61
- return last?.argument && isFunction(last.argument) ? last.argument : null
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
- * True when a function enclosing the timer call returns a closure that clears the same handle: the
66
- * `subscribe` of `useSyncExternalStore`, or any owner that hands back its own unsubscribe.
67
- */
68
- const clearedByReturnedCleanup = (call, source) => {
69
- const handle = handleOf(call)
70
- if (!handle) return false
71
- for (let current = call.parent; current; current = current.parent) {
72
- if (current.type !== "FunctionExpression" && current.type !== "ArrowFunctionExpression" && current.type !== "FunctionDeclaration") continue
73
- const cleanup = returnedFunction(current)
74
- if (!cleanup) continue
75
- let clears = false
76
- walk(source, cleanup, (node) => {
77
- if (node.type === "CallExpression" && CLEARERS.has(calleeName(node.callee)) && node.arguments[0]?.type === "Identifier" && node.arguments[0].name === handle) clears = true
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 false
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
- /** A timer belongs in an effect that clears it, or in a function whose returned cleanup clears it. */
85
- export const timerNeedsEffectCleanup = {
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: "`setTimeout` and `setInterval` live in an effect whose cleanup clears them." },
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
- "`{{timer}}` is started outside an effect. 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 clear it in the cleanup, or move it into a hook that does.",
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
- const filename = context.filename || context.getFilename()
99
- // Non-React modules (the API client's timeout, the theme bootstrap) own their timers and clear them in `finally`.
100
- if (!isGoverned(filename) || /\/modules\//.test(normalizePath(filename))) return {}
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 name = calleeName(node.callee)
105
- if (!name || !TIMERS.has(name)) return
106
- if (clearedByReturnedCleanup(node, source)) return
107
- const effect = enclosingEffect(node)
108
- if (!effect) return context.report({ node, messageId: "orphan", data: { timer: name } })
109
- const cleanup = cleanupOf(effect)
110
- const clears = cleanup && /\bclear(?:Timeout|Interval)\b/.test(source.getText(cleanup))
111
- if (!clears) context.report({ node, messageId: "noCleanup", data: { timer: name } })
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
- /** A fetch in an effect: `await`, `fetch(...)`, `.then(...)`, outside the cleanup function. */
120
- const fetchesIn = (sourceCode, effect) => {
121
- const cleanup = cleanupOf(effect)
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 === cleanup) return false
126
- if (node.type === "AwaitExpression") found = node
127
- else if (node.type === "CallExpression") {
128
- const name = calleeName(node.callee)
129
- if (name === "fetch" || name === "window.fetch" || (node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier" && node.callee.property.name === "then")) {
130
- found = node
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: "No `await`, `fetch` or `.then` inside a `useEffect`; read through SWR or a server reader." },
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 loads data (`await`, `fetch` or `.then`). 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. Client reads use SWR calling the app client (`hooks/`); reads a route needs go through a server reader (`modules/api/<domain>/read-*.ts`).",
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 (!isGoverned(context.filename || context.getFilename())) return {}
151
- const source = context.sourceCode ?? context.getSourceCode()
428
+ if (!isProductSource(context)) return {}
152
429
  return {
153
430
  CallExpression(node) {
154
- if (!isEffectCall(node)) return
155
- const effect = effectCallback(node)
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 = fetchesIn(source, effect)
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 (!isGoverned(context.filename || context.getFilename())) return {}
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 || !isFunction(handler)) return
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 (!isGoverned(context.filename || context.getFilename())) return {}
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
- "timer-needs-effect-cleanup": timerNeedsEffectCleanup,
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,