Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix (dev-server-rollup): support child plugins in resolution algorithm #2050

Merged
merged 5 commits into from
Feb 16, 2023

Conversation

43081j
Copy link
Contributor

@43081j 43081j commented Oct 7, 2022

It is possible a rollup plug injects its own child plugins by hooking into the rollup options hook and mutating the options to have extra plugs.

This change introduces a check for such plugins and calls them before the host plugin in case they successfully resolve a path first.

Similarly, the resolve method of plugins receives an options object to hold state for the resolution plugins. We currently lose this object when calling through to resolveImport, which can confuse third-party plugins/resolvers heavily.

To fix this, resolveImport now also accepts a resolverOptions so we can pass it on when calling child plugins.

cc @lucaelin as discussed in #1700

this was an incredibly deep rabbit hole of spaghetti code (outside modernweb), thanks a lot to lucaelin for figuring a bunch of it out too.

its possible we still need to fix rollup upstream as it has some weird import/export mismatch lucaelin is aware of (and already opened an issue for?). weirdly my local copy passes fine now but its possible in the week ive had this up on bricks, i just fixed it in node_modules and forgot about it (to get it working).

WIP for now

as im pretty sure certain there's still another infinite loop floating around...

@changeset-bot
Copy link

changeset-bot bot commented Oct 7, 2022

⚠️ No Changeset found

Latest commit: fd71f22

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@lucaelin
Copy link
Contributor

lucaelin commented Oct 7, 2022

Thank you so much for working on this @43081j !
Afaict the implementation should fix those two bugs in the case of commonjs, but it approaches the fix differently from my patch.
My approach is to honor the skipSelf and putting those plugins on a kind of blacklist to prevent repeating calls to resolveImport. Your solution seems to work by only fixing the additional options parameter that should be passed from resolve back into resolveImport and resolveId respectively. Commonjs has special handling for the absence of this parameter, and if passed correctly, bails out when detecting being part of a node-resolve-initiated resolution attempt here. I think that is good for wds to pass the parameter correctly, I am just not sure how many plugins in the rollup-ecosystem rely on the skipSelf without implementing their own mitigation like commonjs seems to do. I think that proper handling of skipSelf should be tackled, probably in a separate PR, though.

On to the third bug you mentioned that is part of the commonjs module itself, filed here. That bug is only relevant for plugins that have code like this in them, and I am still unsure as to why this only fails in wds, while rollup seems to bundle this code just fine. Further investigation is required and should also be tackled in a separate PR, if it turns out to be an issue on the wds side.

@43081j
Copy link
Contributor Author

43081j commented Oct 7, 2022

yep we can't quite do what was in your patch, though. it causes a test to fail (correctly).

thats what the remaining infinite loop is: node-resolve in some cases calls commonjs and vice versa, indefinitely.

we can't really store a trace of plugins on our side because the plugins have nothing in common (they don't share a context or anything for an individual resolution). i'm not sure yet how to solve that...

basically the one piece of missing code is the skipSelf logic, this isn't an alternative, it just isn't implemented yet (inf loop still exists).

@43081j
Copy link
Contributor Author

43081j commented Oct 7, 2022

to expand on that:

by overriding rollup's resolution, we have to handle skipSelf ourselves. if we don't (like in master and in this PR), we get infinite loops sometimes.

however, it isn't as simple as storing some set of plugins we've already visited.

  • it can't be global because the cache should be used during one resolution run, not across an entire request
  • it can't be tied to the koa context (the request) because multiple resolution runs can happen in one request
  • it can't be tied to the plugin context because every plugin receives a new one, every time resolveImport is called
  • it can't be tied to the rollup context, because one exists for the lifetime of the server

somewhere, we do need to track the plugins we've already visited and skip them in that case. im just unsure where and what to tie it to (its a cache really, we need to bind that to something unique to each initial resolve call).

i've tried so many combinations of caches, keying strategies, etc and they always fix the inf loop but cause the tests to fail. this is because they all over-cache, as far as i can see (for the reasons above).

somehow we need to create a cache on the first resolveImport call, and use that through the stack from there on.

@43081j
Copy link
Contributor Author

43081j commented Oct 20, 2022

@daKmoR FYI this seems to reproduce the problem:

  it('can transform modules which resolve commonjs dependencies', async () => {
    const rootDir = path.resolve(__dirname, '..', 'fixtures', 'basic');
    const { server, host } = await createTestServer({
      plugins: [
        {
          name: 'test',
          serve(context) {
            if (context.path === '/foo.js') {
              return 'import {expect} from "chai"; export {expect};';
            }
          },
        },
        commonjs(),
        nodeResolvePlugin(rootDir, false, {}),
      ],
    });

    try {
      const text = await fetchText(`${host}/foo.js`);
      console.log(text);
    } finally {
      server.stop();
    }
  });

in the commonjs.test.ts tests

it causes an inf loop, probably in master too

@43081j
Copy link
Contributor Author

43081j commented Oct 20, 2022

ok i pushed a change now which tries to respect the skipSelf flag properly.

the reason it can't live inside resolve() is because the path has already been normalised by commonjs by that point. For example, foo.js?commonjs-require or whatever it is will become foo.js. so we end up caching variations of foo.js in the same key, leading to some craziness.

instead, i've cached in resolveImport where we have the original path with the commonjs markers and what not.

this seems to work though im not entirely sure if keying by Context is the right thing to do. may be that we can simplify it more, e.g. cache only within the plugin (this) but i suspect that may not work since a plugin can call other plugins.

don't think anyone exists to review this, maybe @lucaelin if you remember the stuff you investigated you might follow it. or someone can maybe just try it out? i've tried it with chai which was a prime example of the inf loop problem, and it seems to work.

@lucaelin
Copy link
Contributor

lucaelin commented Oct 20, 2022

Correctly passing resolveOptions from resolve to resolveImport allows for the skipSelf logic to move into resolveImport entirely, great idea!
But I wonder if this is actually correct right now, because if one plugin sets the skipSelf flag, all plugins have resolveImport called with that flag. Maybe resolve should forward only resolveOptions.custom to plugins other than the one making the call to resolve?

Lets look at it in individual steps:

  1. Plugin A resolveImport is called.
  2. Plugin A calls resolve with skipSelf
  3. Loop over all other plugins: Plugin B resolveImport is called with skipSelf (which is not right, is it?)
  4. Plugin B calls resolve with skipSelf
  5. Loop over all other plugins: Plugin A resolveImport is called with skipSelf (again, but first time with skipSelf)
  6. Plugin A calls resolve with skipSelf (again)
  7. Loop over all other plugins: Plugin B resolveImport is called with skipSelf, but it was already called with skipSelf so it returns

I think a better flow would be this:

  1. Plugin A resolveImport is called.
  2. Plugin A calls resolve with skipSelf
  3. Plugin A calls its own resolveImport with skipSelf, which adds it to the cache but nothing more
  4. Loop over all other plugins: Plugin B resolveImport is called (omitting skipSelf, only passing resolveOptions.custom)
  5. Plugin B calls resolve with skipSelf
  6. Plugin B calls its own resolveImport with skipSelf, which adds it to the cache but nothing more
  7. Loop over all other plugins: Plugin A resolveImport is called, but it was already called with skipSelf so it returns

The changes required would be:

  1. calling resolveImport with skipSelf is a noop apart form adding to the cache
  2. resolve calls the calling plugins resolveImport with skipSelf
  3. resolve only passes resolveOptions.custom to other plugins

The issue I have with that though, is that passing skipSelf to resolveImport then makes it behave entirely different from normal operation. So IMO a second function should be introduced that only adds a plugin+file to the resolverCache and is called by resolve on the calling plugin. resolveImport would look at that same resolverCache and only skip if the plugin+file combination is already in the cache.

What do you think? I might be completely wrong about this :D

@43081j
Copy link
Contributor Author

43081j commented Oct 20, 2022

im not entirely sure. every plugin i've seen within rollup expects to pass the options all the way through, so i expected the same here.

e.g.

https://github.com/rollup/plugins/blob/ed46b8d111e3c07af39f4c89dd55ca9f982aa634/packages/commonjs/src/resolve-id.js#L114-L118

here the commonjs plugin is telling the rollup context to resolve (via any plugins or logic, not only commonjs) with skipSelf: true. so it may be ok that we're doing the same

@lucaelin
Copy link
Contributor

I took a deep dive into rollups code and found this: https://github.com/rollup/rollup/blob/3cb7f1376f4bbd519d320491629111e1d26cfc80/src/utils/resolveIdViaPlugins.ts#L41
This resolveIdViaPlugins is eventually called by a plugin calling resolve and as you can see it does not forward the skipSelf flag to pluginDriver.hookFirst in the second parameters argument, only assertions, custom, and isEntry. However in L34 and L43 you can see that it uses a combination of importer, plugin, and source to append to a list of skipped plugins. https://github.com/rollup/rollup/blob/3cb7f1376f4bbd519d320491629111e1d26cfc80/src/utils/PluginDriver.ts#L138 uses this list to skip those plugins from the resolve loop.

With this information I am certain that rollup does not pass the skipSelf along to other plugins.

@43081j
Copy link
Contributor Author

43081j commented Oct 20, 2022

yup i've been there a few times during debugging.

its wrapping the resolver function in one which uses the accumulated skip set to decide if to skipSelf or not.

i was aware of it but assumed that the wrapped resolver being passed to the hook meant skipSelf was set later (in the wrapped resolver), making the fact that it wasn't passed in initially meaningless.

though i guess the bit i overlooked was that the hook will call one resolveId without skipSelf, and all the inner ones (which are a result of calling the wrapped resolver) will have it set.

we can't easily do this pattern though because the context is created per plugin and/or per resolution in our case. nothing easy to wrap. i have tried this route already a few times but got as stuck as this solution.

suppose its back to the drawing board anyway, a tomorrow problem.

@43081j
Copy link
Contributor Author

43081j commented Jan 18, 2023

@daKmoR @thepassle any chance one of you could take a look at this? we think we've solved it (finally). or maybe you could point the right person at it?

let me know if you need any help understanding it 👀

43081j and others added 5 commits January 18, 2023 18:46
It is possible a rollup plug injects its own child plugins by hooking
into the rollup `options` hook and mutating the options to have extra
plugs.

This change introduces a check for such plugins and calls them before
the host plugin in case they successfully resolve a path first.

Similarly, the `resolve` method of plugins receives an options object to
hold state for the resolution plugins. We currently lose this object
when calling through to `resolveImport`, which can confuse third-party
plugins/resolvers heavily.

To fix this, `resolveImport` now also accepts a `resolverOptions` so we
can pass it on when calling child plugins.
Copy link
Member

@Westbrook Westbrook left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there another instance of a plugin with a child plugin that we could include in the tests in some way that would allow for us to confirm that these changes don't just fix the resolve plugin? Seems like a pretty concise fix, and it would be great if it was broadly applicable beyond the node resolve plugin, while also testing for breakage in other usage so we can hopefully be ahead of issues here in the future.

Once we get that sorted one way or another, we could target this PR to the next branch and get a canary release on merge to confirm again that this works as advertised when leveraging a clean node_modules. I'm pretty sure we've got a couple of projects on the Adobe side that can help test that as well.

If everything goes well up to this point, :shipit:. Doable?

@43081j
Copy link
Contributor Author

43081j commented Feb 14, 2023

@Westbrook i found it difficult to fabricate one in tests which behaves the same way as commonjs/node-resolve, but thats probably what we should do.

i have tried this on our largest project - it solves the problems in that case at least, although it raised other issues with using WTR which i need to list at some point

i've unfortunately run into a lot of flakiness with cjs support in wtr. even the fact the docs delegate to "just wrap the rollup plugin" without any real focus on how it should work, examples, etc. point to it being incorrectly treated as a non-first-class supported case

@Westbrook
Copy link
Member

I'm really just looking at how to safely get the changes you've proposed here into the library. The semantics of "does WTR support CJS or not" can be argued in a different context.

For a "second plugin with child plugins"... I'm not deeply invested in this area, is commonjs/node-resolve unique enough to require a custom plugin to confirm, or can we just ensure that other plugins with child plugins are included by this expansion and call it a go for now? I'd like one or the other, and am happy with you choosing here.

@43081j
Copy link
Contributor Author

43081j commented Feb 14, 2023

its a pretty difficult thing to test @Westbrook

you can see the test we added actually relies on the fact that this repo itself has chai, too.

the plugin isn't so much the difficult part here. having a dependency which is structured in a particular way, is.

i think to do this test properly/better, we'd need some fixture directory which itself has a package.json and some known CJS module we're aware caused problems before this fix. thats basically what the new test does now, but the current one uses this repo (not a separate fixture package).

im fairly short on time right now too, juggling a big list of tasks/PRs/etc. so if we want to rework the tests, ill need some help.

@thepassle
Copy link
Member

At this point id argue we should just make a next release so people can start trying it out in the wild, what do you think @Westbrook ?

@Westbrook Westbrook changed the base branch from master to next February 15, 2023 12:52
@Westbrook
Copy link
Member

@43081j is this ready to do that from your stand point? If so, pipit into ready for review and we can get that process moving.

@43081j
Copy link
Contributor Author

43081j commented Feb 15, 2023

yup i think so. the tests could be better as i mentioned above but im pretty confident the fix is right.

@43081j 43081j marked this pull request as ready for review February 15, 2023 14:05
@Westbrook Westbrook merged commit d1547f0 into modernweb-dev:next Feb 16, 2023
@43081j 43081j deleted the resolver-fix branch February 16, 2023 08:54
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.

4 participants