Repository: swagger-api/swagger-ui (js)

Issue:
Console errors when scrolling Swagger UI with request snippets: "TypeError: Cannot read properties of undefined (reading 'preventYScrollingBeyondElement')"
### Q&A (please complete the following information)
 - OS: Windows 10
 - Browser: Chrome (latest)
 - Method of installation: https://petstore.swagger.io/?requestSnippetsEnabled=true
 - Swagger-UI version: 4.8.1
 - Swagger/OpenAPI version: any

### Content & configuration

Swagger UI must be configured with `requestSnippetsEnabled` = `true`.

For example, you can use https://petstore.swagger.io/?requestSnippetsEnabled=true

### Describe the bug you're encountering
If you switch between the request snippet tabs and then scroll the page up/down, the following error is logged to the console. The error does not seem to affect any functionality though.
```
Uncaught TypeError: Cannot read properties of undefined (reading 'preventYScrollingBeyondElement')
    at onWheel (request-snippets.jsx:76:45)
    at Object.Ve (react-dom.production.min.js:52:317)
    at Ge (react-dom.production.min.js:52:471)
    at react-dom.production.min.js:53:35
    at Or (react-dom.production.min.js:100:68)
    at jr (react-dom.production.min.js:101:380)
    at react-dom.production.min.js:113:65
    at De (react-dom.production.min.js:292:189)
    at react-dom.production.min.js:50:57
    at Mr (react-dom.production.min.js:105:469)
```

### To reproduce...
1. Open https://petstore.swagger.io/?requestSnippetsEnabled=true
2. Open the browser console.
3. Execute `POST /pet` with the default request body.
4. In the request snippets section, switch to the "cURL (PowerShell)" tab.
5. Scroll the page up-down.
6. In the request snippets section, switch to the "cURL (CMD)" tab.
7. Scroll the page up-down.

At some point during steps 4-7 you'll see the console errors about `preventYScrollingBeyondElement`.

### Expected behavior
No errors in the console.

### Screenshots
![image](https://user-images.githubusercontent.com/8576823/159689650-c9964d4c-bb6c-41a7-975e-f200b3b3bcd1.png)

### Additional context or thoughts
n/a


Pull request description:
fix(request-snippets): prevent scrolling errors from missing function
<!--- Provide a general summary of your changes in the Title above -->

### Description
<!--- Describe your changes in detail -->

* Update `RequestSnippets` to functional component with React Hooks
* React Hooks allows us to implement `useRef` and `useEffect`, to localize adding/removing event listeners to `RequestSnippets`
* Update `LiveResponse` Mocha test with updated import of `RequestSnippets`

### Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
<!--- Use the magic "Fixes #1234" format, so the issues are -->
<!--- automatically closed when this PR is merged. -->

Fixes #7932
Followup to #7497 , where we still want to leverage `useRef`

Root cause is that #7497 removed the `preventYScrollingBeyondElement` from parent class, so this PR removes any additional references

### How Has This Been Tested?
<!--- Please describe in detail how you manually tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->

all existing tests pass.
local testing to confirm console.errors no longer appear

### Screenshots (if appropriate):



## Checklist
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

### My PR contains... 
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] No code changes (`src/` is unmodified: changes to documentation, CI, metadata, etc.)
- [ ] Dependency changes (any modification to dependencies in `package.json`)
- [x] Bug fixes (non-breaking change which fixes an issue)
- [x] Improvements (misc. changes to existing features)
- [ ] Features (non-breaking change which adds functionality)

### My changes...
- [ ] are breaking changes to a public API (config options, System API, major UI change, etc).
- [ ] are breaking changes to a private API (Redux, component props, utility functions, etc.).
- [ ] are breaking changes to a developer API (npm script behavior changes, new dev system dependencies, etc).
- [x] are not breaking changes.

### Documentation
- [x] My changes do not require a change to the project documentation.
- [ ] My changes require a change to the project documentation.
- [ ] If yes to above: I have updated the documentation accordingly.

### Automated tests
- [ ] My changes can not or do not need to be tested.
- [x] My changes can and should be tested by unit and/or integration tests.
- [ ] If yes to above: I have added tests to cover my changes.
- [ ] If yes to above: I have taken care to cover edge cases in my tests.
- [x] All new and existing tests passed.


