no-await-before-copy-to-clipboard.js 4.1 KB

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