Repository: testing-library/svelte-testing-library
Pull request #328 (2024-02-20 17:39:59)

fix: $destroy and createRoot are no more

mcous: I added the `mount` / `unmount` changes to #325 before you made this PR, which will at least unblock the majority of testing. I'm starting to think that either:

- `rerender` won't be supported in Svelte 5
    - Instead, the complexity is pushed to the user, who will need to do something like
      ```js
      // comp.test.svelte.js
      const props = $state({ name: 'hello' })
      render(Comp, { props })
      // ...
      act(() => (props.name = 'goodbye'))
      ```
    - This is how `solid-testing-library` operates
- We'll need to introduce a build step into this library so we can use `$state` ourselves
    - I actually don't know if this will work

Either path feels like a bit of a bummer!
yanick: >     * `rerender` won't be supported in Svelte 5
>       
>       * Instead, the complexity is pushed to the user, who will need to do something like
>         ```js
>         // comp.test.svelte.js
>         const props = $state({ name: 'hello' })
>         render(Comp, { props })
>         // ...
>         act(() => (props.name = 'goodbye'))
>         ```
>       * This is how `solid-testing-library` operates

Yup, that's what I think as well. I'm trying to change the rerender test to use that ^^^ pattern, but for some reason it doesn't react to `props.name = 'goodbye'`. But I used a waitFor, not an act, so I'll try that.

> 
>     * We'll need to introduce a build step into this library so we can use `$state` ourselves

I *think* we just need to change the name of the test file to be `test.svelte.js` for the compiler to do the right thing. I'll say this with more conviction once the test is passing. :-)

Right now my battle plan is

1. find out the Way That Works with Svelte 5.
2. see how that can fit with the previous versions of svelte. Worst case we might have to split things and have `import {} from '@testing-library/svelte` for the latest supported version and `import {} from '@testing-library/svelte/svelteX` for `X` that are legacy versions and upcoming versions. 
3. Depending on (2), organize the tests to cover the maximum without going insane.

So yeah, weeeeee, supporting several major versions is fun, fun, fun. :zany_face: 


>       
>       * I actually don't know if this will work
> 
> 
> Either path feels like a bit of a bummer!


yanick: @mcous This is not the patch we deserve, but probably the patch we need. I'm using the `svelte/legacy` API of svelte 5 as I could not make the runes work in the testing context yet. I've also skipped 2 tests for svelte 5 and happy-dom because they are failing in a weird way. 

Patch:
diff --git a/README.md b/README.md
index d49bbae..0a39429 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,21 @@ This library has `peerDependencies` listings for `svelte >= 3`.
 You may also be interested in installing `@testing-library/jest-dom` so you can use
 [the custom jest matchers](https://github.com/testing-library/jest-dom).
 
+### Svelte 5 support
+
+If you are riding the bleeding edge of Svelte 5, you'll need to either
+import from `@testing-library/svelte/svelte5` instead of `@testing-library/svelte`, or have your `vite.config.js` contains the following alias:
+
+```
+export default defineConfig(({ }) => ({
+  test: {
+    alias: {
+      '@testing-library/svelte': '@testing-library/svelte/svelte5'
+    }
+  },
+}))
+```
+
 ## Docs
 
 See the [**docs**](https://testing-library.com/docs/svelte-testing-library/intro) over at the Testing Library website.
diff --git a/package.json b/package.json
index 938f563..4e8c6f1 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,10 @@
       "types": "./types/index.d.ts",
       "default": "./src/index.js"
     },
+    "./svelte5": {
+      "types": "./types/index.d.ts",
+      "default": "./src/svelte5-index.js"
+    },
     "./vitest": {
       "default": "./src/vitest.js"
     }
diff --git a/src/index.js b/src/index.js
index e94d814..4181419 100644
--- a/src/index.js
+++ b/src/index.js
@@ -13,3 +13,4 @@ if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) {
 }
 
 export * from './pure.js'
+export * from '@testing-library/dom'
diff --git a/src/pure.js b/src/pure.js
index a672b64..3c412da 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -3,60 +3,60 @@ import {
   getQueriesForElement,
   prettyDOM,
 } from '@testing-library/dom'
+import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
 import * as Svelte from 'svelte'
 
