@mehshekari/gsap-motion 4.2.0 → 4.3.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/README.md +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/adapter/react.md +2 -1
- package/skill/reference/devices.md +5 -2
- package/skill/reference/interaction.md +7 -0
- package/skill/reference/motion-design.md +1 -2
- package/skill/scripts/lib/ast.mjs +37 -6
- package/skill/scripts/lib/rules.mjs +174 -35
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
|
|
|
32
32
|
build without warning:
|
|
33
33
|
|
|
34
34
|
```json
|
|
35
|
-
"lint": "eslint && npx @mehshekari/gsap-motion@4.
|
|
35
|
+
"lint": "eslint && npx @mehshekari/gsap-motion@4.3.0 audit src --quiet"
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
|
|
|
4
4
|
license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
|
|
5
5
|
compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 22+ and nothing else.
|
|
6
6
|
metadata:
|
|
7
|
-
version: "4.
|
|
7
|
+
version: "4.3.0"
|
|
8
8
|
verified-gsap: "3.15"
|
|
9
9
|
verified-gsap-react: "2.1"
|
|
10
10
|
---
|
package/skill/adapter/react.md
CHANGED
|
@@ -35,7 +35,8 @@ body re-runs on change) and `revertOnUpdate` (the previous run reverts first).
|
|
|
35
35
|
A tween created *outside* the body — in a handler, a promise, a callback — is
|
|
36
36
|
never reverted. Wrap that function in `contextSafe`, from the same hook, and the
|
|
37
37
|
context owns it again. Only wrap functions that *create* animations: playing an
|
|
38
|
-
existing timeline creates nothing. `
|
|
38
|
+
existing timeline creates nothing. Inside `mm.add`, use its own: the callback's
|
|
39
|
+
second argument, `(context, contextSafe) =>`. `contextSafe` runs during render, so reading a
|
|
39
40
|
ref inside the callback trips React's "cannot access refs during render" rule —
|
|
40
41
|
build the timeline in the body, keep it on a ref, and expose a `useCallback`
|
|
41
42
|
that plays it.
|
|
@@ -23,13 +23,16 @@ mm.add(
|
|
|
23
23
|
hoverable: "(hover: hover) and (pointer: fine)",
|
|
24
24
|
motion: "(prefers-reduced-motion: no-preference)",
|
|
25
25
|
},
|
|
26
|
-
(context) => {
|
|
26
|
+
(context, contextSafe) => {
|
|
27
27
|
const { wide, hoverable, motion } = context.conditions;
|
|
28
|
-
// …one branch, three facts
|
|
28
|
+
// …one branch, three facts. A handler that tweens later: contextSafe(fn).
|
|
29
29
|
},
|
|
30
30
|
);
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
`contextSafe` is the **second** argument. The context has none, and
|
|
34
|
+
`context.add(fn)` runs `fn` at once: either one ships a blank page.
|
|
35
|
+
|
|
33
36
|
`matchMedia` re-runs the body when any condition changes and reverts what the
|
|
34
37
|
previous run created, so a visitor who rotates a phone, plugs in a mouse or
|
|
35
38
|
turns on reduced motion gets the right version without a reload.
|
|
@@ -13,6 +13,11 @@ The recovery is what makes it feel physical. A button that springs toward the
|
|
|
13
13
|
cursor and snaps back on leave feels broken; the same button easing back over
|
|
14
14
|
0.6s feels alive.
|
|
15
15
|
|
|
16
|
+
**A layer that answers the pointer follows it** — on both axes, toward the
|
|
17
|
+
cursor. One axis, or the opposite way, reads as resistance: in blind judging, a
|
|
18
|
+
card that followed beat one that drifted away every time. Depth comes from how
|
|
19
|
+
*far* each layer follows, not which way.
|
|
20
|
+
|
|
16
21
|
## The rule: `quickTo`, not a tween per event
|
|
17
22
|
|
|
18
23
|
```ts
|
|
@@ -168,6 +173,8 @@ Cheap, and the styling stays in CSS where it can follow the theme.
|
|
|
168
173
|
- Never destroy the focus ring with a transform that moves the element out from
|
|
169
174
|
under it.
|
|
170
175
|
- Gate hover effects on `(hover: hover)`. On touch, `:hover` sticks after a tap.
|
|
176
|
+
Input is its own axis, read in one `gsap.matchMedia` with layout and motion —
|
|
177
|
+
see [devices.md](devices.md).
|
|
171
178
|
- Under `prefers-reduced-motion: reduce`, keep the state change and drop the
|
|
172
179
|
travel — see [accessibility.md](accessibility.md).
|
|
173
180
|
|
|
@@ -142,11 +142,10 @@ Before calling an animation done:
|
|
|
142
142
|
- Does it say one of the five things at the top of this file?
|
|
143
143
|
- Is the rhythm intentional, or are the delays arbitrary?
|
|
144
144
|
- Does anything looping use `ease: "none"`?
|
|
145
|
+
- Does an ambient loop move only decoration, never the copy being read?
|
|
145
146
|
- Is the seam of the loop invisible?
|
|
146
147
|
- Do entrances use `out` easing and exits `in`?
|
|
147
148
|
- Does one element carry the emphasis, rather than all of them?
|
|
148
149
|
- Does it still read at 400px wide, and on a touch device with no hover?
|
|
149
150
|
- Does the reduced-motion branch still communicate the same thing?
|
|
150
|
-
- Is everything reverted on unmount?
|
|
151
|
-
- Is every plugin justified, and would a transform have done it?
|
|
152
151
|
- Watch it five times. Does anything start to irritate?
|
|
@@ -478,12 +478,43 @@ export function gsapContexts(root) {
|
|
|
478
478
|
}
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
481
|
+
/**
|
|
482
|
+
* A matchMedia is a context too: `mm.revert()` reverts what its `add`
|
|
483
|
+
* callbacks made. Its callback is recognised on `gsap.matchMedia().add` and on
|
|
484
|
+
* any name the file assigns `gsap.matchMedia()` to, followed when it is handed
|
|
485
|
+
* over by name. Whether the matchMedia itself is ever reverted is
|
|
486
|
+
* `unmanaged-instance`'s to report, not a reason to distrust its callbacks.
|
|
487
|
+
*/
|
|
488
|
+
const matchMedias = new Set(
|
|
489
|
+
findAll(
|
|
490
|
+
root,
|
|
491
|
+
(node) =>
|
|
492
|
+
(node.type === "VariableDeclarator" &&
|
|
493
|
+
Boolean(node.init) &&
|
|
494
|
+
calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
|
|
495
|
+
(node.type === "AssignmentExpression" &&
|
|
496
|
+
calleeName(unwrap(node.right)) === "gsap.matchMedia"),
|
|
497
|
+
)
|
|
498
|
+
.map((node) => dottedName(node.type === "VariableDeclarator" ? node.id : node.left))
|
|
499
|
+
.filter(Boolean),
|
|
500
|
+
);
|
|
501
|
+
const isMatchMediaAdd = (node) => {
|
|
502
|
+
if (methodName(node) !== "add") return false;
|
|
503
|
+
const object = unwrap(unwrap(node.callee).object);
|
|
504
|
+
return calleeName(object) === "gsap.matchMedia" || matchMedias.has(dottedName(object));
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const scopes = [
|
|
508
|
+
...findAll(
|
|
509
|
+
root,
|
|
510
|
+
(node) =>
|
|
511
|
+
node.type === "CallExpression" &&
|
|
512
|
+
(isHook(node) || calleeName(node) === "gsap.context" || safe.has(bareName(node))),
|
|
513
|
+
).flatMap((call) => call.arguments),
|
|
514
|
+
...findAll(root, isMatchMediaAdd).flatMap((call) =>
|
|
515
|
+
call.arguments.map((argument) => resolveFunction(root, argument)).filter(Boolean),
|
|
516
|
+
),
|
|
517
|
+
];
|
|
487
518
|
|
|
488
519
|
const counts = new Map();
|
|
489
520
|
const functions = new Map();
|
|
@@ -49,6 +49,8 @@ import {
|
|
|
49
49
|
const HINT = {
|
|
50
50
|
matchMediaNeverRuns:
|
|
51
51
|
"A matchMedia callback runs only while at least one of its named conditions matches, and a visitor with no reduced-motion preference matches none of these — so the branch for them is dead code and nothing animates. Add `motion: \"(prefers-reduced-motion: no-preference)\"` beside it, or use two separate `add` calls.",
|
|
52
|
+
contextSafeMisuse:
|
|
53
|
+
"The callback of `gsap.matchMedia().add`, `gsap.context` and `useGSAP` receives `(context, contextSafe)`: take the second argument, as in `mm.add(query, (context, contextSafe) => …)`, and wrap the delayed callback in it. `context.add(fn)` runs `fn` straight away instead.",
|
|
52
54
|
stackedFrom:
|
|
53
55
|
"A `from` or `fromTo` applies its start state the moment it is built. Built more than once against the same element, the last one wins, and holds that element at its start state until the playhead reaches it — invisible, if the start state hides it. Pass `immediateRender: false`, and set the element's first state yourself with `gsap.set`.",
|
|
54
56
|
orphanTween:
|
|
@@ -685,6 +687,45 @@ function stopped(ast, name) {
|
|
|
685
687
|
);
|
|
686
688
|
}
|
|
687
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Every `add` call on a `gsap.matchMedia()`: made on it directly, or on a name
|
|
692
|
+
* the file binds it to, at declaration or later.
|
|
693
|
+
*/
|
|
694
|
+
function matchMediaAdds(file) {
|
|
695
|
+
const media = new Set(
|
|
696
|
+
findAll(
|
|
697
|
+
file.ast,
|
|
698
|
+
(node) =>
|
|
699
|
+
(node.type === "VariableDeclarator" &&
|
|
700
|
+
node.id.type === "Identifier" &&
|
|
701
|
+
/**
|
|
702
|
+
* `let tl;` has no initializer, and `calleeName` does not accept
|
|
703
|
+
* null. Every fixture initialised its declarations, so 133 passing
|
|
704
|
+
* tests missed this; the first real project threw on it and took
|
|
705
|
+
* the whole audit down with it.
|
|
706
|
+
*/
|
|
707
|
+
Boolean(node.init) &&
|
|
708
|
+
calleeName(unwrap(node.init)) === "gsap.matchMedia") ||
|
|
709
|
+
(node.type === "AssignmentExpression" &&
|
|
710
|
+
node.left.type === "Identifier" &&
|
|
711
|
+
calleeName(unwrap(node.right)) === "gsap.matchMedia"),
|
|
712
|
+
).map((node) => (node.type === "VariableDeclarator" ? node.id.name : node.left.name)),
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
const onMatchMedia = (call) => {
|
|
716
|
+
const callee = unwrap(call.callee);
|
|
717
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
718
|
+
const object = unwrap(callee.object);
|
|
719
|
+
if (object?.type === "Identifier") return media.has(object.name);
|
|
720
|
+
return calleeName(object) === "gsap.matchMedia";
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
return findAll(file.ast, (node) => methodName(node) === "add" && onMatchMedia(node));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Nodes a value passes through unchanged on its way to whatever uses it. */
|
|
727
|
+
const TRANSPARENT = new Set(["ParenthesizedExpression", "ChainExpression", "TSAsExpression", "TSNonNullExpression"]);
|
|
728
|
+
|
|
688
729
|
export const RULES = [
|
|
689
730
|
// --- Lifecycle ------------------------------------------------------------
|
|
690
731
|
{
|
|
@@ -1606,50 +1647,67 @@ export const RULES = [
|
|
|
1606
1647
|
id: "matchmedia-never-runs",
|
|
1607
1648
|
level: "warn",
|
|
1608
1649
|
description:
|
|
1609
|
-
"A matchMedia callback that branches on
|
|
1650
|
+
"A matchMedia callback that branches on reduced motion, when every condition needs it",
|
|
1610
1651
|
/**
|
|
1611
1652
|
* `mm.add({ reduced: "(prefers-reduced-motion: reduce)" }, (context) => …)`
|
|
1612
|
-
* whose callback
|
|
1653
|
+
* whose callback branches on the preference. The callback runs only while a
|
|
1613
1654
|
* named condition matches, and a visitor with no preference matches none —
|
|
1614
1655
|
* so the branch written for them never runs, and neither does the
|
|
1615
1656
|
* animation in it. Found in a head-to-head test on a real hero, where the
|
|
1616
|
-
* whole page shipped without motion.
|
|
1657
|
+
* whole page shipped without motion, and again in phase 8's first round.
|
|
1658
|
+
*
|
|
1659
|
+
* Branching is the whole signal: reading `conditions`, or asking
|
|
1660
|
+
* `window.matchMedia` about reduced motion inside the callback. A
|
|
1661
|
+
* reduced-only `add` that does not branch is GSAP's documented pattern for
|
|
1662
|
+
* setting end states, meant to run for those visitors only, and is correct.
|
|
1617
1663
|
*
|
|
1618
|
-
* The
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1664
|
+
* The conditions are read when they are written in the call, or kept in a
|
|
1665
|
+
* `const` the file declares once. An imported object, or a `let` that may be
|
|
1666
|
+
* reassigned, cannot be judged here and is left alone.
|
|
1621
1667
|
*/
|
|
1622
1668
|
test(file) {
|
|
1623
1669
|
const REDUCE = /prefers-reduced-motion\s*:\s*reduce/i;
|
|
1670
|
+
const PREFERENCE = /prefers-reduced-motion/i;
|
|
1624
1671
|
|
|
1625
|
-
/**
|
|
1626
|
-
const
|
|
1627
|
-
|
|
1672
|
+
/** The conditions as written: in the call, or in a `const` the file declares once. */
|
|
1673
|
+
const conditionsOf = (argument) => {
|
|
1674
|
+
const target = unwrap(argument);
|
|
1675
|
+
if (target?.type !== "Identifier") return target;
|
|
1676
|
+
const declarators = findAll(
|
|
1628
1677
|
file.ast,
|
|
1629
1678
|
(node) =>
|
|
1630
1679
|
node.type === "VariableDeclarator" &&
|
|
1631
1680
|
node.id.type === "Identifier" &&
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
Boolean(node.init) &&
|
|
1639
|
-
calleeName(unwrap(node.init)) === "gsap.matchMedia",
|
|
1640
|
-
).map((node) => node.id.name),
|
|
1641
|
-
);
|
|
1681
|
+
node.id.name === target.name,
|
|
1682
|
+
);
|
|
1683
|
+
if (declarators.length !== 1) return null;
|
|
1684
|
+
const [declarator] = declarators;
|
|
1685
|
+
return parentOf(file.ast, declarator)?.kind === "const" ? unwrap(declarator.init) : null;
|
|
1686
|
+
};
|
|
1642
1687
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
if (
|
|
1648
|
-
return
|
|
1688
|
+
/** Every condition needs reduced motion: one query string, or an object of them. */
|
|
1689
|
+
const everyReduces = (conditions) => {
|
|
1690
|
+
const query = staticString(conditions);
|
|
1691
|
+
if (typeof query === "string") return REDUCE.test(query);
|
|
1692
|
+
if (conditions?.type !== "ObjectExpression" || !conditions.properties.length) return false;
|
|
1693
|
+
return conditions.properties.every((property) => {
|
|
1694
|
+
const value = staticString(property.value);
|
|
1695
|
+
return typeof value === "string" && REDUCE.test(value);
|
|
1696
|
+
});
|
|
1649
1697
|
};
|
|
1650
1698
|
|
|
1651
|
-
/** Does the callback read
|
|
1699
|
+
/** Does the callback read its conditions, or ask the browser about reduced motion? */
|
|
1652
1700
|
const branches = (fn) => {
|
|
1701
|
+
const asksTheBrowser =
|
|
1702
|
+
findAll(fn.body, (node) => {
|
|
1703
|
+
if (node.type !== "CallExpression") return false;
|
|
1704
|
+
const name = calleeName(node);
|
|
1705
|
+
if (name !== "window.matchMedia" && name !== "matchMedia") return false;
|
|
1706
|
+
const query = staticString(node.arguments[0]);
|
|
1707
|
+
return typeof query === "string" && PREFERENCE.test(query);
|
|
1708
|
+
}).length > 0;
|
|
1709
|
+
if (asksTheBrowser) return true;
|
|
1710
|
+
|
|
1653
1711
|
const [param] = fn.params;
|
|
1654
1712
|
if (!param) return false;
|
|
1655
1713
|
if (param.type === "ObjectPattern") {
|
|
@@ -1668,26 +1726,107 @@ export const RULES = [
|
|
|
1668
1726
|
}).length > 0;
|
|
1669
1727
|
};
|
|
1670
1728
|
|
|
1671
|
-
return
|
|
1729
|
+
return matchMediaAdds(file)
|
|
1672
1730
|
.filter((call) => {
|
|
1673
|
-
|
|
1674
|
-
if (conditions?.type !== "ObjectExpression" || !conditions.properties.length) return false;
|
|
1675
|
-
const everyReduces = conditions.properties.every((property) => {
|
|
1676
|
-
const value = staticString(property.value);
|
|
1677
|
-
return typeof value === "string" && REDUCE.test(value);
|
|
1678
|
-
});
|
|
1679
|
-
if (!everyReduces) return false;
|
|
1731
|
+
if (!everyReduces(conditionsOf(call.arguments[0]))) return false;
|
|
1680
1732
|
const callback = resolveFunction(file.ast, call.arguments[1]);
|
|
1681
1733
|
return Boolean(callback) && branches(callback);
|
|
1682
1734
|
})
|
|
1683
1735
|
.map((call) => ({
|
|
1684
1736
|
index: call.start,
|
|
1685
1737
|
message:
|
|
1686
|
-
"This matchMedia callback branches on
|
|
1738
|
+
"This matchMedia callback branches on reduced motion, but every condition needs reduced motion — so it never runs for a visitor without that preference.",
|
|
1687
1739
|
hint: HINT.matchMediaNeverRuns,
|
|
1688
1740
|
}));
|
|
1689
1741
|
},
|
|
1690
1742
|
},
|
|
1743
|
+
{
|
|
1744
|
+
id: "context-safe-misuse",
|
|
1745
|
+
level: "warn",
|
|
1746
|
+
description:
|
|
1747
|
+
"A callback made context-safe with the context itself, which runs it at once instead",
|
|
1748
|
+
/**
|
|
1749
|
+
* The callback of `gsap.matchMedia().add`, `gsap.context` or `useGSAP`
|
|
1750
|
+
* receives `(context, contextSafe)`. Two ways of getting that wrong both
|
|
1751
|
+
* ship a blank page:
|
|
1752
|
+
*
|
|
1753
|
+
* - `({ contextSafe }) => …` reads `contextSafe` from the context, which has
|
|
1754
|
+
* none. It is `undefined`, and the first call throws.
|
|
1755
|
+
* - `context.add(fn)` handed on as a callback. `add` with a function runs it
|
|
1756
|
+
* at once, passing the context, and returns what it returned, so the
|
|
1757
|
+
* caller receives no callback and `fn` ran with the wrong argument.
|
|
1758
|
+
*
|
|
1759
|
+
* Found in the 4.3 creative A/B: three of 27 runs, in both arms, crashed on
|
|
1760
|
+
* load this way, every one inside a matchMedia. `add` as a statement runs a
|
|
1761
|
+
* block inside the context and is correct; a named `add("onClick", fn)` and
|
|
1762
|
+
* `add(null, fn)` return a wrapper and are correct too. Only an `add` whose
|
|
1763
|
+
* result is used, given a function, is reported.
|
|
1764
|
+
*/
|
|
1765
|
+
test(file) {
|
|
1766
|
+
const callbacks = new Set(
|
|
1767
|
+
[
|
|
1768
|
+
...matchMediaAdds(file).map((call) => call.arguments[1]),
|
|
1769
|
+
...findAll(
|
|
1770
|
+
file.ast,
|
|
1771
|
+
(node) =>
|
|
1772
|
+
node.type === "CallExpression" &&
|
|
1773
|
+
["gsap.context", "useGSAP"].includes(calleeName(node)),
|
|
1774
|
+
).map((call) => call.arguments[0]),
|
|
1775
|
+
]
|
|
1776
|
+
.map((argument) => resolveFunction(file.ast, argument))
|
|
1777
|
+
.filter(Boolean),
|
|
1778
|
+
);
|
|
1779
|
+
|
|
1780
|
+
/** Is the call's result handed on, rather than thrown away as a statement? */
|
|
1781
|
+
const used = (call) => {
|
|
1782
|
+
let child = call;
|
|
1783
|
+
let parent = parentOf(file.ast, call);
|
|
1784
|
+
while (parent && TRANSPARENT.has(parent.type)) {
|
|
1785
|
+
child = parent;
|
|
1786
|
+
parent = parentOf(file.ast, parent);
|
|
1787
|
+
}
|
|
1788
|
+
if (!parent || parent.type === "ExpressionStatement" || parent.type === "SequenceExpression") {
|
|
1789
|
+
return false;
|
|
1790
|
+
}
|
|
1791
|
+
return !(parent.type === "CallExpression" && parent.callee === child);
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
const findings = [];
|
|
1795
|
+
for (const fn of callbacks) {
|
|
1796
|
+
const [param] = fn.params;
|
|
1797
|
+
if (param?.type === "ObjectPattern") {
|
|
1798
|
+
const property = param.properties.find((p) => keyName(p) === "contextSafe");
|
|
1799
|
+
if (property) {
|
|
1800
|
+
findings.push({
|
|
1801
|
+
index: property.start,
|
|
1802
|
+
message:
|
|
1803
|
+
"`contextSafe` is read from the callback's first argument, the context, which has none: it is undefined, and the first call throws.",
|
|
1804
|
+
hint: HINT.contextSafeMisuse,
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
if (param?.type !== "Identifier") continue;
|
|
1810
|
+
const adds = findAll(fn.body, (node) => {
|
|
1811
|
+
if (methodName(node) !== "add") return false;
|
|
1812
|
+
const receiver = unwrap(unwrap(node.callee).object);
|
|
1813
|
+
return receiver?.type === "Identifier" && receiver.name === param.name;
|
|
1814
|
+
});
|
|
1815
|
+
for (const call of adds) {
|
|
1816
|
+
const target = unwrap(call.arguments[0]);
|
|
1817
|
+
if (!target || target.type === "Literal" || target.type === "TemplateLiteral") continue;
|
|
1818
|
+
if (!isFunction(target) && target.type !== "Identifier") continue;
|
|
1819
|
+
if (!used(call)) continue;
|
|
1820
|
+
findings.push({
|
|
1821
|
+
index: call.start,
|
|
1822
|
+
message: `\`${param.name}.add(fn)\` runs fn at once and returns its result, so what is handed on here is not a callback.`,
|
|
1823
|
+
hint: HINT.contextSafeMisuse,
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return findings;
|
|
1828
|
+
},
|
|
1829
|
+
},
|
|
1691
1830
|
{
|
|
1692
1831
|
id: "stacked-from",
|
|
1693
1832
|
level: "warn",
|