Skip to content

Conversation

GeorgeTaveras1231
Copy link

@GeorgeTaveras1231 GeorgeTaveras1231 commented Jan 3, 2025

This allows reading the hooks more reliably.

Using a WeakMap in a closure causes issues if multiple versions of html-webpack-plugin are installed because it requires that plugins that tap into these hooks use the version of this package that is resolved by the webpack config. This may be a common issue in monorepos. Using a global symbol allows any version of html-webpack-plugin to resolve the hooks used in the compilation, allows plugins in monorepos to work more seamlessly.

Current workaround

I work in a monorepo where I've implemented a couple of plugins that tap in the html-webpack-plugin hooks. To bypass the issues I'm encountering, I defer the loading of html-webpack-plugin to the execution of the plugin, and use the context of the compilation to require to appropriate instance of html-webpack-plugin

- import HtmlWebpackPlugin from 'html-webpack-plugin';
+ import { createRequire } from 'node:module';
+ import path from 'node:path';

class MyHTMLPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('MyHTMLPlugin', (compilation) => {
+    const require = createRequire(path.resolve(compiler.options.context, 'webpack.config.js');
+    const HtmlWebpackPlugin = require('html-webpack-plugin');
      const hooks = HtmlWebpackPlugin.getCompilationHooks(compilation);
      hooks.beforeEmit.tap('MyHTMLPlugin', () => { /* Omitted */ });
    });
  }
}

This allows reading the hooks more reliably. 

Using a WeakMap in a closure causes issues if multiple versions of html-webpack-plugin are installed because it requires that plugins that tap into these hooks use the version of this package that is resolved by the webpack config. This may be a common issue in monorepos. Using a global symbol allows any version of `html-webpack-plugin` to resolve the hooks used in the compilation, allows plugins in monorepos to work more seamlessly.
@jantimon
Copy link
Owner

jantimon commented Jan 4, 2025

I don't think that the html-webpack-plugin should alter the webpack compilation object

What about passing the html-webpack-plugin to the custom plugin?

class MyHTMLPlugin {
  constructor(plugin) {
    this.plugin = plugin
  }
}

@GeorgeTaveras1231

This comment was marked as outdated.

@GeorgeTaveras1231
Copy link
Author

@jantimon Any updates on this?

@GeorgeTaveras1231
Copy link
Author

@jantimon I was affected by this issue again so thought I'd revisit it.

I'll reiterate an old proposal and list another alternative, which can serve as a point of reference for others but to consider tradeoffs of not addressing this.

A solution I mentioned before is to store the WeakMap globally. I've done this in a few similar cases and works pretty well with a minor risk (I'll elaborate if needed but prob not worth getting in the weeds).

const hooksSymbol = Symbol.for('html-webpack-plugin/hooks');
globalThis[hooksSymbol] ??= new WeakMap();

const hooksMap = globalThis[hooksSymbol];

Another workaround I've applied in my plugins is to accept a getHtmlWebpackPluginHooks function,

const plugin = new MyCustomPlugin({
  getHtmlWebpackPluginHooks: (compilation) => {
    return LocalWebpackPlugin.getHooks(compilation)
  }
});

This works fine but feels like an unnecessary burden to place on plugin developers.

Let me know if you have any thoughts. If you oppose any change to this please just close the PR 🙏🏽

@alexander-akait
Copy link
Collaborator

Can you run npm ls html-webpack-plugin?

@GeorgeTaveras1231
Copy link
Author

GeorgeTaveras1231 commented Jul 9, 2025

@alexander-akait I dont have access to this environment at the moment. But the issue is relatively straightforward. Using a traditional node_modules folder structure in a monorepo, where the root workspace has a version of 'html-webpack-plugin' that is not compatible with two child workspaces. This leads to multiple instances of the library loading in memory if the two child workspaces both import it.

Something like

V2
|_ V1
|_ V1

Hope this helps if you are seeking to understand the problem

@alexander-akait
Copy link
Collaborator

@GeorgeTaveras1231 it means this should be resolved by using only one version, unfortunately there are some limitation with old plugins, especial v1, why you can't use the only one version?

@GeorgeTaveras1231
Copy link
Author

GeorgeTaveras1231 commented Jul 15, 2025

@alexander-akait in theory I agree with you.

You could force the usage of a single version by marking it as a peerDependencies in the package.json file of each workspace.