-const IS_SVELTE_5 = typeof Svelte.createRoot === 'function'
-const targetCache = new Set()
-const componentCache = new Set()
-
-const svelteComponentOptions = IS_SVELTE_5
-  ? ['target', 'props', 'events', 'context', 'intro', 'recover']
-  : ['accessors', 'anchor', 'props', 'hydrate', 'intro', 'context']
-
-const render = (
-  Component,
-  { target, ...options } = {},
-  { container, queries } = {}
-) => {
-  container = container || document.body
-  target = target || container.appendChild(document.createElement('div'))
-  targetCache.add(target)
-
-  const ComponentConstructor = Component.default || Component
-
-  const checkProps = (options) => {
-    const isProps = !Object.keys(options).some((option) =>
-      svelteComponentOptions.includes(option)
+const IS_SVELTE_5 = /^5\./.test(SVELTE_VERSION)
+export const targetCache = new Set()
+export const componentCache = new Set()
+
+const svelteComponentOptions = [
+  'accessors',
+  'anchor',
+  'props',
+  'hydrate',
+  'intro',
+  'context',
+]
+
+export const buildCheckProps = (svelteComponentOptions) => (options) => {
+  const isProps = !Object.keys(options).some((option) =>
+    svelteComponentOptions.includes(option)
+  )
+
+  // Check if any props and Svelte options were accidentally mixed.
+  if (!isProps) {
+    const unrecognizedOptions = Object.keys(options).filter(
+      (option) => !svelteComponentOptions.includes(option)
     )
 
-    // Check if any props and Svelte options were accidentally mixed.
-    if (!isProps) {
-      const unrecognizedOptions = Object.keys(options).filter(
-        (option) => !svelteComponentOptions.includes(option)
-      )
-
-      if (unrecognizedOptions.length > 0) {
-        throw Error(`
+    if (unrecognizedOptions.length > 0) {
+      throw Error(`
           Unknown options were found [${unrecognizedOptions}]. This might happen if you've mixed
           passing in props with Svelte options into the render function. Valid Svelte options
           are [${svelteComponentOptions}]. You can either change the prop names, or pass in your
           props for that component via the \`props\` option.\n\n
           Eg: const { /** Results **/ } = render(MyComponent, { props: { /** props here **/ } })\n\n
         `)
-      }
-
-      return options
     }
 
-    return { props: options }
+    return options
   }
 
-  const renderComponent = (options) => {
+  return { props: options }
+}
+
+const checkProps = buildCheckProps(svelteComponentOptions)
+
+const buildRenderComponent =
+  ({ target, ComponentConstructor }) =>
+  (options) => {
     options = { target, ...checkProps(options) }
 
-    const component = IS_SVELTE_5
-      ? Svelte.createRoot(ComponentConstructor, options)
-      : new ComponentConstructor(options)
+    if (IS_SVELTE_5)
+      throw new Error('for Svelte 5, use `@testing-library/svelte/svelte5`')
+
+    const component = new ComponentConstructor(options)
 
     componentCache.add(component)
 
@@ -71,30 +71,46 @@ const render = (
     return component
   }
 
-  let component = renderComponent(options)
-
-  return {
-    container,
-    component,
-    debug: (el = container) => console.log(prettyDOM(el)),
-    rerender: async (props) => {
-      if (props.props) {
-        console.warn(
-          'rerender({ props: {...} }) deprecated, use rerender({...}) instead'
-        )
-        props = props.props
-      }
-      component.$set(props)
-      await Svelte.tick()
-    },
-    unmount: () => {
-      cleanupComponent(component)
-    },
-    ...getQueriesForElement(container, queries),
+export const buildRender =
+  (buildRenderComponent) =>
+  (Component, { target, ...options } = {}, { container, queries } = {}) => {
+    container = container || document.body
+    target = target || container.appendChild(document.createElement('div'))
+    targetCache.add(target)
+
+    const ComponentConstructor = Component.default || Component
+
+    const renderComponent = buildRenderComponent({
+      target,
+      ComponentConstructor,
+    })
+
+    let component = renderComponent(options)
+
+    return {
+      container,
+      component,
+      debug: (el = container) => console.log(prettyDOM(el)),
+      rerender: async (props) => {
+        if (props.props) {
+          console.warn(
+            'rerender({ props: {...} }) deprecated, use rerender({...}) instead'
+          )
+          props = props.props
+        }
+        component.$set(props)
+        await Svelte.tick()
+      },
+      unmount: () => {
+        cleanupComponent(component)
+      },
+      ...getQueriesForElement(container, queries),
+    }
   }
-}
 
-const cleanupComponent = (component) => {
+export const render = buildRender(buildRenderComponent)
+
+export const cleanupComponent = (component) => {
   const inCache = componentCache.delete(component)
 
   if (inCache) {
@@ -110,19 +126,19 @@ const cleanupTarget = (target) => {
   }
 }
 
-const cleanup = () => {
+export const cleanup = () => {
   componentCache.forEach(cleanupComponent)
   targetCache.forEach(cleanupTarget)
 }
 
-const act = async (fn) => {
+export const act = async (fn) => {
   if (fn) {
     await fn()
   }
   return Svelte.tick()
 }
 
-const fireEvent = async (...args) => {
+export const fireEvent = async (...args) => {
   const event = dtlFireEvent(...args)
   await Svelte.tick()
   return event
@@ -135,9 +151,3 @@ Object.keys(dtlFireEvent).forEach((key) => {
     return event
   }
 })
-
-/* eslint-disable import/export */
-
-export * from '@testing-library/dom'
-
-export { render, cleanup, fireEvent, act }
diff --git a/src/svelte5-index.js b/src/svelte5-index.js
new file mode 100644
index 0000000..1770eac
--- /dev/null
+++ b/src/svelte5-index.js
@@ -0,0 +1,17 @@
+import { act, cleanup } from './svelte5.js'
+
+// If we're running in a test runner that supports afterEach
+// then we'll automatically run cleanup afterEach test
+// this ensures that tests run in isolation from each other
+// if you don't like this then either import the `pure` module
+// or set the STL_SKIP_AUTO_CLEANUP env variable to 'true'.
+if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) {
+  afterEach(async () => {
+    await act()
+    cleanup()
+  })
+}
+
+export * from './svelte5.js'
+export * from '@testing-library/dom'
+export { act, fireEvent } from './pure.js'
diff --git a/src/svelte5.js b/src/svelte5.js
new file mode 100644
index 0000000..6f30784
--- /dev/null
+++ b/src/svelte5.js
@@ -0,0 +1,41 @@
+import { createClassComponent } from 'svelte/legacy'
+import {
+  componentCache,
+  cleanup,
+  buildCheckProps,
+  buildRender,
+} from './pure.js'
+
+const svelteComponentOptions = [
+  'target',
+  'props',
+  'events',
+  'context',
+  'intro',
+  'recover',
+]
+
+const checkProps = buildCheckProps(svelteComponentOptions)
+
+const buildRenderComponent =
+  ({ target, ComponentConstructor }) =>
+  (options) => {
+    options = { target, ...checkProps(options) }
+
+    const component = createClassComponent({
+      component: ComponentConstructor,
+      ...options,
+    })
+
+    componentCache.add(component)
+
+    return component
+  }
+
+const render = buildRender(buildRenderComponent)
+
+/* eslint-disable import/export */
+
+import { act, fireEvent } from './pure.js'
+
+export { render, cleanup, fireEvent, act }
diff --git a/vite.config.js b/vite.config.js
index 0ad04cc..3a8aaf2 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,5 +1,19 @@
 import { svelte } from '@sveltejs/vite-plugin-svelte'
 import { defineConfig } from 'vite'
+import path from 'path'
+import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
+
+const IS_SVELTE_5 = SVELTE_VERSION >= '5'
+
+const alias = [
+  {
+    find: '@testing-library/svelte',
+    replacement: path.resolve(
+      __dirname,
+      IS_SVELTE_5 ? 'src/svelte5-index.js' : 'src/index.js'
+    ),
+  },
+]
 
 // https://vitejs.dev/config/
 export default defineConfig(({ mode }) => ({
@@ -12,6 +26,7 @@ export default defineConfig(({ mode }) => ({
     conditions: mode === 'test' ? ['browser'] : [],
   },
   test: {
+    alias,
     environment: 'jsdom',
     setupFiles: ['./src/__tests__/_vitest-setup.js'],
     mockReset: true,


Test patch:
diff --git a/src/__tests__/act.test.js b/src/__tests__/act.test.js
index 6eafc0d..57fa5b9 100644
--- a/src/__tests__/act.test.js
+++ b/src/__tests__/act.test.js
@@ -1,6 +1,6 @@
 import { beforeEach, describe, expect, test } from 'vitest'
 
-import { act, fireEvent, render as stlRender } from '..'
+import { act, fireEvent, render as stlRender } from '@testing-library/svelte'
 import Comp from './fixtures/Comp.svelte'
 
 describe('act', () => {
diff --git a/src/__tests__/auto-cleanup-skip.test.js b/src/__tests__/auto-cleanup-skip.test.js
index 265f55b..db65447 100644
--- a/src/__tests__/auto-cleanup-skip.test.js
+++ b/src/__tests__/auto-cleanup-skip.test.js
@@ -7,7 +7,7 @@ describe('auto-cleanup-skip', () => {
 
   beforeAll(async () => {
     process.env.STL_SKIP_AUTO_CLEANUP = 'true'
-    const stl = await import('..')
+    const stl = await import('@testing-library/svelte')
     render = stl.render
   })
 
diff --git a/src/__tests__/auto-cleanup.test.js b/src/__tests__/auto-cleanup.test.js
index 349ee39..4d38d35 100644
--- a/src/__tests__/auto-cleanup.test.js
+++ b/src/__tests__/auto-cleanup.test.js
@@ -1,6 +1,6 @@
 import { describe, expect, test } from 'vitest'
 
-import { render } from '..'
+import { render } from '@testing-library/svelte'
 import Comp from './fixtures/Comp.svelte'
 
 describe('auto-cleanup', () => {
diff --git a/src/__tests__/cleanup.test.js b/src/__tests__/cleanup.test.js
index 789338d..3e49548 100644
--- a/src/__tests__/cleanup.test.js
+++ b/src/__tests__/cleanup.test.js
@@ -1,6 +1,7 @@
 import { describe, expect, test, vi } from 'vitest'
+import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
 
-import { act, cleanup, render } from '..'
+import { act, cleanup, render } from '@testing-library/svelte'
 import Mounter from './fixtures/Mounter.svelte'
 
 const onExecuted = vi.fn()
@@ -15,7 +16,7 @@ describe('cleanup', () => {
     expect(document.body).toBeEmptyDOMElement()
   })
 
-  test('cleanup unmounts component', async () => {
+  test.runIf(SVELTE_VERSION < '5')('cleanup unmounts component', async () => {
     await act(renderSubject)
     cleanup()
 
diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js
index effdef4..e7f2fd5 100644
--- a/src/__tests__/context.test.js
+++ b/src/__tests__/context.test.js
@@ -1,9 +1,10 @@
 import { expect, test } from 'vitest'
 
-import { render } from '..'
+import { render } from '@testing-library/svelte'
 import Comp from './fixtures/Context.svelte'
+import { IS_HAPPYDOM, IS_SVELTE_5 } from './utils.js'
 
-test('can set a context', () => {
+test.skipIf(IS_SVELTE_5 && IS_HAPPYDOM)('can set a context', () => {
   const message = 'Got it'
 
   const { getByText } = render(Comp, {
diff --git a/src/__tests__/debug.test.js b/src/__tests__/debug.test.js
index 1072a0f..2a9c6e3 100644
--- a/src/__tests__/debug.test.js
+++ b/src/__tests__/debug.test.js
@@ -1,7 +1,7 @@
 import { prettyDOM } from '@testing-library/dom'
 import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
 
-import { render } from '..'
+import { render } from '@testing-library/svelte'
 import Comp from './fixtures/Comp.svelte'
 
 describe('debug', () => {
diff --git a/src/__tests__/events.test.js b/src/__tests__/events.test.js
index fccf990..16f688c 100644
--- a/src/__tests__/events.test.js
+++ b/src/__tests__/events.test.js
@@ -1,6 +1,6 @@
 import { describe, expect, test } from 'vitest'
 
-import { fireEvent, render } from '..'
+import { fireEvent, render } from '@testing-library/svelte'
 import Comp from './fixtures/Comp.svelte'
 
 describe('events', () => {
diff --git a/src/__tests__/mount.test.js b/src/__tests__/mount.test.js
index 898aa6a..df0792b 100644
--- a/src/__tests__/mount.test.js
+++ b/src/__tests__/mount.test.js
@@ -1,13 +1,14 @@
 import { describe, expect, test, vi } from 'vitest'
 
-import { act, render, screen } from '..'
+import { act, render, screen } from '@testing-library/svelte'
 import Mounter from './fixtures/Mounter.svelte'
+import { IS_HAPPYDOM, IS_SVELTE_5 } from './utils.js'
 
 const onMounted = vi.fn()
 const onDestroyed = vi.fn()
 const renderSubject = () => render(Mounter, { onMounted, onDestroyed })
 
-describe('mount and destroy', () => {
+describe.skipIf(IS_SVELTE_5 && IS_HAPPYDOM)('mount and destroy', () => {
   test('component is mounted', async () => {
     renderSubject()
 
diff --git a/src/__tests__/multi-base.test.js b/src/__tests__/multi-base.test.js
index 39f28d1..bad628e 100644
--- a/src/__tests__/multi-base.test.js
+++ b/src/__tests__/multi-base.test.js
@@ -1,6 +1,6 @@
 import { describe, expect, test } from 'vitest'
 
-import { render } from '..'
+import { render } from '@testing-library/svelte'
 import Comp from './fixtures/Comp.svelte'
 
 describe('multi-base', () => {
diff --git a/src/__tests__/render.test.js b/src/__tests__/render.test.js
index 262e062..cb30b77 100644
--- a/src/__tests__/render.test.js
+++ b/src/__tests__/render.test.js
@@ -1,7 +1,7 @@
 import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
 import { beforeEach, describe, expect, test } from 'vitest'
 
-import { act, render as stlRender } from '..'
+import { act, render as stlRender } from '@testing-library/svelte'
 import Comp from './fixtures/Comp.svelte'
 import CompDefault from './fixtures/Comp2.svelte'
 
@@ -107,7 +107,7 @@ describe('render', () => {
   })
 
   test('correctly find component constructor on the default property', () => {
-    const { getByText } = render(CompDefault, { props: { name: 'World' } })
+    const { getByText } = stlRender(CompDefault, { props: { name: 'World' } })
 
     expect(getByText('Hello World!')).toBeInTheDocument()
   })
diff --git a/src/__tests__/rerender.test.js b/src/__tests__/rerender.test.js
index 922ea63..6fabf36 100644
--- a/src/__tests__/rerender.test.js
+++ b/src/__tests__/rerender.test.js
@@ -1,10 +1,10 @@
 /**
  * @jest-environment jsdom
  */
-import { describe, expect, test, vi } from 'vitest'
-import { writable } from 'svelte/store'
+import { expect, test, vi } from 'vitest'
+
+import { render, waitFor } from '@testing-library/svelte'
 
-import { act, render, waitFor } from '..'
 import Comp from './fixtures/Rerender.svelte'
 
 test('mounts new component successfully', async () => {
diff --git a/src/__tests__/transition.test.js b/src/__tests__/transition.test.js
index e8191f5..7e7dd3d 100644
--- a/src/__tests__/transition.test.js
+++ b/src/__tests__/transition.test.js
@@ -1,16 +1,17 @@
 import { userEvent } from '@testing-library/user-event'
-import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
 import { beforeEach, describe, expect, test, vi } from 'vitest'
 
-import { render, screen, waitFor } from '..'
+import { IS_JSDOM, IS_SVELTE_5 } from './utils.js'
+
+import { render, screen, waitFor } from '@testing-library/svelte'
 import Transitioner from './fixtures/Transitioner.svelte'
 
-describe.runIf(SVELTE_VERSION < '5')('transitions', () => {
+describe.runIf(!IS_SVELTE_5)('transitions', () => {
   beforeEach(() => {
-    if (window.navigator.userAgent.includes('jsdom')) {
-      const raf = (fn) => setTimeout(() => fn(new Date()), 16)
-      vi.stubGlobal('requestAnimationFrame', raf)
-    }
+    if (!IS_JSDOM) return
+
+    const raf = (fn) => setTimeout(() => fn(new Date()), 16)
+    vi.stubGlobal('requestAnimationFrame', raf)
   })
 
   test('on:introend', async () => {
diff --git a/src/__tests__/utils.js b/src/__tests__/utils.js
new file mode 100644
index 0000000..69be184
--- /dev/null
+++ b/src/__tests__/utils.js
@@ -0,0 +1,7 @@
+import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
+
+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_SVELTE_5 = SVELTE_VERSION >= '5'
diff --git a/src/vitest.js b/src/vitest.js
index 135ddbe..2f9930c 100644
--- a/src/vitest.js
+++ b/src/vitest.js
@@ -1,6 +1,6 @@
 import { afterEach } from 'vitest'
 
-import { act, cleanup } from './pure.js'
+import { act, cleanup } from '@testing-library/svelte'
 
 afterEach(async () => {
   await act()