Shopify / hydrogen

Hydrogen lets you build faster headless storefronts in less time, on Shopify.
https://hydrogen.shop
MIT License
1.43k stars 273 forks source link

Error when using html-react-parser #2517

Closed bgonzalez-thestable closed 3 weeks ago

bgonzalez-thestable commented 1 month ago

What is the location of your example repository?

No response

Which package or tool is having this issue?

Hydrogen

What version of that package or tool are you using?

2024.7.4

What version of Remix are you using?

2.10.1

Steps to Reproduce

  1. Setup a fresh Hydrogen instance
  2. Install html-react-parser as a dependency
  3. Go to any component or route and import the package:
    
    import parse from 'html-react-parser';

//...

export function Foo() { const parsedHTML = parse('

bar

'); }



### Expected Behavior

Server should not try to execute DOM methods, the `html-react-parser` package [supports SSR](https://www.npmjs.com/package/html-react-parser#is-ssr-supported).

### Actual Behavior

The server crashes with the error: `Error: This browser does not support document.implementation.createHTMLDocument`.
bgonzalez-thestable commented 1 month ago

Related: https://github.com/kkomelin/isomorphic-dompurify/issues/214#issuecomment-1868232246

wizardlyhel commented 1 month ago

The issue it that the package itself expects to have a window or document to be available in the SSR side. Hydrogen local server uses Cloudflare workerd instance. This is to ensure that the local environment that you have is the same as the one that will be running on production. A cloudflare worker doesn't have access to a window or document.

Instead of trying to run the html parser in SSR, run it on client side. I tried this package locally.

import * as parser from 'html-react-parser';

const parse = parser.default;

export default function Homepage() {
  const data = useLoaderData<typeof loader>();
  const [parsedHtml, setParsedHtml] = useState<ReturnType<typeof parse>>();

  useEffect(() => {
    setParsedHtml(parse('<p>bar</p>'));
  });

  return (
    <div className="home">
      {parsedHtml && <div>{parsedHtml}</div>}
    </div>
  );
}

Make sure to add the parser package in the ssr.optimizeDeps in the vite.config.ts

 ssr: {
    optimizeDeps: {
      /**
       * Include dependencies here if they throw CJS<>ESM errors.
       * For example, for the following error:
       *
       * > ReferenceError: module is not defined
       * >   at /Users/.../node_modules/example-dep/index.js:1:1
       *
       * Include 'example-dep' in the array below.
       * @see https://vitejs.dev/config/dep-optimization-options
       */
      include: ['html-react-parser'],
    },
  },
bgonzalez-thestable commented 1 month ago

Thanks @wizardlyhel

Moving this to client-side would be problematic because in our case we receive markup or markdown from a CMS response. This would require making the entire page client-side, as individual pieces of the structured data received from the CMS may o may not have HTML.

Additionally, this works on Hydrogen Remix classic (no Vite), and it also works on Vite + Remix (no Hydrogen/Oxygen).

We're currently unable to migrate to Vite + Hydrogen because this is blocking the implementation.

wizardlyhel commented 1 month ago

Then I would consider using dompurify + jsdom.

I don't know what other limitations these 2 package can run into when executed on the server side. They could be:

The core of any html sanitizer is that they relies on there is a DOM object that they can parse strings into html nodes and run queries to manipulate the node elements. Instead of writing a DOM object, they just reuse the ones from browser. But often, package like jsdom provides way more than just a simple parse and query dom nodes since these packages are used for unit testing. So these dom packages are really large in size because they try to mimic everything, including downloading scripts, that a browser dom does.

bgonzalez-thestable commented 1 month ago

I tried using dompurify as well and it had a similar error, I will try it with jsdom.

That said, I still do not understand why this works as expected on all other SSR frameworks out there, this seems to be a problem specific to Oxygen/Hydrogen. As I mentioned, we currently have sites deployed to production running on Hydrogen + Remix classic and there's no errors.

The server should not be trying to parse these into HTML, I tried using this other plugin https://www.npmjs.com/package/isomorphic-dompurify to no avail. And as described in https://github.com/kkomelin/isomorphic-dompurify/issues/214#issuecomment-1868232246 it looks like Hydrogen is loading the wrong file?

wizardlyhel commented 1 month ago

It depends on the hosting environment you are on. Some hosting platform supply a global.windows object and some don't. For example, Vercel and Cloudflare workers (what Oxygen is using) doesn't have a global.windows object. The reason why windows often isn't defined is due to performance.

To be honest, the fact that I have to do this kinda import workaround when using html-react-parser is already strange.

import * as parser from 'html-react-parser';

const parse = parser.default;

This already tells me that the library export is set up oddly. Most likely the difference between es modules setup and cjs setup (what html-react-parser most likely is using).

Since you are saying that this package "should" work on SSR, the file is there and it's just using the wrong one. Why not look into the option of patching the package so that it returns the correct file? https://www.npmjs.com/package/patch-package

wizardlyhel commented 1 month ago

Another option is just copy the entire package content locally into your project and see if that works

bgonzalez-thestable commented 1 month ago

That's odd, I'm not sure why on your environment you have to import the package that way, for us to works as a normal import.

For example, a simplified hook that parses HTML:

import parse from 'html-react-parser';

export const useHTMLParser = (html) => {
  return typeof html === 'string' ? parse(html) : html;
};

The production environments we have are hosted in Oxygen/Cloudflare and the parser works as expected.

dcodrin commented 1 month ago

@bgonzalez-thestable

You have to ensure that the html-dom-parser dependency of html-react-parser resolves to the server-side version. Below are the required changes to your vite.config.ts file in order to make this work.

import {createRequire} from 'node:module';
const require = createRequire(import.meta.url);

// This needs to be resolved to the server-side version of html-dom-parser
// since running this on the edge will throw an error.
const htmlDomParserPath = require.resolve(
  'html-dom-parser/lib/server/html-to-dom',
);

export default defineConfig({
  resolve: {
    alias: [
      {
        find: 'html-dom-parser',
        replacement: htmlDomParserPath,
      },
    ],
  },
  ssr: {
    optimizeDeps: {
      include: ['html-react-parser'],
    },
  },
})
bgonzalez-thestable commented 3 weeks ago

@dcodrin wow, you're a savior! This is exactly what I was missing.

Thank you so much!