Repository: testing-library/svelte-testing-library
Pull request #321 (2024-02-12 01:38:36)

feat: add Svelte v5-next support
## Overview

This PR is an alternative approach to #284 (CC @yanick) that adds experimental support for `svelte@next` without breaking support for Svelte v3 nor v4. I think adding support for the prerelease version of Svelte v5  as soon as possible will be a really helpful move for everyone trying to migrate - myself very much included.

This PR requires the cleanup work I did in #314. Please see https://github.com/mcous/svelte-testing-library/pull/1 in my fork for the incremental diff from #314 to here. This PR is ready for review, but #314 should merge first

## Issues encountered

- `onMount` and `onDestroy` are attached and called asynchronously
    - If you synchronously create and then destroy a component, neither `onMount` nor `onDestroy` will be called
    - Tests required a sprinkling of `act()`s to get passing again
- `anchor` option has been removed
    - Doesn't directly affect this library, we just have a test that exercises it
- `Element.animate` is required to run transitions
    - Doesn't directly affect this library
    - _Does_ affect any users running transitions in tests, including our own test suite
    - Not sure what the right move is here; probably a documentation update
- `happy-dom` is pretty broken, _probably_ a Svelte issue
    - See #319 
    - See sveltejs/svelte#10358

## Work after this PR

- If we want to guarantee that `onMount` and `onDestroy` are called, we'll need to make `render` async so it can call `tick()` after render, which will be a breaking change
- Accordingly, I think we'll still want to issue a major bump that drops Svelte 3 and Node 16, maybe when Svelte 5 proper is released
- We can and should keep Svelte 4 support, in my opinion


RobinKnipe: Is there any consumer emulation as part of the CI flow?
I've setup a svelte5 project (with `npx create svelte@latest`) and chosen `vitest` support, then changed to this PR's commit:
```
 + @testing-library/svelte@git+ssh://github.com/mcous/svelte-testing-library#150335aac28f071710ca5771d4dc2a4f7363633c

 1 package installed [1.56s]
```
...but running a simple component test still fails to render :disappointed: I had hoped for more success seeing all the jsdom checks passing here, but I'm still seeing the same error as the latest release:
```
Instantiating a component with `new` is no longer valid in Svelte 5.
```
yanick: It'll soon be in a `next` release. I've just cut the very first testing of `next` this morning with some other changes, and I'll try to add in the v5 support in that branch later on. Hold tight, I'll try to report on it before the end of the day.

