Repository: testing-library/svelte-testing-library (js)

Issue:
Svelte 5 rendering in Nx
Hey Team,

I have been spending some time already in trying to get svelte-testing-library to work in my monorepo.
However I am failing to do so. I will try to outline the details as best as I can below.

### The error message.

When I run my test `nx test ui-component` (which is just a nx command for running the vitest command). I get the following error.

```
 FAIL  src/lib/Divider.spec.ts > should render correctly
Svelte error: rune_outside_svelte
The `$state` rune is only available inside `.svelte` and `.svelte.js/ts` files
```

When I remove this $state part from the render function inside modern.svelte.js (line 26( I get the following error:

```
 FAIL  src/lib/Divider.spec.ts > should render correctly
Svelte error: lifecycle_function_unavailable
`mount(...)` is not available on the server
```

To me this sounds like something is wrong with getting svelte to load correctly. I am in the myst about what I should do, and how to fix it. I have tried looking thoroughly at my vite configs, tsconfigs, etc. But I cannot copy the tutorials one on one as we're using Nx.

Below I will share some code / config

The test (nothing wrong with this, it works in other setups):

```
import { render, screen } from '@testing-library/svelte';
import { test } from 'vitest';

import '@testing-library/jest-dom/vitest';

import { Divider } from '../';

test('should render correctly', () => {
    render(Divider);
    expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
```

The component itself (cannot be wrong I guess, works in Storybook)

```
<hr class="my-0 border-b border-t border-b-white/10 border-t-black/30" />
```

My vite.config.ts

```
/// <reference types='vitest' />
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { svelteTesting } from '@testing-library/svelte/vite';
import * as path from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import { viteStaticCopy } from 'vite-plugin-static-copy';

export default defineConfig({
  root: __dirname,
  cacheDir: '../../node_modules/.vite/ui/divider',

  plugins: [
    svelte(),
    svelteTesting(),
    nxViteTsPaths({ buildLibsFromSource: false }),
    dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') }),
    viteStaticCopy({
      targets: [
        {
          src: path.join(__dirname, '**/*.md'),
          dest: '.',
        },
        {
          src: path.join(__dirname, '**/index.ts'),
          dest: '.',
        },
        {
          src: path.join(__dirname, '**/*.svelte'),
          dest: './lib',
        },
      ],
    }),
  ],

  // Uncomment this if you are using workers.
  // worker: {
  //  plugins: [ nxViteTsPaths() ],
  // },

  // Configuration for building your library.
  // See: https://vitejs.dev/guide/build.html#library-mode
  build: {
    outDir: '../../dist/ui/divider',
    emptyOutDir: true,
    reportCompressedSize: true,
    lib: {
      // Could also be a dictionary or array of multiple entry points.
      entry: 'src/index.ts',
      name: 'ui-divider',
      fileName: 'index',
      // Change this to the formats you want to support.
      // Don't forget to update your package.json as well.
      formats: ['es'],
    },
    rollupOptions: {
      // External packages that should not be bundled into your library.
      external: ['svelte'],
    },
  },

  test: {
    watch: false,
    globals: true,
    environment: 'jsdom',
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],

    reporters: ['default'],
    coverage: {
      reportsDirectory: '../../coverage/ui/divider',
      provider: 'v8',
    },
  },
});
```

My tsconfig:

```
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
 "module": "esnext",
    "moduleResolution": "bundler",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "target": "es2017",
    "verbatimModuleSyntax": true,
    "sourceMap": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "files": [],
  "include": [],
  "references": [
    {
      "path": "./tsconfig.lib.json"
    },
    {
      "path": "./tsconfig.spec.json"
    }
  ]
}
````

My packages:

```
   "svelte": "5.0.0-next.246",
   "@sveltejs/vite-plugin-svelte": "^4.0.0-next.7",
    "vitest": "^1.3.1",
    "@testing-library/svelte": "^5.2.3",
    "@testing-library/jest-dom": "^6.5.0"
```

Do you have any idea why this error is popping up? And how I can fix it.

Pull request description:
fix(vite): set `ssr.noExternal` even if not in project package.json
If `@testing-library/svelte` is not set in the `package.json` of the project Vitest is running in (e.g. in certain monorepo setups), `@sveltejs/vite-plugin-svelte` will not detect that it needs to be inlined. This, in turn, breaks Svelte 5 support, which relies on STL running through the Svelte compiler for runes support.

Hopefully this is something that can be fixed in `@sveltejs/vite-plugin-svelte`, but it might not be! This PR updates our own Vite plugin to ensure this setting is correct, even if `@sveltejs/vite-plugin-svelte` misses it.

Fixes #403

Patch:
diff --git a/src/vite.js b/src/vite.js
index 0062b89..1ad712f 100644
--- a/src/vite.js
+++ b/src/vite.js
@@ -7,12 +7,13 @@ import { fileURLToPath } from 'node:url'
  * Ensures Svelte is imported correctly in tests
  * and that the DOM is cleaned up after each test.
  *
- * @param {{resolveBrowser?: boolean, autoCleanup?: boolean}} options
+ * @param {{resolveBrowser?: boolean, autoCleanup?: boolean, noExternal?: boolean}} options
  * @returns {import('vite').Plugin}
  */
 export const svelteTesting = ({
   resolveBrowser = true,
   autoCleanup = true,
+  noExternal = true,
 } = {}) => ({
   name: 'vite-plugin-svelte-testing-library',
   config: (config) => {
@@ -27,6 +28,10 @@ export const svelteTesting = ({
     if (autoCleanup) {
       addAutoCleanup(config)
     }
+
+    if (noExternal) {
+      addNoExternal(config)
+    }
   },
 })
 
@@ -64,6 +69,10 @@ const addAutoCleanup = (config) => {
   const test = config.test ?? {}
   let setupFiles = test.setupFiles ?? []
 
+  if (test.globals) {
+    return
+  }
+
   if (typeof setupFiles === 'string') {
     setupFiles = [setupFiles]
   }
@@ -73,3 +82,40 @@ const addAutoCleanup = (config) => {
   test.setupFiles = setupFiles
   config.test = test
 }
+
+/**
+ * Add `@testing-library/svelte` to Vite's noExternal rules, if not present.
+ *
+ * This ensures `@testing-library/svelte` is processed by `@sveltejs/vite-plugin-svelte`
+ * in certain monorepo setups.
+ */
+const addNoExternal = (config) => {
+  const ssr = config.ssr ?? {}
+  let noExternal = ssr.noExternal ?? []
+
+  if (noExternal === true) {
+    return
+  }
+
+  if (typeof noExternal === 'string' || noExternal instanceof RegExp) {
+    noExternal = [noExternal]
+  }
+
+  if (!Array.isArray(noExternal)) {
+    return
+  }
+
+  for (const rule of noExternal) {
+    if (typeof rule === 'string' && rule === '@testing-library/svelte') {
+      return
+    }
+
+    if (rule instanceof RegExp && rule.test('@testing-library/svelte')) {
+      return
+    }
+  }
+
+  noExternal.push('@testing-library/svelte')
+  ssr.noExternal = noExternal
+  config.ssr = ssr
+}
diff --git a/vite.config.js b/vite.config.js
index 76baf61..1ddeea5 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -11,6 +11,7 @@ export default defineConfig({
     setupFiles: ['./src/__tests__/_vitest-setup.js'],
     mockReset: true,
     unstubGlobals: true,
+    unstubEnvs: true,
     coverage: {
       provider: 'v8',
       include: ['src/**/*'],


Test patch:
diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js
index 68be33c..f637d6f 100644
--- a/src/__tests__/utils.js
+++ b/src/__tests__/utils.js
@@ -4,6 +4,8 @@ export const IS_JSDOM = window.navigator.userAgent.includes('jsdom')
 
 export const IS_HAPPYDOM = !IS_JSDOM // right now it's happy or js
 
+export const IS_JEST = Boolean(process.env.JEST_WORKER_ID)
+
 export const IS_SVELTE_5 = SVELTE_VERSION >= '5'
 
 export const MODE_LEGACY = 'legacy'
diff --git a/src/__tests__/vite-plugin.test.js b/src/__tests__/vite-plugin.test.js
new file mode 100644
index 0000000..9232f65
--- /dev/null
+++ b/src/__tests__/vite-plugin.test.js
@@ -0,0 +1,227 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { svelteTesting } from '../vite.js'
+import { IS_JEST } from './utils.js'
+
+describe.skipIf(IS_JEST)('vite plugin', () => {
+  beforeEach(() => {
+    vi.stubEnv('VITEST', '1')
+  })
+
+  test('does not modify config if disabled', () => {
+    const subject = svelteTesting({
+      resolveBrowser: false,
+      autoCleanup: false,
+      noExternal: false,
+    })
+
+    const result = {}
+    subject.config(result)
+
+    expect(result).toEqual({})
+  })
+
+  test('does not modify config if not Vitest', () => {
+    vi.stubEnv('VITEST', '')
+
+    const subject = svelteTesting()
+
+    const result = {}
+    subject.config(result)
+
+    expect(result).toEqual({})
+  })
+
+  test.each([
+    {
+      config: () => ({ resolve: { conditions: ['node'] } }),
+      expectedConditions: ['browser', 'node'],
+    },
+    {
+      config: () => ({ resolve: { conditions: ['svelte', 'node'] } }),
+      expectedConditions: ['svelte', 'browser', 'node'],
+    },
+  ])(
+    'adds browser condition if necessary',
+    ({ config, expectedConditions }) => {
+      const subject = svelteTesting({
+        resolveBrowser: true,
+        autoCleanup: false,
+        noExternal: false,
+      })
+
+      const result = config()
+      subject.config(result)
+
+      expect(result).toEqual({
+        resolve: {
+          conditions: expectedConditions,
+        },
+      })
+    }
+  )
+
+  test.each([
+    {
+      config: () => ({}),
+      expectedConditions: [],
+    },
+    {
+      config: () => ({ resolve: { conditions: [] } }),
+      expectedConditions: [],
+    },
+    {
+      config: () => ({ resolve: { conditions: ['svelte'] } }),
+      expectedConditions: ['svelte'],
+    },
+  ])(
+    'skips browser condition if possible',
+    ({ config, expectedConditions }) => {
+      const subject = svelteTesting({
+        resolveBrowser: true,
+        autoCleanup: false,
+        noExternal: false,
+      })
+
+      const result = config()
+      subject.config(result)
+
+      expect(result).toEqual({
+        resolve: {
+          conditions: expectedConditions,
+        },
+      })
+    }
+  )
+
+  test.each([
+    {
+      config: () => ({}),
+      expectedSetupFiles: [expect.stringMatching(/src\/vitest.js$/u)],
+    },
+    {
+      config: () => ({ test: { setupFiles: [] } }),
+      expectedSetupFiles: [expect.stringMatching(/src\/vitest.js$/u)],
+    },
+    {
+      config: () => ({ test: { setupFiles: 'other-file.js' } }),
+      expectedSetupFiles: [
+        'other-file.js',
+        expect.stringMatching(/src\/vitest.js$/u),
+      ],
+    },
+  ])('adds cleanup', ({ config, expectedSetupFiles }) => {
+    const subject = svelteTesting({
+      resolveBrowser: false,
+      autoCleanup: true,
+      noExternal: false,
+    })
+
+    const result = config()
+    subject.config(result)
+
+    expect(result).toEqual({
+      test: {
+        setupFiles: expectedSetupFiles,
+      },
+    })
+  })
+
+  test('skips cleanup in global mode', () => {
+    const subject = svelteTesting({
+      resolveBrowser: false,
+      autoCleanup: true,
+      noExternal: false,
+    })
+
+    const result = { test: { globals: true } }
+    subject.config(result)
+
+    expect(result).toEqual({
+      test: {
+        globals: true,
+      },
+    })
+  })
+
+  test.each([
+    {
+      config: () => ({ ssr: { noExternal: [] } }),
+      expectedNoExternal: ['@testing-library/svelte'],
+    },
+    {
+      config: () => ({}),
+      expectedNoExternal: ['@testing-library/svelte'],
+    },
+    {
+      config: () => ({ ssr: { noExternal: 'other-file.js' } }),
+      expectedNoExternal: ['other-file.js', '@testing-library/svelte'],
+    },
+    {
+      config: () => ({ ssr: { noExternal: /other/u } }),
+      expectedNoExternal: [/other/u, '@testing-library/svelte'],
+    },
+  ])('adds noExternal rule', ({ config, expectedNoExternal }) => {
+    const subject = svelteTesting({
+      resolveBrowser: false,
+      autoCleanup: false,
+      noExternal: true,
+    })
+
+    const result = config()
+    subject.config(result)
+
+    expect(result).toEqual({
+      ssr: {
+        noExternal: expectedNoExternal,
+      },
+    })
+  })
+
+  test.each([
+    {
+      config: () => ({ ssr: { noExternal: true } }),
+      expectedNoExternal: true,
+    },
+    {
+      config: () => ({ ssr: { noExternal: '@testing-library/svelte' } }),
+      expectedNoExternal: '@testing-library/svelte',
+    },
+    {
+      config: () => ({ ssr: { noExternal: /svelte/u } }),
+      expectedNoExternal: /svelte/u,
+    },
+  ])('skips noExternal if able', ({ config, expectedNoExternal }) => {
+    const subject = svelteTesting({
+      resolveBrowser: false,
+      autoCleanup: false,
+      noExternal: true,
+    })
+
+    const result = config()
+    subject.config(result)
+
+    expect(result).toEqual({
+      ssr: {
+        noExternal: expectedNoExternal,
+      },
+    })
+  })
+
+  test('bails on noExternal if input is unexpected', () => {
+    const subject = svelteTesting({
+      resolveBrowser: false,
+      autoCleanup: false,
+      noExternal: true,
+    })
+
+    const result = { ssr: { noExternal: false } }
+    subject.config(result)
+
+    expect(result).toEqual({
+      ssr: {
+        noExternal: false,
+      },
+    })
+  })
+})