Repository: remarkablemark/html-react-parser (ts)

Issue:
Only trim nodes if parent can't contain text nodes
When parsing html, in most cases we need to enable trim to prevent the `validateDOMNesting` errors

```ts
domToReact(html, {trim: true})
```

However this doesn't always work. There may be cases where a div contains a `&nbsp` character. During parsing, this gets trimmed out. 

```ts
const html = `
  <div>     Hello</div>
`;

function App() {
  return parse(html, { trim: true });
}
````
[CodeSandBox Demo](https://codesandbox.io/s/html-react-parser-205-forked-zvbmz)

Note, the whitespace before the `Hello` aren't spaces . They are `&nbsp`.

Would you be open to the idea of allowing users to provide a function for trim that would allow specifying a custom trimmer to handle these special spaces? It would look something like this

```ts
const customTrim = node => {
  const data = node.data;
  // some trimming logic
  return data;
}

return parse(html, { trim: customTrim });
```
I could even see it being extended to check if the node type allows spaces or not and trimming based on that although this can be done in the replacer function also as suggested in #205

```ts
const customTrim = node => {
  const {type, name, data} = node;
  if(type === 'tag' && ['div', 'p', ...etc].contains(name)) {
    return data
  }
  return data.trim()
}
```

I'm happy to create a PR if you're open to this idea

Pull request:
fix: trim only when necessary if trimming is off
<!--
Filling out the information below can facilitate the review/merge of the pull request (PR).
-->

Fixes #384

## What is the motivation for this pull request?
Fixes an issue #384 where trimming can lead to inaccurate parsing when nodes have whitespace characters

<!-- Is this a feature, bug fix, documentation, etc.? -->

## What is the current behavior?
 - if trim is off, whitespace characters trigger reacts validation errors
 - if trim is on, whitespaces are stripped from nodes that can contain white spaces which results in inaccurate parsing
<!-- Please link to the issue (if applicable). -->

## What is the new behavior?
Regardless of the trimming setting, if a text node appears in a node that can contain text nodes eg `<table>`, `<tr>`, it will be removed. If trimming is off, then whitespaces are preserved in nodes that can contain text nodes. 

<!-- If this is a feature change or bug fix. -->

## Checklist:

<!--
Feel free to remove any item that is irrelevant to your changes.
To check an item, place an "x" in the box like so: `- [x] Tests`
-->

- [x] [Conventional Commits](https://www.conventionalcommits.org/)
- [x] Tests
- [x] Documentation
- [x] Types

Looking for approvals before I add to documentation!

Patch:
diff --git a/README.md b/README.md
index 1df4b00..26c4e25 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,6 @@ parse('<p>Hello, World!</p>'); // React.createElement('p', {}, 'Hello, World!')
   - [Parser throws an error](#parser-throws-an-error)
   - [Is SSR supported?](#is-ssr-supported)
   - [Elements aren't nested correctly](#elements-arent-nested-correctly)
-  - [Warning: validateDOMNesting(...): Whitespace text nodes cannot appear as a child of table](#warning-validatedomnesting-whitespace-text-nodes-cannot-appear-as-a-child-of-table)
   - [Don't change case of tags](#dont-change-case-of-tags)
   - [TS Error: Property 'attribs' does not exist on type 'DOMNode'](#ts-error-property-attribs-does-not-exist-on-type-domnode)
   - [Can I enable `trim` for certain elements?](#can-i-enable-trim-for-certain-elements)
@@ -351,16 +350,16 @@ By default, whitespace is preserved:
 parse('<br>\n'); // [React.createElement('br'), '\n']
 ```
 
-To remove whitespace, enable the `trim` option:
+But certain elements like `<table>` will strip out invalid whitespace:
 
 ```js
-parse('<br>\n', { trim: true }); // React.createElement('br')
+parse('<table>\n</table>'); // React.createElement('table')
 ```
 
-This fixes the warning:
+To remove whitespace, enable the `trim` option:
 