mcous: @RobinKnipe that smells like a dependency install issue, you might want to check if you have duplicate versions installed with `npm why @testing-library/svelte` (if I'm remembering that command properly).

If you're curious what our CI does, check out release.yml in the workflows folder; it's fairly straightforward.

Like @yanick said, you'll probably have a happier time if you wait for the prerelease version in npm. I've never messed around with installing this library via git so I don't really know what pitfalls and limitations exist, especially coming if it's coming from a fork rather than upstream
RobinKnipe: If it helps; some partial success in [this test project](https://github.com/RobinKnipe/svelte5). Noddy unit tests created for the 2 components from the default "create svelte" setup (one working, one failing).
mcous: @RobinKnipe your example repo has three problems, all interacting/hiding each other, that are causing that `<Header>` failure. All three issues are about how to test SvelteKit apps, not Svelte v5. The exact same failure happens if you switch your repo to Svelte 4. 

- Vite is configured with the [vanilla Svelte plugin rather than SvelteKit](https://github.com/RobinKnipe/svelte5/blob/e8da47c98b8edbda61cdfc24c595b2247dd82c0f/vite.config.ts#L1-L2), which hides...
- Vite is not configured to [resolve the browser bundle](https://testing-library.com/docs/svelte-testing-library/setup#vitest) of Svelte, which hides...
- The SvelteKit `$page` store will be empty in unit tests, so your test crashes on:
    ```
    TypeError: Cannot read properties of undefined (reading 'pathname')
     ❯ Object.hydrate [as h] src/routes/Header.svelte:19:32
         17|   </svg>
         18|   <ul>
         19|    <li aria-current={$page.url.pathname === '/' ? 'page' : undefined}>
           |                                ^
         20|     <a href="/">Home</a>
         21|    </li>
    ```

Since these are not issues with this library nor Svelte v5, if you have any additional questions, please open a separate discussion if you need help testing a SvelteKit app
yanick: Basing on 'next', and I'm going to push the conflict resolutions in 2 minutes.

yanick: @mcous You good with me merging this bad boy into next and seeing if it triggers our first next-type release to npm?

mcous: @yanick oh boy 😬, no time like the present

The only thing giving me pause is that our `next` branch is not configured as a `prerelease` in `release.yml`. So I _think_ merging may bump to `5.0.0` proper and release it on the `next` tag.

Not sure what your desires are on `5.0.0` vs `5.0.0-next.0` (or whatever). Either way I'm happy with what you choose! I'm fairly confident with this PR given the test matrix and the low risk to harming Svelte v4 support
yanick: > The only thing giving me pause is that our `next` branch is not configured as a `prerelease` in `release.yml`. So I _think_ merging may bump to `5.0.0` proper and release it on the `next` tag.

Oh yeah, you're right. `next` and `main` are regular releases, and `alpha` and `beta` branches are prereleases.
 
> Not sure what your desires are on `5.0.0` vs `5.0.0-next.0` (or whatever). Either way I'm happy with what you choose! I'm fairly confident with this PR given the test matrix and the low risk to harming Svelte v4 support

Hmmm, it's true that with the test suite, the chances that we'll utterly bugger up everything are slim (and yes, I just jinxed us by saying that). But today is a beautiful day to di-- I mean, deploy, so let's Leeroy Jenkins this puppy out and see what happens. >.>


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c6e83fa..ab433cc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,11 +16,23 @@ jobs:
     if: ${{ !contains(github.head_ref, 'all-contributors') }}
     name: Node ${{ matrix.node }}, Svelte ${{ matrix.svelte }}, ${{ matrix.test-runner }}
     runs-on: ubuntu-latest
+    continue-on-error: ${{ matrix.experimental }}
     strategy:
+      fail-fast: false
       matrix:
         node: ['16', '18', '20']
         svelte: ['3', '4']
         test-runner: ['vitest:jsdom', 'vitest:happy-dom']
+        experimental: [false]
+        include:
+          - node: '20'
+            svelte: 'next'
+            test-runner: 'vitest:jsdom'
+            experimental: true
+          - node: '20'
+            svelte: 'next'
+            test-runner: 'vitest:happy-dom'
+            experimental: true
 
     steps:
       - name: ⬇️ Checkout repo
diff --git a/package.json b/package.json
index 20bc1c2..938f563 100644
--- a/package.json
+++ b/package.json
@@ -64,13 +64,13 @@
     "contributors:generate": "all-contributors generate"
   },
   "peerDependencies": {
-    "svelte": "^3 || ^4"
+    "svelte": "^3 || ^4 || ^5"
   },
   "dependencies": {
     "@testing-library/dom": "^9.3.1"
   },
   "devDependencies": {
-    "@sveltejs/vite-plugin-svelte": "^2.4.2",
+    "@sveltejs/vite-plugin-svelte": "^3.0.2",
     "@testing-library/jest-dom": "^6.3.0",
     "@testing-library/user-event": "^14.5.2",
     "@typescript-eslint/eslint-plugin": "6.19.1",
@@ -94,11 +94,11 @@
     "npm-run-all": "^4.1.5",
     "prettier": "3.2.4",
     "prettier-plugin-svelte": "3.1.2",
-    "svelte": "^3 || ^4",
+    "svelte": "^4.2.10",
     "svelte-check": "^3.6.3",
     "svelte-jester": "^3.0.0",
     "typescript": "^5.3.3",
-    "vite": "^4.3.9",
+    "vite": "^5.1.1",
     "vitest": "^0.33.0"
   }
 }
diff --git a/src/pure.js b/src/pure.js
index 59c62ff..a672b64 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -3,19 +3,15 @@ import {
   getQueriesForElement,
   prettyDOM,
 } from '@testing-library/dom'
-import { tick } from 'svelte'
+import * as Svelte from 'svelte'
 
-const containerCache = new Set()
+const IS_SVELTE_5 = typeof Svelte.createRoot === 'function'
+const targetCache = new Set()
 const componentCache = new Set()
 
-const svelteComponentOptions = [
-  'accessors',
-  'anchor',
-  'props',
-  'hydrate',
-  'intro',
-  'context',
-]
+const svelteComponentOptions = IS_SVELTE_5
+  ? ['target', 'props', 'events', 'context', 'intro', 'recover']
+  : ['accessors', 'anchor', 'props', 'hydrate', 'intro', 'context']
 
 const render = (
   Component,
@@ -24,6 +20,7 @@ const render = (
 ) => {
   container = container || document.body
   target = target || container.appendChild(document.createElement('div'))
+  targetCache.add(target)
 
   const ComponentConstructor = Component.default || Component
 
@@ -54,17 +51,27 @@ const render = (
     return { props: options }
   }
 
-  let component = new ComponentConstructor({
-    target,
-    ...checkProps(options),
-  })
+  const renderComponent = (options) => {
+    options = { target, ...checkProps(options) }
 
-  containerCache.add({ container, target, component })
-  componentCache.add(component)
+    const component = IS_SVELTE_5
+      ? Svelte.createRoot(ComponentConstructor, options)
+      : new ComponentConstructor(options)
 
-  component.$$.on_destroy.push(() => {
-    componentCache.delete(component)
-  })
+    componentCache.add(component)
+
+    // TODO(mcous, 2024-02-11): remove this behavior in the next major version
+    // It is unnecessary has no path to implementation in Svelte v5
+    if (!IS_SVELTE_5) {
+      component.$$.on_destroy.push(() => {
+        componentCache.delete(component)
+      })
+    }
+
+    return component
+  }
+
+  let component = renderComponent(options)
 
   return {
     container,
@@ -78,48 +85,53 @@ const render = (
         props = props.props
       }
       component.$set(props)
-      await tick()
+      await Svelte.tick()
     },
     unmount: () => {
-      if (componentCache.has(component)) component.$destroy()
+      cleanupComponent(component)
     },
     ...getQueriesForElement(container, queries),
   }
 }
 
-const cleanupAtContainer = (cached) => {
-  const { target, component } = cached
+const cleanupComponent = (component) => {
+  const inCache = componentCache.delete(component)
 
-  if (componentCache.has(component)) component.$destroy()
+  if (inCache) {
+    component.$destroy()
+  }
+}
 
-  if (target.parentNode === document.body) {
+const cleanupTarget = (target) => {
+  const inCache = targetCache.delete(target)
+
+  if (inCache && target.parentNode === document.body) {
     document.body.removeChild(target)
   }
-
-  containerCache.delete(cached)
 }
 
 const cleanup = () => {
-  Array.from(containerCache.keys()).forEach(cleanupAtContainer)
+  componentCache.forEach(cleanupComponent)
+  targetCache.forEach(cleanupTarget)
 }
 
 const act = async (fn) => {
   if (fn) {
     await fn()
   }
-  return tick()
+  return Svelte.tick()
 }
 
 const fireEvent = async (...args) => {
   const event = dtlFireEvent(...args)
-  await tick()
+  await Svelte.tick()
   return event
 }
 
 Object.keys(dtlFireEvent).forEach((key) => {
   fireEvent[key] = async (...args) => {
     const event = dtlFireEvent[key](...args)
-    await tick()
+    await Svelte.tick()
     return event
   }
 })
diff --git a/vite.config.js b/vite.config.js
index 4baf76f..0ad04cc 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -3,7 +3,7 @@ import { defineConfig } from 'vite'
 
 // https://vitejs.dev/config/
 export default defineConfig(({ mode }) => ({
-  plugins: [svelte()],
+  plugins: [svelte({ hot: false })],
   resolve: {
     // Ensure `browser` exports are used in tests
     // Vitest prefers modules' `node` export by default


Test patch:
diff --git a/src/__tests__/__snapshots__/render.test.js.snap b/src/__tests__/__snapshots__/render.test.js.snap
index a31cf2e..b9eb849 100644
--- a/src/__tests__/__snapshots__/render.test.js.snap
+++ b/src/__tests__/__snapshots__/render.test.js.snap
@@ -1,6 +1,6 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`render > should accept svelte component options 1`] = `
+exports[`render > should accept svelte v4 component options 1`] = `
 <body>
   <div>
     <h1
@@ -18,8 +18,31 @@ exports[`render > should accept svelte component options 1`] = `
     <button>
       Button
     </button>
-    <!--&lt;Comp&gt;-->
     <div />
   </div>
 </body>
 `;
+
+exports[`render > should accept svelte v5 component options 1`] = `
+<body>
+  
+  
+  
+  <section>
+    <h1
+      data-testid="test"
+    >
+      Hello World!
+    </h1>
+     
+    <div>
+      we have context
+    </div>
+     
+    <button>
+      Button
+    </button>
+    
+  </section>
+</body>
+`;
diff --git a/src/__tests__/cleanup.test.js b/src/__tests__/cleanup.test.js
new file mode 100644
index 0000000..789338d
--- /dev/null
+++ b/src/__tests__/cleanup.test.js
@@ -0,0 +1,35 @@
+import { describe, expect, test, vi } from 'vitest'
+
+import { act, cleanup, render } from '..'
+import Mounter from './fixtures/Mounter.svelte'
+
+const onExecuted = vi.fn()
+const onDestroyed = vi.fn()
+const renderSubject = () => render(Mounter, { onExecuted, onDestroyed })
+
+describe('cleanup', () => {
+  test('cleanup deletes element', async () => {
+    renderSubject()
+    cleanup()
+
+    expect(document.body).toBeEmptyDOMElement()
+  })
+
+  test('cleanup unmounts component', async () => {
+    await act(renderSubject)
+    cleanup()
+
+    expect(onDestroyed).toHaveBeenCalledOnce()
+  })
+
+  test('cleanup handles unexpected errors during mount', () => {
+    onExecuted.mockImplementation(() => {
+      throw new Error('oh no!')
+    })
+
+    expect(renderSubject).toThrowError()
+    cleanup()
+
+    expect(document.body).toBeEmptyDOMElement()
+  })
+})
diff --git a/src/__tests__/fixtures/Mounter.svelte b/src/__tests__/fixtures/Mounter.svelte
index 477bb34..51ebcd8 100644
--- a/src/__tests__/fixtures/Mounter.svelte
+++ b/src/__tests__/fixtures/Mounter.svelte
@@ -1,11 +1,19 @@
 <script>
-  import { onDestroy,onMount } from 'svelte'
+  import { onDestroy, onMount } from 'svelte'
 
-  export let onMounted
-  export let onDestroyed
+  export let onExecuted = undefined
+  export let onMounted = undefined
+  export let onDestroyed = undefined
 
-  onMount(() => onMounted())
-  onDestroy(() => onDestroyed())
+  onExecuted?.()
+
+  onMount(() => {
+    onMounted?.()
+  })
+
+  onDestroy(() => {
+    onDestroyed?.()
+  })
 </script>
 
 <button />
diff --git a/src/__tests__/fixtures/Rerender.svelte b/src/__tests__/fixtures/Rerender.svelte
index aac1600..1a3fa24 100644
--- a/src/__tests__/fixtures/Rerender.svelte
+++ b/src/__tests__/fixtures/Rerender.svelte
@@ -1,15 +1,17 @@
 <script>
-  import { getContext, onMount } from 'svelte'
+  import { onDestroy, onMount } from 'svelte'
 
-  export let name
+  export let onExecuted = undefined
+  export let onMounted = undefined
+  export let onDestroyed = undefined
 
-  const mountCounter = getContext('mountCounter')
+  export let name = ''
 
-  onMount(() => {
-    mountCounter.update((i) => i + 1)
-  })
-</script>
+  onExecuted?.()
+
+  onMount(() => onMounted?.())
 
-<h1 data-testid="test">Hello {name}!</h1>
+  onDestroy(() => onDestroyed?.())
+</script>
 
-<div data-testid="mount-counter">{$mountCounter}</div>
+<div data-testid="test">Hello {name}!</div>
diff --git a/src/__tests__/mount.test.js b/src/__tests__/mount.test.js
index 830b513..898aa6a 100644
--- a/src/__tests__/mount.test.js
+++ b/src/__tests__/mount.test.js
@@ -3,31 +3,31 @@ import { describe, expect, test, vi } from 'vitest'
 import { act, render, screen } from '..'
 import Mounter from './fixtures/Mounter.svelte'
 
-describe('mount and destroy', () => {
-  const handleMount = vi.fn()
-  const handleDestroy = vi.fn()
+const onMounted = vi.fn()
+const onDestroyed = vi.fn()
+const renderSubject = () => render(Mounter, { onMounted, onDestroyed })
 
+describe('mount and destroy', () => {
   test('component is mounted', async () => {
-    await act(() => {
-      render(Mounter, { onMounted: handleMount, onDestroyed: handleDestroy })
-    })
+    renderSubject()
 
     const content = screen.getByRole('button')
 
-    expect(handleMount).toHaveBeenCalledOnce()
     expect(content).toBeInTheDocument()
+    await act()
+    expect(onMounted).toHaveBeenCalledOnce()
   })
 
   test('component is destroyed', async () => {
-    const { unmount } = render(Mounter, {
-      onMounted: handleMount,
-      onDestroyed: handleDestroy,
-    })
+    const { unmount } = renderSubject()
+
+    await act()
+    unmount()
 
-    await act(() => unmount())
     const content = screen.queryByRole('button')
 
-    expect(handleDestroy).toHaveBeenCalledOnce()
     expect(content).not.toBeInTheDocument()
+    await act()
+    expect(onDestroyed).toHaveBeenCalledOnce()
   })
 })
diff --git a/src/__tests__/render.test.js b/src/__tests__/render.test.js
index 6beb984..262e062 100644
--- a/src/__tests__/render.test.js
+++ b/src/__tests__/render.test.js
@@ -1,3 +1,4 @@
+import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
 import { beforeEach, describe, expect, test } from 'vitest'
 
 import { act, render as stlRender } from '..'
@@ -11,13 +12,13 @@ describe('render', () => {
     return stlRender(Comp, {
       target: document.body,
       props,
-      ...additional
+      ...additional,
     })
   }
 
   beforeEach(() => {
     props = {
-      name: 'World'
+      name: 'World',
     }
   })
 
@@ -41,7 +42,9 @@ describe('render', () => {
   })
 
   test('change props with accessors', async () => {
-    const { component, getByText } = render({ accessors: true })
+    const { component, getByText } = render(
+      SVELTE_VERSION < '5' ? { accessors: true } : {}
+    )
 
     expect(getByText('Hello World!')).toBeInTheDocument()
 
@@ -59,23 +62,41 @@ describe('render', () => {
     expect(getByText('Hello World!')).toBeInTheDocument()
   })
 
-  test('should accept svelte component options', () => {
-    const target = document.createElement('div')
-    const div = document.createElement('div')
-    document.body.appendChild(target)
-    target.appendChild(div)
-    const { container } = stlRender(Comp, {
-      target,
-      anchor: div,
-      props: { name: 'World' },
-      context: new Map([['name', 'context']])
-    })
-    expect(container).toMatchSnapshot()
-  })
+  test.runIf(SVELTE_VERSION < '5')(
+    'should accept svelte v4 component options',
+    () => {
+      const target = document.createElement('div')
+      const div = document.createElement('div')
+      document.body.appendChild(target)
+      target.appendChild(div)
+      const { container } = stlRender(Comp, {
+        target,
+        anchor: div,
+        props: { name: 'World' },
+        context: new Map([['name', 'context']]),
+      })
+      expect(container).toMatchSnapshot()
+    }
+  )
+
+  test.runIf(SVELTE_VERSION >= '5')(
+    'should accept svelte v5 component options',
+    () => {
+      const target = document.createElement('section')
+      document.body.appendChild(target)
+
+      const { container } = stlRender(Comp, {
+        target,
+        props: { name: 'World' },
+        context: new Map([['name', 'context']]),
+      })
+      expect(container).toMatchSnapshot()
+    }
+  )
 
   test('should throw error when mixing svelte component options and props', () => {
     expect(() => {
-      stlRender(Comp, { anchor: '', name: 'World' })
+      stlRender(Comp, { props: {}, name: 'World' })
     }).toThrow(/Unknown options were found/)
   })
 
@@ -93,10 +114,8 @@ describe('render', () => {
 
   test("accept the 'context' option", () => {
     const { getByText } = stlRender(Comp, {
-      props: {
-        name: 'Universe'
-      },
-      context: new Map([['name', 'context']])
+      props: { name: 'Universe' },
+      context: new Map([['name', 'context']]),
     })
 
     expect(getByText('we have context')).toBeInTheDocument()
diff --git a/src/__tests__/rerender.test.js b/src/__tests__/rerender.test.js
index d6cbc21..922ea63 100644
--- a/src/__tests__/rerender.test.js
+++ b/src/__tests__/rerender.test.js
@@ -1,24 +1,24 @@
 /**
  * @jest-environment jsdom
  */
-import { expect, test, vi } from 'vitest'
+import { describe, expect, test, vi } from 'vitest'
 import { writable } from 'svelte/store'
 
-import { render, waitFor } from '..'
+import { act, render, waitFor } from '..'
 import Comp from './fixtures/Rerender.svelte'
 
-const mountCounter = writable(0)
-
 test('mounts new component successfully', async () => {
+  const onMounted = vi.fn()
+  const onDestroyed = vi.fn()
+
   const { getByTestId, rerender } = render(Comp, {
-    props: { name: 'World 1' },
-    context: new Map(Object.entries({ mountCounter })),
+    props: { name: 'World 1', onMounted, onDestroyed },
   })
 
   const expectToRender = (content) =>
     waitFor(() => {
       expect(getByTestId('test')).toHaveTextContent(content)
-      expect(getByTestId('mount-counter')).toHaveTextContent('1')
+      expect(onMounted).toHaveBeenCalledOnce()
     })
 
   await expectToRender('Hello World 1!')
@@ -27,12 +27,15 @@ test('mounts new component successfully', async () => {
 
   rerender({ props: { name: 'World 2' } })
   await expectToRender('Hello World 2!')
+  expect(onDestroyed).not.toHaveBeenCalled()
 
-  expect(console.warn).toHaveBeenCalled()
+  expect(console.warn).toHaveBeenCalledOnce()
 
   console.warn.mockClear()
+  onDestroyed.mockReset()
   rerender({ name: 'World 3' })
   await expectToRender('Hello World 3!')
+  expect(onDestroyed).not.toHaveBeenCalled()
 
   expect(console.warn).not.toHaveBeenCalled()
 })
diff --git a/src/__tests__/transition.test.js b/src/__tests__/transition.test.js
index 5b1ff18..e8191f5 100644
--- a/src/__tests__/transition.test.js
+++ b/src/__tests__/transition.test.js
@@ -1,10 +1,11 @@
 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 Transitioner from './fixtures/Transitioner.svelte'
 
-describe('transitions', () => {
+describe.runIf(SVELTE_VERSION < '5')('transitions', () => {
   beforeEach(() => {
     if (window.navigator.userAgent.includes('jsdom')) {
       const raf = (fn) => setTimeout(() => fn(new Date()), 16)