Patch:
diff --git a/src/core/components/curl.jsx b/src/core/components/curl.jsx
index 44f3b15e..eefce029 100644
--- a/src/core/components/curl.jsx
+++ b/src/core/components/curl.jsx
@@ -21,7 +21,6 @@ export default class Curl extends React.Component {
       ? <SyntaxHighlighter
           language="bash"
           className="curl microlight"
-          onWheel={this.preventYScrollingBeyondElement}
           style={getStyle(get(config, "syntaxHighlight.theme"))}
           >
           {curl}
diff --git a/src/core/plugins/request-snippets/index.js b/src/core/plugins/request-snippets/index.js
index cbda08f9..08bc8cc4 100644
--- a/src/core/plugins/request-snippets/index.js
+++ b/src/core/plugins/request-snippets/index.js
@@ -1,6 +1,6 @@
 import * as fn from "./fn"
 import * as selectors from "./selectors"
-import { RequestSnippets } from "./request-snippets"
+import RequestSnippets from "./request-snippets"
 export default () => {
   return {
     components: {
diff --git a/src/core/plugins/request-snippets/request-snippets.jsx b/src/core/plugins/request-snippets/request-snippets.jsx
index 22b2ffe1..0410c035 100644
--- a/src/core/plugins/request-snippets/request-snippets.jsx
+++ b/src/core/plugins/request-snippets/request-snippets.jsx
@@ -1,127 +1,160 @@
-import React from "react"
-import { CopyToClipboard } from "react-copy-to-clipboard"
+import React, { useRef, useEffect, useState } from "react"
 import PropTypes from "prop-types"
 import get from "lodash/get"
-import {SyntaxHighlighter, getStyle} from "core/syntax-highlighting"
-
-export class RequestSnippets extends React.Component {
-  constructor() {
-    super()
-    this.state = {
-      activeLanguage: this.props?.requestSnippetsSelectors?.getSnippetGenerators()?.keySeq().first(),
-      expanded: this.props?.requestSnippetsSelectors?.getDefaultExpanded(),
+import isFunction from "lodash/isFunction"
+import { CopyToClipboard } from "react-copy-to-clipboard"
+import { SyntaxHighlighter, getStyle } from "core/syntax-highlighting"
+
+const style = {
+  cursor: "pointer",
+  lineHeight: 1,
+  display: "inline-flex",
+  backgroundColor: "rgb(250, 250, 250)",
+  paddingBottom: "0",
+  paddingTop: "0",
+  border: "1px solid rgb(51, 51, 51)",
+  borderRadius: "4px 4px 0 0",
+  boxShadow: "none",
+  borderBottom: "none"
+}
+
+const activeStyle = {
+  cursor: "pointer",
+  lineHeight: 1,
+  display: "inline-flex",
+  backgroundColor: "rgb(51, 51, 51)",
+  boxShadow: "none",
+  border: "1px solid rgb(51, 51, 51)",
+  paddingBottom: "0",
+  paddingTop: "0",
+  borderRadius: "4px 4px 0 0",
+  marginTop: "-5px",
+  marginRight: "-5px",
+  marginLeft: "-5px",
+  zIndex: "9999",
+  borderBottom: "none"
+}
+
+const RequestSnippets = ({ request, requestSnippetsSelectors, getConfigs }) => {
+  const config = isFunction(getConfigs) ? getConfigs() : null
+  const canSyntaxHighlight = get(config, "syntaxHighlight") !== false && get(config, "syntaxHighlight.activated", true)
+  const rootRef = useRef(null)
+
+  const [activeLanguage, setActiveLanguage] = useState(requestSnippetsSelectors.getSnippetGenerators()?.keySeq().first())
+  const [isExpanded, setIsExpanded] = useState(requestSnippetsSelectors?.getDefaultExpanded())
+  useEffect(() => {
+    const doIt = () => {
+
+    }
+    doIt()
+  }, [])
+  useEffect(() => {
+    const childNodes = Array
+      .from(rootRef.current.childNodes)
+      .filter(node => !!node.nodeType && node.classList?.contains("curl-command"))
+    // eslint-disable-next-line no-use-before-define
+    childNodes.forEach(node => node.addEventListener("mousewheel", handlePreventYScrollingBeyondElement, { passive: false }))
+
+    return () => {
+      // eslint-disable-next-line no-use-before-define
+      childNodes.forEach(node => node.removeEventListener("mousewheel", handlePreventYScrollingBeyondElement))
+    }
+  }, [request])
+
+  const snippetGenerators = requestSnippetsSelectors.getSnippetGenerators()
+  const activeGenerator = snippetGenerators.get(activeLanguage)
+  const snippet = activeGenerator.get("fn")(request)
+
+  const handleGenChange = (key) => {
+    const needsChange = activeLanguage !== key
+    if (needsChange) {
+      setActiveLanguage(key)
     }
   }
 
-  static propTypes = {
-    request: PropTypes.object.isRequired,
-    requestSnippetsSelectors: PropTypes.object.isRequired,
-    getConfigs: PropTypes.object.isRequired,
-    requestSnippetsActions: PropTypes.object.isRequired,
+  const handleSetIsExpanded = () => {
+    setIsExpanded(!isExpanded)
   }
-  render() {
-      const {request, getConfigs, requestSnippetsSelectors } = this.props
-      const snippetGenerators = requestSnippetsSelectors.getSnippetGenerators()
-      const activeLanguage = this.state.activeLanguage || snippetGenerators.keySeq().first()
-      const activeGenerator = snippetGenerators.get(activeLanguage)
-      const snippet = activeGenerator.get("fn")(request)
-      const onGenChange = (key) => {
-        const needsChange = activeLanguage !== key
-        if(needsChange) {
-          this.setState({
-            activeLanguage: key
-          })
-        }
-      }
-      const style = {
-        cursor: "pointer",
-        lineHeight: 1,
-        display: "inline-flex",
-        backgroundColor: "rgb(250, 250, 250)",
-        paddingBottom: "0",
-        paddingTop: "0",
-        border: "1px solid rgb(51, 51, 51)",
-        borderRadius: "4px 4px 0 0",
-        boxShadow: "none",
-        borderBottom: "none"
-      }
-      const activeStyle = {
-        cursor: "pointer",
-        lineHeight: 1,
-        display: "inline-flex",
-        backgroundColor: "rgb(51, 51, 51)",
-        boxShadow: "none",
-        border: "1px solid rgb(51, 51, 51)",
-        paddingBottom: "0",
-        paddingTop: "0",
-        borderRadius: "4px 4px 0 0",
-        marginTop: "-5px",
-        marginRight: "-5px",
-        marginLeft: "-5px",
-        zIndex: "9999",
-        borderBottom: "none"
-      }
-      const getBtnStyle = (key) => {
-        if (key === activeLanguage) {
-          return activeStyle
-        }
-        return style
-      }
-      const config = getConfigs()
-
-      const SnippetComponent = config?.syntaxHighlight?.activated
-        ? <SyntaxHighlighter
-          language={activeGenerator.get("syntax")}
-          className="curl microlight"
-          onWheel={function(e) {return this.preventYScrollingBeyondElement(e)}}
-          style={getStyle(get(config, "syntaxHighlight.theme"))}
+
+  const handleGetBtnStyle = (key) => {
+    if (key === activeLanguage) {
+      return activeStyle
+    }
+    return style
+  }
+
+  const handlePreventYScrollingBeyondElement = (e) => {
+    const { target, deltaY } = e
+    const { scrollHeight: contentHeight, offsetHeight: visibleHeight, scrollTop } = target
+    const scrollOffset = visibleHeight + scrollTop
+    const isElementScrollable = contentHeight > visibleHeight
+    const isScrollingPastTop = scrollTop === 0 && deltaY < 0
+    const isScrollingPastBottom = scrollOffset >= contentHeight && deltaY > 0
+
+    if (isElementScrollable && (isScrollingPastTop || isScrollingPastBottom)) {
+      e.preventDefault()
+    }
+  }
+
+  const SnippetComponent = canSyntaxHighlight
+    ? <SyntaxHighlighter
+      language={activeGenerator.get("syntax")}
+      className="curl microlight"
+      style={getStyle(get(config, "syntaxHighlight.theme"))}
+    >
+      {snippet}
+    </SyntaxHighlighter>
+    :
+    <textarea readOnly={true} className="curl" value={snippet}></textarea>
+
+  return (
+    <div className="request-snippets" ref={rootRef}>
+      Just a test
+      <div style={{ width: "100%", display: "flex", justifyContent: "flex-start", alignItems: "center", marginBottom: "15px" }}>
+        <h4
+          onClick={() => handleSetIsExpanded()}
+          style={{ cursor: "pointer" }}
+        >Snippets</h4>
+        <button
+          onClick={() => handleSetIsExpanded()}
+          style={{ border: "none", background: "none" }}
+          title={isExpanded ? "Collapse operation" : "Expand operation"}
         >
-          {snippet}
-        </SyntaxHighlighter>
-        :
-        <textarea readOnly={true} className="curl" value={snippet}></textarea>
-
-    const expanded = this.state.expanded === undefined ? this.props?.requestSnippetsSelectors?.getDefaultExpanded() : this.state.expanded
-    return (
-        <div>
-          <div style={{width: "100%", display: "flex", justifyContent: "flex-start", alignItems: "center", marginBottom: "15px"}}>
-            <h4
-              style={{ cursor: "pointer" }}
-              onClick={() => this.setState({expanded: !expanded})}
-            >Snippets</h4>
-            <button
-              onClick={() => this.setState({expanded: !expanded})}
-              style={{ border: "none", background: "none" }}
-              title={expanded ? "Collapse operation": "Expand operation"}
-            >
-              <svg className="arrow" width="10" height="10">
-                <use href={expanded ? "#large-arrow-down" : "#large-arrow"} xlinkHref={expanded ? "#large-arrow-down" : "#large-arrow"} />
-              </svg>
-            </button>
+          <svg className="arrow" width="10" height="10">
+            <use href={isExpanded ? "#large-arrow-down" : "#large-arrow"} xlinkHref={isExpanded ? "#large-arrow-down" : "#large-arrow"} />
+          </svg>
+        </button>
+      </div>
+      {
+        isExpanded && <div className="curl-command">
+          <div style={{ paddingLeft: "15px", paddingRight: "10px", width: "100%", display: "flex" }}>
+            {
+              snippetGenerators.entrySeq().map(([key, gen]) => {
+                return (<div style={handleGetBtnStyle(key)} className="btn" key={key} onClick={() => handleGenChange(key)}>
+                  <h4 style={key === activeLanguage ? { color: "white", } : {}}>{gen.get("title")}</h4>
+                </div>)
+              })
+            }
+          </div>
+          <div className="copy-to-clipboard">
+            <CopyToClipboard text={snippet}>
+              <button />
+            </CopyToClipboard>
+          </div>
+          <div>
+            {SnippetComponent}
           </div>
-          {
-            expanded && <div className="curl-command">
-              <div style={{paddingLeft: "15px", paddingRight: "10px", width: "100%", display: "flex"}}>
-                {
-                  snippetGenerators.entrySeq().map(([key, gen]) => {
-                    return (<div style={getBtnStyle(key)} className="btn" key={key} onClick={() => onGenChange(key)}>
-                      <h4 style={key === activeLanguage ? {color: "white",} : {}}>{gen.get("title")}</h4>
-                    </div>)
-                  })
-                }
-              </div>
-              <div className="copy-to-clipboard">
-                <CopyToClipboard text={snippet}>
-                  <button />
-                </CopyToClipboard>
-              </div>
-              <div>
-                {SnippetComponent}
-              </div>
-            </div>
-          }
         </div>
+      }
+    </div>
+  )  
+}
 
-      )
-  }
+RequestSnippets.propTypes = {
+  request: PropTypes.object.isRequired,
+  requestSnippetsSelectors: PropTypes.object.isRequired,
+  getConfigs: PropTypes.object.isRequired,
+  requestSnippetsActions: PropTypes.object,
 }
+
+export default RequestSnippets


Test patch:
diff --git a/test/mocha/components/live-response.jsx b/test/mocha/components/live-response.jsx
index b740ea94..bac8ff0f 100644
--- a/test/mocha/components/live-response.jsx
+++ b/test/mocha/components/live-response.jsx
@@ -6,7 +6,7 @@ import expect from "expect"
 import { shallow } from "enzyme"
 import LiveResponse from "components/live-response"
 import ResponseBody from "components/response-body"
-import { RequestSnippets } from "core/plugins/request-snippets/request-snippets"
+import RequestSnippets from "core/plugins/request-snippets/request-snippets"
 
 describe("<LiveResponse/>", function () {
   let request = fromJSOrdered({