-```
-Warning: validateDOMNesting(...): Whitespace text nodes cannot appear as a child of <table>. Make sure you don't have any extra whitespace between tags on each line of your source code.
+```js
+parse('<br>\n', { trim: true }); // React.createElement('br')
 ```
 
 However, intentional whitespace may be stripped out:
@@ -427,10 +426,6 @@ parse('<div /><div />'); // returns single element instead of array of elements
 
 See [#158](https://github.com/remarkablemark/html-react-parser/issues/158).
 
-### Warning: validateDOMNesting(...): Whitespace text nodes cannot appear as a child of table
-
-Enable the [trim](#trim) option. See [#155](https://github.com/remarkablemark/html-react-parser/issues/155).
-
 ### Don't change case of tags
 
 Tags are lowercased by default. To prevent that from happening, pass the [htmlparser2 option](#htmlparser2):
diff --git a/lib/dom-to-react.js b/lib/dom-to-react.js
index f08745c..1a05de6 100644
--- a/lib/dom-to-react.js
+++ b/lib/dom-to-react.js
@@ -3,6 +3,7 @@ var attributesToProps = require('./attributes-to-props');
 var utilities = require('./utilities');
 
 var setStyleProp = utilities.setStyleProp;
+var canTextBeChildOfNode = utilities.canTextBeChildOfNode;
 
 /**
  * Converts DOM nodes to JSX element(s).
@@ -23,11 +24,11 @@ function domToReact(nodes, options) {
 
   var result = [];
   var node;
+  var isWhitespace;
   var hasReplace = typeof options.replace === 'function';
   var replaceElement;
   var props;
   var children;
-  var data;
   var trim = options.trim;
 
   for (var i = 0, len = nodes.length; i < len; i++) {
@@ -51,15 +52,23 @@ function domToReact(nodes, options) {
     }
 
     if (node.type === 'text') {
-      // if trim option is enabled, skip whitespace text nodes
-      if (trim) {
-        data = node.data.trim();
-        if (data) {
-          result.push(node.data);
-        }
-      } else {
-        result.push(node.data);
+      isWhitespace = !node.data.trim().length;
+
+      if (isWhitespace && node.parent && !canTextBeChildOfNode(node.parent)) {
+        // We have a whitespace node that can't be nested in its parent
+        // so skip it
+        continue;
       }
+
+      if (trim && isWhitespace) {
+        // Trim is enabled and we have a whitespace node
+        // so skip it
+        continue;
+      }
+
+      // We have a text node that's not whitespace and it can be nested
+      // in its parent so add it to the results
+      result.push(node.data);
       continue;
     }
 
diff --git a/lib/utilities.js b/lib/utilities.js
index 16eaa6f..ce1345e 100644
--- a/lib/utilities.js
+++ b/lib/utilities.js
@@ -96,9 +96,35 @@ function setStyleProp(style, props) {
  */
 var PRESERVE_CUSTOM_ATTRIBUTES = React.version.split('.')[0] >= 16;
 
+// Taken from
+// https://github.com/facebook/react/blob/cae635054e17a6f107a39d328649137b83f25972/packages/react-dom/src/client/validateDOMNesting.js#L213
+var elementsWithNoTextChildren = new Set([
+  'tr',
+  'tbody',
+  'thead',
+  'tfoot',
+  'colgroup',
+  'table',
+  'head',
+  'html',
+  'frameset'
+]);
+
+/**
+ * Checks if the given node can contain text nodes
+ *
+ * @param {DomElement} node
+ * @returns {boolean}
+ */
+function canTextBeChildOfNode(node) {
+  return !elementsWithNoTextChildren.has(node.name);
+}
+
 module.exports = {
   PRESERVE_CUSTOM_ATTRIBUTES: PRESERVE_CUSTOM_ATTRIBUTES,
   invertObject: invertObject,
   isCustomComponent: isCustomComponent,
-  setStyleProp: setStyleProp
+  setStyleProp: setStyleProp,
+  canTextBeChildOfNode: canTextBeChildOfNode,
+  elementsWithNoTextChildren: elementsWithNoTextChildren
 };
diff --git a/package.json b/package.json
index 4cac283..de0beb3 100644
--- a/package.json
+++ b/package.json
@@ -46,16 +46,16 @@
     "@commitlint/cli": "15.0.0",
     "@commitlint/config-conventional": "15.0.0",
     "@rollup/plugin-commonjs": "21.0.1",
-    "@rollup/plugin-node-resolve": "13.1.1",
+    "@rollup/plugin-node-resolve": "13.0.6",
     "@size-limit/preset-big-lib": "7.0.4",
     "@types/react": "17.0.37",
-    "@typescript-eslint/parser": "5.7.0",
+    "@typescript-eslint/parser": "5.6.0",
     "benchmark": "2.1.4",
     "dtslint": "4.2.1",
     "eslint": "8.4.1",
     "eslint-plugin-prettier": "4.0.0",
     "husky": "7.0.4",
-    "jest": "27.4.5",
+    "jest": "27.4.4",
     "lint-staged": "12.1.2",
     "pinst": "2.1.6",
     "preact": "10.6.4",
@@ -66,7 +66,7 @@
     "rollup": "2.61.1",
     "rollup-plugin-terser": "7.0.2",
     "size-limit": "7.0.4",
-    "typescript": "4.5.4"
+    "typescript": "4.5.3"
   },
   "peerDependencies": {
     "react": "0.14 || 15 || 16 || 17"


Test patch:
diff --git a/test/index.test.js b/test/index.test.js
index 6118117..a0cb28a 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -138,13 +138,34 @@ describe('htmlparser2 option', () => {
 });
 
 describe('trim option', () => {
-  it('preserves whitespace text nodes when disabled (default)', () => {
+  it('preserves whitespace text nodes when disabled if valid in parent (default)', () => {
     const html = `<table>
   <tbody>