However, in practice, any package listed in peerDependencies also has to be installed as a devDependencies for testing and other things. And unfortunately, JS runtimes and bundlers that use the node_modules folder don't care wether s package is a dev only or production dependency when resolving import requests.

You could place it in the root workspace as a devDependencies but this is also an anti-pattern - if for example, your tests are in a child workspace. Its an anti-pattern for readability reasons, but also conflicts with features like Yarn PnP.

You could also create a whole new workspace just for testing - but I imagine for some projects will co-locate tests inside the workspace being tested - so this approach could be undesired in those cases.

I think this ultimately leaves us with limited options for solving this

  1. Solving this in the user space - force the use of a single version via dependency injection (analogous to my solution of accepting a function to get the hooks in your plugin)
  2. (Solving it in this package) Making all instances of the package access the same hooks object (like my solution to use a global Symbol and/or global WeakMap)
  3. Ignoring the problem - I imagine this affects a small subset of users. But the ones affected will be limited to either the dependency injection solution above (which burdens devs with identifying this bug and the additional API surface for consumers). OR doing something non-trivial to make sure the node_modules folder structure to always resembles a tree that prevents this issue (like I mentioned before - placing tests in their own workspace). OR doing something non-trivial to change how the html-webpack-plugin package is resolved by their target JS runtime or bundler before it is used.

After working in a monorepo for a couple years, I feel pretty confident in my assessment of this problem but let me know if you see any gaps in my thought process. Ultimately, I'm not blocked by this because I worked around the issue with the 'dependency injection' approach, and am just interested in giving back. So I'll accept whatever you feel is appropriate 😌

@GeorgeTaveras1231
Copy link
Author

I accidentally left out of the solutions available in the user space the original work around I added to the PR description which is to resolve html-webpack-plugin using the compilation context. This is maybe aligned with my suggestion to "do something non-trivial to make sure html-webpack-plugin is resolved correctly". Either way, calling attention to it again for the sake of thoroughness.

@jantimon
Copy link
Owner

jantimon commented Jul 15, 2025

I can understand the pain and get that this really happens in large codebases.

However I believe there are better approaches than using global state and would prefer to keep the current isolation for stability.

Rather than working around multiple versions I would enforcing a single version.. That's possible for all mono repository managers.

With pnpm you can use:

{
  "pnpm": {
    "overrides": {
      "html-webpack-plugin": "^5.5.0"
    }
  }
}

Yarn has resolutions and npm has overrides which work similarly. Wouldn't that also solve your problem?

The main issue with your proposed global approach is that some plugins legitimately use html-webpack-plugin internally and apply their own instances:

const HtmlWebpackPlugin = require("html-webpack-plugin");

class CustomTemplatePlugin {
  apply(compiler) {
    // This plugin creates its own HtmlWebpackPlugin instance
    const htmlPlugin = new HtmlWebpackPlugin({
      template: 'custom-template.html',
      filename: 'special-output.html'
    });
    
    // Applies it to the compiler
    compiler.apply(htmlPlugin);
    
    // Later wants to hook into its own instance
    compiler.hooks.compilation.tap('CustomTemplatePlugin', (compilation) => {
      const hooks = HtmlWebpackPlugin.getCompilationHooks(compilation);
      hooks.beforeEmit.tap('CustomTemplatePlugin', (data) => {
        // Expects to work with its own instance's hooks
      });
    });
  }
}

With your proposed global approach, this plugin might overwriting foreign hooks from a different html-webpack-plugin in the global state, causing runtime errors, incompatible hook signatures and data format mismatches between versions or other unexpected behavior when multiple html-webpack-plugin instances exist..

The current WeakMap approach ensures that each plugin gets exactly the hooks from the version it's designed to work with

If package manager overrides are not possible for you I believe using the html-webpack-plugin from root like you showed is also fine:

