no-await-before-copy-to-clipboard.js140 lines · main
1/**
2 * ESLint rule to prevent calling copyToClipboard after an await expression.
3 *
4 * Safari doesn't support async clipboard operations - the clipboard write must happen
5 * synchronously with the user gesture. The copyToClipboard function accepts a Promise<string>
6 * to handle this, but if you await before calling it, Safari will fail.
7 *
8 * BAD:
9 * const data = await fetchData()
10 * copyToClipboard(data)
11 *
12 * GOOD:
13 * copyToClipboard(fetchData())
14 * // or
15 * copyToClipboard(fetchData().then(format))
16 */
17
18/** @type {import('eslint').Rule.RuleModule} */
19module.exports = {
20 meta: {
21 type: 'problem',
22 docs: {
23 description:
24 'Disallow calling copyToClipboard after an await expression (breaks Safari clipboard)',
25 recommended: true,
26 },
27 messages: {
28 noAwaitBeforeCopy:
29 'Do not call copyToClipboard after an await. Safari requires clipboard operations to be synchronous with user gestures. Pass a Promise directly to copyToClipboard instead.',
30 },
31 schema: [],
32 },
33
34 create(context) {
35 // Track await expressions per function scope
36 const functionScopes = new Map()
37
38 /**
39 * Get the nearest function scope for a node
40 */
41 function getFunctionScope(node) {
42 let current = node.parent
43 while (current) {
44 if (
45 current.type === 'FunctionDeclaration' ||
46 current.type === 'FunctionExpression' ||
47 current.type === 'ArrowFunctionExpression'
48 ) {
49 return current
50 }
51 current = current.parent
52 }
53 return null
54 }
55
56 /**
57 * Check if nodeA comes before nodeB in source order
58 */
59 function isBefore(nodeA, nodeB) {
60 return nodeA.range[1] <= nodeB.range[0]
61 }
62
63 /**
64 * Check if the await is in a path that leads to the copyToClipboard call.
65 */
66 function isAwaitInPathTo(awaitNode, copyNode) {
67 // Simple heuristic: if the await comes before the copy in source order
68 // and they're in the same function, it's likely in the execution path.
69 // This may have some false positives for complex control flow, but it's
70 // better to be safe for Safari compatibility.
71 return isBefore(awaitNode, copyNode)
72 }
73
74 return {
75 // Track when we enter a function that could be async
76 ':function'(node) {
77 functionScopes.set(node, { awaitExpressions: [], isAsync: node.async })
78 },
79
80 // Track when we exit a function
81 ':function:exit'(node) {
82 functionScopes.delete(node)
83 },
84
85 // Record all await expressions
86 AwaitExpression(node) {
87 const funcScope = getFunctionScope(node)
88 if (funcScope && functionScopes.has(funcScope)) {
89 functionScopes.get(funcScope).awaitExpressions.push(node)
90 }
91 },
92
93 // Check copyToClipboard calls
94 CallExpression(node) {
95 // Check if this is a call to copyToClipboard
96 const callee = node.callee
97 let isCopyToClipboard = false
98
99 if (callee.type === 'Identifier' && callee.name === 'copyToClipboard') {
100 isCopyToClipboard = true
101 } else if (
102 callee.type === 'MemberExpression' &&
103 callee.property.type === 'Identifier' &&
104 callee.property.name === 'copyToClipboard'
105 ) {
106 isCopyToClipboard = true
107 }
108
109 if (!isCopyToClipboard) {
110 return
111 }
112
113 // Find the function scope
114 const funcScope = getFunctionScope(node)
115 if (!funcScope || !functionScopes.has(funcScope)) {
116 return
117 }
118
119 const scopeInfo = functionScopes.get(funcScope)
120
121 // Only check async functions (only they can have await)
122 if (!scopeInfo.isAsync) {
123 return
124 }
125
126 // Check if any await expression comes before this copyToClipboard call
127 for (const awaitExpr of scopeInfo.awaitExpressions) {
128 if (isAwaitInPathTo(awaitExpr, node)) {
129 context.report({
130 node,
131 messageId: 'noAwaitBeforeCopy',
132 })
133 // Only report once per call
134 return
135 }
136 }
137 },
138 }
139 },
140}