+    <tr><td>hello</td><td>\n</td><td>&nbsp;</td>\t</tr>\r
   </tbody>
 </table>`;
     const reactElement = parse(html);
-    expect(render(reactElement)).toBe(html);
+    expect(render(reactElement)).toBe(
+      '<table><tbody><tr><td>hello</td><td>\n</td><td>\u00a0</td></tr></tbody></table>'
+    );
+    expect(reactElement).toMatchInlineSnapshot(`
+      <table>
+        <tbody>
+          <tr>
+            <td>
+              hello
+            </td>
+            <td>
+              
+
+            </td>
+            <td>
+              \u00a0
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    `);
   });
 
   it('removes whitespace text nodes when enabled', () => {
diff --git a/test/utilities.test.js b/test/utilities.test.js
index b5d5741..eed9b46 100644
--- a/test/utilities.test.js
+++ b/test/utilities.test.js
@@ -3,7 +3,9 @@ const {
   PRESERVE_CUSTOM_ATTRIBUTES,
   invertObject,
   isCustomComponent,
-  setStyleProp
+  setStyleProp,
+  elementsWithNoTextChildren,
+  canTextBeChildOfNode
 } = require('../lib/utilities');
 
 describe('invertObject', () => {
@@ -125,3 +127,22 @@ describe('setStyleProp', () => {
     expect(props).toEqual({ style: {} });
   });
 });
+
+describe('canTextBeChildOfNode', () => {
+  it.each(Array.from(elementsWithNoTextChildren))(
+    'returns false since text node cannot be child of %s',
+    nodeName => {
+      const node = {
+        name: nodeName
+      };
+      expect(canTextBeChildOfNode(node)).toBe(false);
+    }
+  );
+
+  it('returns true if text can be child of <td/>', () => {
+    const node = {
+      name: 'td'
+    };
+    expect(canTextBeChildOfNode(node)).toBe(true);
+  });
+});