const require = createRequire(path.resolve(compiler.options.context, 'webpack.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');

I know that's not what you were looking for but I hope it helps a little bit to understand the intention of non global hooks

@GeorgeTaveras1231
Copy link
Author

@jantimon your comment totally makes sense and I understand the risk with globally storing the hooks.

I'll just say I think I spotted a contradiction in your recommendation. If the recommendation is to use a single version/instance of html-webpack-plugin - wouldn't the recommendation for plugin develops that use it internally (as you showed) also be to use a single a single version/instance of it? In which case then treating the hook store as a global singleton aligns with that guideline - if the guideline is "only a single instance of html-webpack-plugin should run at a time"

I do see the risk of storing it globally following my recommendation leading to weird issues especially if the hook API changes significantly between major versions and a user loads more than one version in a single runtime. I think this can be managed by changing the global symbol every time there is a significant change to the hook API (required feature or breaking change) - yes this adds some maintenance overhead since you have to to keep some loose versioning scheme outside of the package.json but would allow multiple instances to communicate while mitigating some of the risk.

As far as the recommendation to use resolutions and overrides. You are right to suggest that will fix the issue but these workarounds should be reserved for special cases. And are easier to manage in smaller repos. In general, this is a pattern that limits the growth of monorepos because it requires all sub projects to be in sync. This limits the ability to do partial migration between major releases (for example, migrate a workspace at a time, merge/integrate, then iterate).

@GeorgeTaveras1231
Copy link
Author

GeorgeTaveras1231 commented Jul 15, 2025

I think I may have misinterpreted a bit of your message and am now not sure if you meant to suggest that using a single instance is a general rule.

I think this is ok - but I suppose I'm still hoping something can be done. At least if possible, add guidelines for plugin developers that align with proper plugin implementations that do not lead to this issue. I think this applies to all plugin developers (wether they use html-webpack-plugin internally or externally).

In order to ensure that different plugins work well with each other and to avoid this error, they should either.

  1. Set html-webpack-plugin as a peerDependency - allowing the host to manage a single instance of it.
  2. Or, allow passing (in some way) some method to get/access the hooks.

Without this, there is the risk that a minor version mismatch or accidentally incompatible version range request leads to two or more plugins unable to properly tap into the html hooks and build. I have a vague memory of running into this issue with some open source plugins that were unable to be used together for this reason - though I don't have the example readily available 😅.

Would these guidelines seem reasonable to you? And does changing this request to a documentation request resonate with you?

@jantimon
Copy link
Owner

Your global approach has a serious flaw: If multiple versions of html-webpack-plugin are loaded and we store hooks globally, the last loaded version will overwrite the global hook definition with its version, even if that version isn't the active one.

So what might happen is that version A creates hooks, version B loads later and overwrites them, but version A is used in the webpack config - therefore hooks called by A might have the wrong API or might not exist in that version and break the build

From my feeling the real issue is that you're requiring the wrong module. You probably have something like:

packages/packageX/
├── node_modules/html-webpack-plugin (local version)
├── your-plugin.js (requires local version)
packages/packageY/
├── node_modules/html-webpack-plugin (different version)
└── webpack.config.js (uses different version)

Your plugin gets the local module, but webpack is using a different instance from the config. This is a monorepo module resolution problem and less an issue of the html-webpack-plugin

And if it is a monorepo issue I would try to fix the monorepo.
There are diffeent options:

First you could use syncpack to keep versions consistent across workspaces or pnpm catalogs to define versions as reusable constants. And as previously said you can use Package manager overrides.

If you really want to fix it in code you can also try this approach that finds the actual plugin instance webpack is using but also loses the version guarantee:

function findHtmlWebpackPlugin(compiler) {
  for (const plugin of compiler.options.plugins || []) {
    if (plugin.constructor.name === 'HtmlWebpackPlugin' || 
        plugin.__proto__.constructor.name === 'HtmlWebpackPlugin') {
      return plugin.constructor;
    }
  }
  return null;
}

class MyHTMLPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('MyHTMLPlugin', (compilation) => {
      const HtmlWebpackPlugin = findHtmlWebpackPlugin(compiler);
      const hooks = HtmlWebpackPlugin?.getCompilationHooks(compilation);
      hooks?.beforeEmit.tap('MyHTMLPlugin', () => { /* Omitted */ });
    });
  }
}

Note that this will find only the first instance - so if you are using multiple html-webpack-plugin versions you might want to adjust findHtmlWebpackPlugin to return a Set instead of a single instance

@GeorgeTaveras1231
Copy link
Author

@jantimon I hope you get a chance to review my other comment where I propose documenting guidelines.

Your comment is completely correct and am aware of the potential risks of the wrong version loading first - I still think it can be mitigated/managed with minimal to no side-effects but I wont insist on this.

But I will insist that - this isn't strictly a monorepo problem. This can happen in any scenario where developers install 2 or more plugins, if those plugins intend to tap into the hooks of the root webpack compilation using the html-webpack-plugin instance installed in the host project.

This is why I recommended documenting guidelines for plugin/library developers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants