nuxt: @vitejs/plugin-legacy wrong work in nuxt

Environment


  • Operating System: Linux
  • Node Version: v16.17.1
  • Nuxt Version: 3.0.0-rc.13
  • Nitro Version: 0.6.1
  • Package Manager: pnpm@7.15.0
  • Builder: vite
  • User Config: runtimeConfig, css, vite
  • Build Modules: -

Reproduction

Nuxt

Describe the bug

Problem is polyfills load after entry file and System variable is undefined!

i install legacy plugin in pure vite project and don’t see this problem. i think nuxt problem!

Additional context

No response

Logs

Uncaught ReferenceError: System is not defined `entry-legacy.4e3349cb.js:1:12341`

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 7
  • Comments: 16 (4 by maintainers)

Most upvoted comments

FWIW, I published my recipe above as a Nuxt module:

https://www.npmjs.com/package/nuxt-vite-legacy

This collection of hacks is not something I’m exactly proud of, but it works. 😃

Just a heads up, I managed to deliver isomorphic build which runs both legacy and non-legacy code:

1. Put the polyfills chunk to 1st position and remove module attribute from legacy chunks

modules/vite-legacy.ts:

import { defineNuxtModule } from '@nuxt/kit'
import { pick } from 'lodash'

// Fix vite-legacy build, see https://github.com/nuxt/nuxt/issues/15464
export default defineNuxtModule({
  setup(_option, nuxt) {
    nuxt.hook('build:manifest', manifest => {
      if (!manifest['vite/legacy-polyfills-legacy']) {
        return
      }

      // Copy of manifest where polyfill is moved to 1st position.
      const manifest_copy: typeof manifest = {
        ...pick(manifest, 'vite/legacy-polyfills-legacy'),
        ...manifest,
      }
      // Clear manifest.
      for (const key of Object.keys(manifest)) {
        delete manifest[key]
      }
      // Fill manifest again from the copy.
      Object.assign(manifest, manifest_copy)

      // Remove module attribute from legacy chunks.
      for (const key of Object.keys(manifest)) {
        if (key.match(/-legacy(\.|$)/)) {
          manifest[key].module = false
        }
      }
    })
  },
})

2. Mark legacy chunks as nomodule and remove defer from them

server/plugins/vite-legacy.ts:

import { defineNitroPlugin } from 'nitropack/dist/runtime/plugin'

// Make vite-legacy build operational, see https://github.com/nuxt/nuxt/issues/15464
export default defineNitroPlugin(nitroApp => {
  nitroApp.hooks.hook('render:response', response => {
    // Mark legacy chunks as nomodule (prevents modern browsers from loading them)
    // At the same time, unmark them as defer (otherwise System.register() in the legacy entry doesn't actually execute the code)
    response.body = response.body.replace(
      /(<script src="[^"]+\-legacy\.[^>]+") defer/g,
      '$1 nomodule',
    )

    // Remove legacy chunks preload (fixes warnings in modern browsers)
    response.body = response.body.replace(
      /<link rel="preload" as="script" href="[^"]+\-legacy\..*?>/g,
      '',
    )

    // The other option would be NOT to remove defer from legacy chunks,
    // but start them from a nomodule HTML script:
    //
    // response.body += `<script nomodule>document.querySelector("script[src*='/entry-legacy.']").onload=function(){System.import(this.src)}</script>`
    //
    // This is similar to what vite-legacy-plugin does in vanilla vite.
  })
})

This leaves incompatibility window for legacy browsers that do support modules but don’t support modern features such as async generators (based on caniuse that would be e.g. Chrome 61-62). Vanilla vite-legacy-plugin injects special detection scripts into SSR HTML: https://github.com/vitejs/vite/blob/535795a8286e4a9525acd2340e1d1d1adfd70acf/packages/plugin-legacy/src/snippets.ts

Ideally, Nuxt should adopt that approach fully.

The temporary solution for this problem is to transfer the entry-legacy file to the end of the manifest file. You can solve the problem using a Nuxt hook in a module file:

// modules/fixViteLegacyPlugin.ts
import {defineNuxtModule} from '@nuxt/kit';

export default defineNuxtModule({
  setup(_option, nuxt) {
    nuxt.hook('build:manifest', manifest => {
      const keys = Object.keys(manifest);

      // detect vite plugin added
      if (!keys.some(key => key.includes('polyfills-legacy'))) {
        return;
      }

      const entryKey = keys.find(key => key.includes('entry-legacy')) as string;
      const entryValue = manifest[entryKey];

      // remove entry
      delete manifest[entryKey];

      // add entry end of manifest
      manifest[entryKey] = entryValue;

      // fix legacy module attributes
      for (const item in manifest) {
        manifest[item].module = item.includes('polyfills-legacy')
          ? false
          : !item.includes('-legacy.js');
      }
    });
  }
});

The problem is that the polyfill bundle is loaded after the entry bundle, cause polyfills should always come first. I was inspired by Ilya’s comment to create a solution like this:

// nuxt.config.ts
  hooks: {
    'build:manifest': (manifest) => {
      // kinda hacky, vite polyfills are incorrectly being loaded last so we have to move them to appear first in the object.
      // we can't replace `manifest` entirely, cause then we're only mutating a local variable, not the actual manifest
      // which is why we have to mutate the reference.
      // since ES2015 object string property order is more or less guaranteed - the order is chronological
      const polyfillKey = 'vite/legacy-polyfills'
      const polyfillEntry = manifest[polyfillKey]
      if (!polyfillEntry) return

      const oldManifest = { ...manifest }
      delete oldManifest[polyfillKey]

      for (const key in manifest) {
        delete manifest[key]
      }

      manifest[polyfillKey] = polyfillEntry
      for (const key in oldManifest) {
        manifest[key] = oldManifest[key]
      }
    }
  }

That’s the only change I needed to make, no need to adjust any build artifacts or anything.

Note: The polyfill bundle key might be different depending on the settings of the legacy plugin. I’m only using it to generate polyfills for modern bundles (renderLegacyChunks: false & modernPolyfills: true)

This is something that I feel should be documented somewhere. I have prior experience with Vite and polyfills from another project and expected to be able to add the legacy plugin to the the vite.plugins property in the nuxt.config.ts file without issue. I went so far as writing the documentation for using this property in Nuxt before finding this issue.

Quite shocked that we need to update the response on every request using the render:response, feels icky IMHO.

Honestly this is something I’d like to see first class support however I understand there’s very little sense in re-inventing the wheel.

We’ve discussed as a team and I think we shouldn’t adopt within Nuxt itself as the incompatibility window is small and decreasing with time.

However, a module implementation would very much be welcome. Feel free to ping me if there’s anything you need or Nuxt does not provide as part of doing this.

Just in case if anyone lands here trying to make Nuxt work in Chrome 49 (Windows XP), I managed to achieve that by removing defer from the script tags:

diff --git a/dist/runtime.mjs b/dist/runtime.mjs
index 29154e86eed636881eabc2c7fd01e9883e9e0404..736cbd0cd5dab57e71fd5dea427afc99717c4909 100644
--- a/dist/runtime.mjs
+++ b/dist/runtime.mjs
@@ -162,8 +162,7 @@ function renderScripts(ssrContext, rendererContext) {
   return Object.values(scripts).map((resource) => renderScriptToString({
     type: resource.module ? "module" : null,
     src: rendererContext.buildAssetsURL(resource.file),
-    defer: resource.module ? null : "",
-    crossorigin: ""
+    crossorigin: resource.module ? "" : null
   })).join("");
 }
 function createRenderer(createApp, renderOptions) {

and then to fix this: [nuxt] error caught during app initialization Error: Context conflict the workaround is to remove modern chunks completely:

// modules/vite-legacy-patch.ts
import { defineNuxtModule } from '@nuxt/kit'
import { pick } from 'lodash'

export default defineNuxtModule({
  setup(_option, nuxt) {
    nuxt.hook('build:manifest', manifest => {
      // copy of manifest where polyfill is moved to 1st position
      const manifest_copy: typeof manifest = {
        ...pick(manifest, 'vite/legacy-polyfills-legacy'),
        ...manifest,
      }
      // clear manifest
      for (const key of Object.keys(manifest)) {
        delete manifest[key]
      }
      // fill manifest again from the copy
      Object.assign(manifest, manifest_copy)

      // remove module attributes from legacy chunks
      for (const key of Object.keys(manifest)) {
        if (key.match(/-legacy(\.|$)/)) {
          manifest[key].module = false
        } else if (manifest[key].module) {
          // remove modern chunks completely, otherwise it conflicts in modern Chrome:
          // [nuxt] error caught during app initialization Error: Context conflict
          //
          // that could be related to defer being removed from legacy chunks
          // see: patches/vue-bundle-renderer@1.0.3.patch
          // but with that patch Chrome 49 wouldn't run any scripts at all
          delete manifest[key]
        }
      }
    })
  },
})

and then some polyfills:

export default defineNuxtConfig({
  vite: {
    plugins: [
      // for Windows XP
      // see also modules/vite-legacy-patch.ts
      legacy({
        targets: ['chrome 49'],
        additionalLegacyPolyfills: [
          'intersection-observer',
          'mdn-polyfills/Element.prototype.getAttributeNames',
        ],
      }),
    ],
  }
})

This makes it work in Windows XP.

This is all (without a doubt) very dirty and unpleasant, so if there are better workarounds please weigh in!

UPDATE: see better working solution below.

You just need to add a script tag to your app that loads the polyfill with the desired options. We have something like this in our default layout:

  setup() {
    const script = []
    const { polyfill } = useConfig()
    if (polyfill) {
      const version = polyfill.version || 1
      const features = polyfill.features?.join(',') || ''
      const flags = polyfill.flags?.join(',') || ''
      script.push({
        src: `/polyfill?v=${version}&features=${features}&flags=${flags}`
      })
    }
    …
    useHead({ script })
  }

And the config (retrieved by useConfig(), our own composable for site config):

    …
    polyfill: {
      version: 2, // Only used for cache busting.
      features: [
        'default',
        'globalThis',
        'es2015',
        'es2016',
        'es2017',
        'es2018',
        'es2019',
        'es2020',
        'es2021',
        'es2022',
        'es2023'
      ],
      flags: ['gated']
    }
    …

As a workaround, we ended up configuring config.vite.build.target for legacy JS support, and are using a self-hosed https://polyfill.io/ for the polyfills. We’re self-hosting it because the online version is stuck in v3 for now, see https://github.com/Financial-Times/polyfill-service/issues/2734.

To make configuring config.vite.build.target easier, we’re using browserslist along with esbuild-plugin-browserslist:

import browserslist from 'browserslist'
import { resolveToEsbuildTarget } from 'esbuild-plugin-browserslist'
import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
  …
  vite: {
    build: {
      target: getBuildTarget([
        '>0.1% and supports es6-module and not ios < 12 and not opera > 0',
        'node >= 18.13.0'
      ])
    }
  }
})

function getBuildTarget(browsers) {
  return resolveToEsbuildTarget(browserslist(browsers), {
    printUnknownTargets: false
  })
}