diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index 39f9d9e..0000000
--- a/.eslintignore
+++ /dev/null
@@ -1,6 +0,0 @@
-scripts/*
-.eslintignore
-.prettierignore
-.github/workflows/*
-*.md
-types
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
deleted file mode 100644
index f403dca..0000000
--- a/.eslintrc.cjs
+++ /dev/null
@@ -1,56 +0,0 @@
-module.exports = {
-  root: true,
-  env: {
-    browser: true,
-    es6: true,
-    'vitest-globals/env': true,
-  },
-  extends: [
-    'standard',
-    'plugin:vitest-globals/recommended',
-    'plugin:svelte/recommended',
-    'prettier',
-  ],
-  plugins: ['svelte', 'simple-import-sort', 'json-files'],
-  rules: {
-    'simple-import-sort/imports': 'error',
-    'simple-import-sort/exports': 'error',
-  },
-  overrides: [
-    {
-      files: ['*.svelte'],
-      parser: 'svelte-eslint-parser',
-      parserOptions: {
-        parser: '@typescript-eslint/parser',
-      },
-      rules: {
-        'no-undef-init': 'off',
-        'prefer-const': 'off',
-        'svelte/no-unused-svelte-ignore': 'off',
-      },
-    },
-    {
-      files: ['*.ts'],
-      parser: '@typescript-eslint/parser',
-      extends: [
-        'plugin:@typescript-eslint/recommended',
-        'plugin:@typescript-eslint/stylistic',
-        'prettier',
-      ],
-      rules: {
-        '@typescript-eslint/ban-types': [
-          'error',
-          { types: { '{}': false }, extendDefaults: true },
-        ],
-        '@typescript-eslint/no-explicit-any': 'off',
-        'import/export': 'off',
-      },
-    },
-  ],
-  parserOptions: {
-    ecmaVersion: 2022,
-    sourceType: 'module',
-  },
-  globals: { $state: 'readonly', $props: 'readonly' },
-  ignorePatterns: ['!/.*'],
-}
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index d4a16b5..434a8bb 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -23,13 +23,6 @@ updates:
       development:
         dependency-type: 'development'
 
-    # TODO(mcous, 2024-04-30): update to ESLint v9 + flat config
-    ignore:
-      - dependency-name: 'eslint'
-        versions: ['>=9']
-      - dependency-name: 'eslint-plugin-n'
-        versions: ['>=17']
-
   # Update GitHub Actions dependencies
   - package-ecosystem: 'github-actions'
     directory: '/'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7b7ca01..136111f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,9 +17,8 @@ jobs:
   main:
     # ignore all-contributors PRs
     if: ${{ !contains(github.head_ref, 'all-contributors') }}
-    name: Node ${{ matrix.node }}, Svelte ${{ matrix.svelte }}, ${{ matrix.test-runner }}
+    name: Svelte ${{ matrix.svelte }}, Node ${{ matrix.node }}, ${{ matrix.check }}
     runs-on: ubuntu-latest
-    continue-on-error: ${{ matrix.experimental }}
 
     # enable OIDC for codecov uploads
     permissions:
@@ -28,23 +27,23 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        node: ['16', '18', '20']
-        svelte: ['3', '4']
-        test-runner: ['vitest:jsdom', 'vitest:happy-dom', 'jest']
-        experimental: [false]
+        node: ['16', '18', '20', '22']
+        svelte: ['3', '4', '5']
+        check: ['test:vitest:jsdom', 'test:vitest:happy-dom', 'test:jest']
+        exclude:
+          # Don't run Svelte 3 on Node versions greater than 20
+          - { svelte: '3', node: '22' }
+          # Only run Svelte 5 on Node versions greater than or equal to 20
+          - { svelte: '5', node: '16' }
+          - { svelte: '5', node: '18' }
         include:
-          - node: '20'
-            svelte: 'next'
-            test-runner: 'vitest:jsdom'
-            experimental: true
-          - node: '20'
-            svelte: 'next'
-            test-runner: 'vitest:happy-dom'
-            experimental: true
-          - node: '20'
-            svelte: 'next'
-            test-runner: 'jest'
-            experimental: true
+          # Only lint and test examples on latest Node and Svelte
+          - { svelte: '5', node: '22', check: 'lint' }
+          - { svelte: '5', node: '22', check: 'test:examples' }
+          # Run type checks in latest applicable Node
+          - { svelte: '3', node: '20', check: 'types:legacy' }
+          - { svelte: '4', node: '22', check: 'types:legacy' }
+          - { svelte: '5', node: '22', check: 'types' }
 
     steps:
       - name: ⬇️ Checkout repo
@@ -56,20 +55,14 @@ jobs:
           node-version: ${{ matrix.node }}
 
       - name: 📥 Download deps
-        run: |
-          npm install --no-package-lock
-          npm install --no-save svelte@${{ matrix.svelte }}
+        run: npm run install:${{ matrix.svelte }}
 
-      - name: ▶️ Run tests
-        run: npm run test:${{ matrix.test-runner }}
-
-      - name: ▶️ Run type-checks
-        # NOTE: `SvelteComponent` is not generic in Svelte v3, so type-checking will not pass
-        if: ${{ matrix.node == '20' && matrix.svelte != '3' && matrix.test-runner == 'vitest:jsdom' }}
-        run: npm run types
+      - name: ▶️ Run ${{ matrix.check }}
+        run: npm run ${{ matrix.check }}
 
       - name: ⬆️ Upload coverage report
-        uses: codecov/codecov-action@v4
+        if: ${{ startsWith(matrix.check, 'test:') }}
+        uses: codecov/codecov-action@v5
         with:
           use_oidc: true
           fail_ci_if_error: true
@@ -83,10 +76,10 @@ jobs:
       - name: ⎔ Setup node
         uses: actions/setup-node@v4
         with:
-          node-version: 20
+          node-version: 22
 
       - name: 📥 Download deps
-        run: npm install --no-package-lock
+        run: npm install
 
       - name: 🏗️ Build types
         run: npm run build
@@ -110,7 +103,7 @@ jobs:
       - name: ⎔ Setup node
         uses: actions/setup-node@v4
         with:
-          node-version: 20
+          node-version: 22
 
       - name: 📥 Downloads types build
         uses: actions/download-artifact@v4
diff --git a/.gitignore b/.gitignore
index 151e826..ca3410b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ public/bundle.*
 coverage
 dist
 .idea
+*.tgz
 
 # These cause more harm than good when working with contributors
 yarn-error.log
diff --git a/.npmrc b/.npmrc
index 43c97e7..86916fa 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,2 @@
 package-lock=false
+engine-strict=true
diff --git a/.prettierignore b/.prettierignore
index 5e50c20..c750ecf 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,4 +1 @@
-scripts/*
-.eslintignore
-.prettierignore
 .all-contributorsrc
diff --git a/.prettierrc.yaml b/.prettierrc.yaml
deleted file mode 100644
index 0a2ace3..0000000
--- a/.prettierrc.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-semi: false
-singleQuote: true
-trailingComma: es5
-plugins:
-  - prettier-plugin-svelte
-overrides:
-  - files: '*.svelte'
-    options:
-      parser: svelte
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 92856ee..328bcda 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,11 +28,10 @@ npm run preview-release
 
 ## Development setup
 
-After cloning the repository, install the project's dependencies and run the `validate` script to run all checks and tests to verify your setup.
+After cloning the repository, use the `setup` script to install dependencies and run all checks:
 
 ```shell
-npm install # or `pnpm install`, or `yarn install`, etc.
-npm run validate
+npm run setup
 ```
 
 ### Lint and format
@@ -40,13 +39,13 @@ npm run validate
 Run auto-formatting to ensure any changes adhere to the code style of the repository:
 
 ```shell
-npm run format:delta
+npm run format
 ```
 
 To run lint and format checks without making any changes:
 
 ```shell
-npm run lint:delta
+npm run lint
 ```
 
 ### Test
@@ -58,12 +57,30 @@ npm test
 npm run test:watch
 ```
 
+### Using different versions of Svelte
+
+Use the provided script to set up your environment for different versions of Svelte:
+
+```shell
+# Svelte 5
+npm run install:5
+npm run all
+
+# Svelte 4
+npm run install:4
+npm run all:legacy
+
+# Svelte 3
+npm run install:3
+npm run all:legacy
+```
+
 ### Docs
 
-Use the `toc` script to ensure the README's table of contents is up to date:
+Use the `docs` script to ensure the README's table of contents is up to date:
 
 ```shell
-npm run toc
+npm run docs
 ```
 
 Use `contributors:add` to add a contributor to the README:
diff --git a/README.md b/README.md
index bf45331..6a5f1d9 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,10 @@
 
 <p>Simple and complete Svelte testing utilities that encourage good testing practices.</p>
 
-[**Read The Docs**][stl-docs] | [Edit the docs][stl-docs-repo]
+[**Read The Docs**][stl-docs] | [Edit the docs][stl-docs-repo] | [Examples](./examples)
 
 <!-- prettier-ignore-start -->
+
 [![Build Status][build-badge]][build]
 [![Code Coverage][coverage-badge]][coverage]
 [![version][version-badge]][package]
@@ -29,7 +30,9 @@
 [![Watch on GitHub][github-watch-badge]][github-watch]
 [![Star on GitHub][github-star-badge]][github-star]
 [![Tweet][twitter-badge]][twitter]
+
 <!-- prettier-ignore-end -->
+
 </div>
 
 <hr />
@@ -63,13 +66,11 @@
 
 ## Table of Contents
 
-<!-- START doctoc generated TOC please keep comment here to allow auto update -->
-<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
-
 - [The Problem](#the-problem)
 - [This Solution](#this-solution)
 - [Installation](#installation)
 - [Setup](#setup)
+  - [Auto-cleanup](#auto-cleanup)
 - [Docs](#docs)
 - [Issues](#issues)
   - [🐛 Bugs](#-bugs)
@@ -77,8 +78,6 @@
   - [❓ Questions](#-questions)
 - [Contributors](#contributors)
 
-<!-- END doctoc generated TOC please keep comment here to allow auto update -->
-
 ## The Problem
 
 You want to write maintainable tests for your [Svelte][svelte] components.
@@ -140,6 +139,39 @@ test runners like Jest.
 [vitest]: https://vitest.dev/
 [setup docs]: https://testing-library.com/docs/svelte-testing-library/setup
 
+### Auto-cleanup
+
+In Vitest (via the `svelteTesting` plugin) and Jest (via the `beforeEach` and `afterEach` globals),
+this library will automatically setup and cleanup the test environment before and after each test.
+
+To do your own cleanup, or if you're using another framework, call the `setup` and `cleanup` functions yourself:
+
+```js
+import { cleanup, render, setup } from '@testing-library/svelte'
+
+// before
+setup()
+
+// test
+render(/* ... */)
+
+// after
+cleanup()
+```
+
+To disable auto-cleanup in Vitest, set the `autoCleanup` option of the plugin to false:
+
+```js
+svelteTesting({ autoCleanup: false })
+```
+
+To disable auto-cleanup in Jest and other frameworks with global test hooks,
+set the `STL_SKIP_AUTO_CLEANUP` environment variable:
+
+```shell
+STL_SKIP_AUTO_CLEANUP=1 jest
+```
+
 ## Docs
 
 See the [**docs**][stl-docs] over at the Testing Library website.
@@ -183,8 +215,11 @@ instead of filing an issue on GitHub.
 Thanks goes to these people ([emoji key][emojis]):
 
 <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
+
 <!-- prettier-ignore-start -->
+
 <!-- markdownlint-disable -->
+
 <table>
   <tbody>
     <tr>
@@ -212,6 +247,7 @@ Thanks goes to these people ([emoji key][emojis]):
 </table>
 
 <!-- markdownlint-restore -->
+
 <!-- prettier-ignore-end -->
 
 <!-- ALL-CONTRIBUTORS-LIST:END -->
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..092d967
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,98 @@
+import js from '@eslint/js'
+import eslintPluginVitest from '@vitest/eslint-plugin'
+import eslintConfigPrettier from 'eslint-config-prettier'
+import eslintPluginJestDom from 'eslint-plugin-jest-dom'
+import eslintPluginPromise from 'eslint-plugin-promise'
+import eslintPluginSimpleImportSort from 'eslint-plugin-simple-import-sort'
+import eslintPluginSvelte from 'eslint-plugin-svelte'
+import eslintPluginTestingLibrary from 'eslint-plugin-testing-library'
+import eslintPluginUnicorn from 'eslint-plugin-unicorn'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+  js.configs.recommended,
+  tseslint.configs.strict,
+  tseslint.configs.stylistic,
+  eslintPluginUnicorn.configs['flat/recommended'],
+  eslintPluginPromise.configs['flat/recommended'],
+  eslintPluginSvelte.configs['flat/recommended'],
+  eslintPluginSvelte.configs['flat/prettier'],
+  eslintConfigPrettier,
+  {
+    name: 'settings',
+    languageOptions: {
+      ecmaVersion: 'latest',
+      sourceType: 'module',
+      parserOptions: {
+        parser: tseslint.parser,
+        extraFileExtensions: ['.svelte'],
+      },
+      globals: {
+        ...globals.browser,
+        ...globals.node,
+        ...globals.jest,
+      },
+    },
+  },
+  {
+    name: 'ignores',
+    ignores: ['coverage', 'types'],
+  },
+  {
+    name: 'simple-import-sort',
+    plugins: {
+      'simple-import-sort': eslintPluginSimpleImportSort,
+    },
+    rules: {
+      'simple-import-sort/imports': 'error',
+      'simple-import-sort/exports': 'error',
+    },
+  },
+  {
+    name: 'tests',
+    files: ['**/*.test.js'],
+    extends: [
+      eslintPluginVitest.configs.recommended,
+      eslintPluginJestDom.configs['flat/recommended'],
+      eslintPluginTestingLibrary.configs['flat/dom'],
+    ],
+    rules: {
+      'testing-library/no-node-access': [
+        'error',
+        { allowContainerFirstChild: true },
+      ],
+    },
+  },
+  {
+    name: 'extras',
+    rules: {
+      'unicorn/prevent-abbreviations': 'off',
+    },
+  },
+  {
+    name: 'svelte-extras',
+    files: ['**/*.svelte'],
+    rules: {
+      'svelte/no-unused-svelte-ignore': 'off',
+      'unicorn/filename-case': [
+        'error',
+        { cases: { kebabCase: true, pascalCase: true } },
+      ],
+      'unicorn/no-useless-undefined': 'off',
+    },
+  },
+  {
+    name: 'ts-extras',
+    files: ['**/*.ts'],
+    extends: [
+      tseslint.configs.strictTypeChecked,
+      tseslint.configs.stylisticTypeChecked,
+    ],
+    languageOptions: {
+      parserOptions: {
+        projectService: true,
+      },
+    },
+  }
+)
diff --git a/examples/basic/basic.svelte b/examples/basic/basic.svelte
new file mode 100644
index 0000000..11ca22c
--- /dev/null
+++ b/examples/basic/basic.svelte
@@ -0,0 +1,13 @@
+<script>
+  let { name } = $props()
+
+  let showGreeting = $state(false)
+
+  const onclick = () => (showGreeting = true)
+</script>
+
+<button {onclick}>Greet</button>
+
+{#if showGreeting}
+  <p>Hello {name}</p>
+{/if}
diff --git a/examples/basic/basic.test.js b/examples/basic/basic.test.js
new file mode 100644
index 0000000..6ea6099
--- /dev/null
+++ b/examples/basic/basic.test.js
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test } from 'vitest'
+
+import Subject from './basic.svelte'
+
+test('no initial greeting', () => {
+  render(Subject, { name: 'World' })
+
+  const button = screen.getByRole('button', { name: 'Greet' })
+  const greeting = screen.queryByText(/hello/iu)
+
+  expect(button).toBeInTheDocument()
+  expect(greeting).not.toBeInTheDocument()
+})
+
+test('greeting appears on click', async () => {
+  const user = userEvent.setup()
+  render(Subject, { name: 'World' })
+
+  const button = screen.getByRole('button')
+  await user.click(button)
+  const greeting = screen.getByText(/hello world/iu)
+
+  expect(greeting).toBeInTheDocument()
+})
diff --git a/examples/basic/readme.md b/examples/basic/readme.md
new file mode 100644
index 0000000..6d98485
--- /dev/null
+++ b/examples/basic/readme.md
@@ -0,0 +1,68 @@
+# Basic
+
+This basic example demonstrates how to:
+
+- Pass props to your Svelte component using [render()]
+- [Query][] the structure of your component's DOM elements using screen
+- Interact with your component using [@testing-library/user-event][]
+- Make assertions using expect, using matchers from
+  [@testing-library/jest-dom][]
+
+[query]: https://testing-library.com/docs/queries/about
+[render()]: https://testing-library.com/docs/svelte-testing-library/api#render
+[@testing-library/user-event]: https://testing-library.com/docs/user-event/intro
+[@testing-library/jest-dom]: https://github.com/testing-library/jest-dom
+
+## Table of contents
+
+- [`basic.svelte`](#basicsvelte)
+- [`basic.test.js`](#basictestjs)
+
+## `basic.svelte`
+
+```svelte file=./basic.svelte
+<script>
+  let { name } = $props()
+
+  let showGreeting = $state(false)
+
+  const onclick = () => (showGreeting = true)
+</script>
+
+<button {onclick}>Greet</button>
+
+{#if showGreeting}
+  <p>Hello {name}</p>
+{/if}
+```
+
+## `basic.test.js`
+
+```js file=./basic.test.js
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test } from 'vitest'
+
+import Subject from './basic.svelte'
+
+test('no initial greeting', () => {
+  render(Subject, { name: 'World' })
+
+  const button = screen.getByRole('button', { name: 'Greet' })
+  const greeting = screen.queryByText(/hello/iu)
+
+  expect(button).toBeInTheDocument()
+  expect(greeting).not.toBeInTheDocument()
+})
+
+test('greeting appears on click', async () => {
+  const user = userEvent.setup()
+  render(Subject, { name: 'World' })
+
+  const button = screen.getByRole('button')
+  await user.click(button)
+  const greeting = screen.getByText(/hello world/iu)
+
+  expect(greeting).toBeInTheDocument()
+})
+```
diff --git a/examples/binds/bind.svelte b/examples/binds/bind.svelte
new file mode 100644
index 0000000..62fd858
--- /dev/null
+++ b/examples/binds/bind.svelte
@@ -0,0 +1,5 @@
+<script>
+  let { value = $bindable('') } = $props()
+</script>
+
+<input type="text" bind:value />
diff --git a/examples/binds/bind.test.js b/examples/binds/bind.test.js
new file mode 100644
index 0000000..6d00665
--- /dev/null
+++ b/examples/binds/bind.test.js
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test } from 'vitest'
+
+import Subject from './bind.svelte'
+
+test('value binding', async () => {
+  const user = userEvent.setup()
+  let value = ''
+
+  render(Subject, {
+    get value() {
+      return value
+    },
+    set value(nextValue) {
+      value = nextValue
+    },
+  })
+
+  const input = screen.getByRole('textbox')
+  await user.type(input, 'hello world')
+
+  expect(value).toBe('hello world')
+})
diff --git a/examples/binds/no-bind.svelte b/examples/binds/no-bind.svelte
new file mode 100644
index 0000000..3e4079a
--- /dev/null
+++ b/examples/binds/no-bind.svelte
@@ -0,0 +1,9 @@
+<script>
+  let { value, onInput } = $props()
+
+  const oninput = (event) => {
+    onInput(event.target.value)
+  }
+</script>
+
+<input type="text" {value} {oninput} />
diff --git a/examples/binds/readme.md b/examples/binds/readme.md
new file mode 100644
index 0000000..fb7c745
--- /dev/null
+++ b/examples/binds/readme.md
@@ -0,0 +1,82 @@
+# Binds
+
+Two-way data binding using [bindable() props][] is difficult to test directly.
+It's usually easier to structure your code so that you can test user-facing
+results, leaving the binding as an implementation detail.
+
+However, if two-way binding is an important developer-facing API of your
+component, you can use setters to test your binding.
+
+[bindable() props]: https://svelte.dev/docs/svelte/$bindable
+
+## Table of contents
+
+- [`bind.svelte`](#bindsvelte)
+- [`bind.test.js`](#bindtestjs)
+- [Consider avoiding binding](#consider-avoiding-binding)
+
+## `bind.svelte`
+
+```svelte file=./bind.svelte
+<script>
+  let { value = $bindable('') } = $props()
+</script>
+
+<input type="text" bind:value />
+```
+
+## `bind.test.js`
+
+```js file=./bind.test.js
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test } from 'vitest'
+
+import Subject from './bind.svelte'
+
+test('value binding', async () => {
+  const user = userEvent.setup()
+  let value = ''
+
+  render(Subject, {
+    get value() {
+      return value
+    },
+    set value(nextValue) {
+      value = nextValue
+    },
+  })
+
+  const input = screen.getByRole('textbox')
+  await user.type(input, 'hello world')
+
+  expect(value).toBe('hello world')
+})
+```
+
+## Consider avoiding binding
+
+Before embarking on writing tests for bindable props, consider avoiding
+`bindable()` entirely. Two-way data binding can make your data flows and state
+changes difficult to reason about and test effectively. Instead, you can use
+value props to pass data down and callback props to pass changes back up to the
+parent.
+
+> Well-written applications use bindings very sparingly — the vast majority of
+> data flow should be top-down --
+> <cite>[Rich Harris](https://github.com/sveltejs/svelte/issues/10768#issue-2181814844)</cite>
+
+For example, rather than using a `bindable()` prop, use a value prop to pass the
+value down and callback prop to send changes back up to the parent:
+
+```svelte file=./no-bind.svelte
+<script>
+  let { value, onInput } = $props()
+
+  const oninput = (event) => {
+    onInput(event.target.value)
+  }
+</script>
+
+<input type="text" {value} {oninput} />
+```
diff --git a/examples/contexts/context.svelte b/examples/contexts/context.svelte
new file mode 100644
index 0000000..c0fbb19
--- /dev/null
+++ b/examples/contexts/context.svelte
@@ -0,0 +1,14 @@
+<script>
+  import { getContext } from 'svelte'
+
+  let { label } = $props()
+
+  const messages = getContext('messages')
+</script>
+
+<div role="status" aria-label={label}>
+  {#each messages.current as message (message.id)}
+    <p>{message.text}</p>
+    <hr />
+  {/each}
+</div>
diff --git a/examples/contexts/context.test.js b/examples/contexts/context.test.js
new file mode 100644
index 0000000..4a433d2
--- /dev/null
+++ b/examples/contexts/context.test.js
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './context.svelte'
+
+test('notifications with messages from context', async () => {
+  const messages = {
+    get current() {
+      return [
+        { id: 'abc', text: 'hello' },
+        { id: 'def', text: 'world' },
+      ]
+    },
+  }
+
+  render(Subject, {
+    context: new Map([['messages', messages]]),
+    props: { label: 'Notifications' },
+  })
+
+  const status = screen.getByRole('status', { name: 'Notifications' })
+
+  expect(status).toHaveTextContent('hello world')
+})
diff --git a/examples/contexts/readme.md b/examples/contexts/readme.md
new file mode 100644
index 0000000..15284a9
--- /dev/null
+++ b/examples/contexts/readme.md
@@ -0,0 +1,61 @@
+# Context
+
+If your component requires access to contexts, you can pass those contexts in
+when you render the component. When using extra [component options][] like
+`context`, be sure to place props under the `props` key.
+
+[component options]:
+  https://testing-library.com/docs/svelte-testing-library/api#component-options
+
+## Table of contents
+
+- [`context.svelte`](#contextsvelte)
+- [`context.test.js`](#contexttestjs)
+
+## `context.svelte`
+
+```svelte file=./context.svelte
+<script>
+  import { getContext } from 'svelte'
+
+  let { label } = $props()
+
+  const messages = getContext('messages')
+</script>
+
+<div role="status" aria-label={label}>
+  {#each messages.current as message (message.id)}
+    <p>{message.text}</p>
+    <hr />
+  {/each}
+</div>
+```
+
+## `context.test.js`
+
+```js file=./context.test.js
+import { render, screen } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './context.svelte'
+
+test('notifications with messages from context', async () => {
+  const messages = {
+    get current() {
+      return [
+        { id: 'abc', text: 'hello' },
+        { id: 'def', text: 'world' },
+      ]
+    },
+  }
+
+  render(Subject, {
+    context: new Map([['messages', messages]]),
+    props: { label: 'Notifications' },
+  })
+
+  const status = screen.getByRole('status', { name: 'Notifications' })
+
+  expect(status).toHaveTextContent('hello world')
+})
+```
diff --git a/examples/deprecated/deprecated-event.svelte b/examples/deprecated/deprecated-event.svelte
new file mode 100644
index 0000000..fe21a0e
--- /dev/null
+++ b/examples/deprecated/deprecated-event.svelte
@@ -0,0 +1 @@
+<button on:click>click me</button>
diff --git a/examples/deprecated/deprecated-event.test.js b/examples/deprecated/deprecated-event.test.js
new file mode 100644
index 0000000..973e897
--- /dev/null
+++ b/examples/deprecated/deprecated-event.test.js
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/svelte'
+import userEvent from '@testing-library/user-event'
+import { expect, test, vi } from 'vitest'
+
+import Subject from './deprecated-event.svelte'
+
+test('on:click event', async () => {
+  const user = userEvent.setup()
+  const onClick = vi.fn()
+
+  render(Subject, { events: { click: onClick } })
+
+  const button = screen.getByRole('button')
+  await user.click(button)
+
+  expect(onClick).toHaveBeenCalledOnce()
+})
diff --git a/examples/deprecated/deprecated-slot.svelte b/examples/deprecated/deprecated-slot.svelte
new file mode 100644
index 0000000..08d88e3
--- /dev/null
+++ b/examples/deprecated/deprecated-slot.svelte
@@ -0,0 +1,3 @@
+<h1>
+  <slot />
+</h1>
diff --git a/examples/deprecated/deprecated-slot.test.js b/examples/deprecated/deprecated-slot.test.js
new file mode 100644
index 0000000..dfe73b8
--- /dev/null
+++ b/examples/deprecated/deprecated-slot.test.js
@@ -0,0 +1,13 @@
+import { render, screen, within } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import SubjectTest from './deprecated-slot.test.svelte'
+
+test('heading with slot', () => {
+  render(SubjectTest)
+
+  const heading = screen.getByRole('heading')
+  const child = within(heading).getByTestId('child')
+
+  expect(child).toBeInTheDocument()
+})
diff --git a/examples/deprecated/deprecated-slot.test.svelte b/examples/deprecated/deprecated-slot.test.svelte
new file mode 100644
index 0000000..92b5f6b
--- /dev/null
+++ b/examples/deprecated/deprecated-slot.test.svelte
@@ -0,0 +1,7 @@
+<script>
+  import Subject from './deprecated-slot.svelte'
+</script>
+
+<Subject>
+  <span data-testid="child"></span>
+</Subject>
diff --git a/examples/deprecated/readme.md b/examples/deprecated/readme.md
new file mode 100644
index 0000000..ca1a8d1
--- /dev/null
+++ b/examples/deprecated/readme.md
@@ -0,0 +1,105 @@
+# Deprecated Svelte 3/4 features
+
+Several features from Svelte 3 and 4 have been deprecated in Svelte 5, but while
+you still have components using the old syntax, or if you haven't yet updated to
+Svelte 5, you can continue to use `@testing-library/svelte` to test your
+components.
+
+## Table of contents
+
+- [Events](#events)
+  - [`deprecated-event.svelte`](#deprecated-eventsvelte)
+  - [`deprecated-event.test.js`](#deprecated-eventtestjs)
+- [Slots](#slots)
+  - [`deprecated-slot.svelte`](#deprecated-slotsvelte)
+  - [`deprecated-slot.test.svelte`](#deprecated-slottestsvelte)
+  - [`deprecated-slot.test.js`](#deprecated-slottestjs)
+
+## Events
+
+The `on:event` syntax was deprecated in favor of callback props. However, if you
+have updated your Svelte runtime to version 5, you can use the `events`
+component option to continue to test events in older components.
+
+### `deprecated-event.svelte`
+
+```svelte file=./deprecated-event.svelte
+<button on:click>click me</button>
+```
+
+### `deprecated-event.test.js`
+
+> \[!WARNING]
+>
+> If you are still using Svelte version 3 or 4, `render` will **not** have an
+> `events` option. Instead, use `component.$on` to attach an event listener.
+>
+> ```js
+> const onClick = vi.fn()
+>
+> const { component } = render(Subject)
+> component.$on('click', onClick)
+> ```
+
+```js file=./deprecated-event.test.js
+import { render, screen } from '@testing-library/svelte'
+import userEvent from '@testing-library/user-event'
+import { expect, test, vi } from 'vitest'
+
+import Subject from './deprecated-event.svelte'
+
+test('on:click event', async () => {
+  const user = userEvent.setup()
+  const onClick = vi.fn()
+
+  render(Subject, { events: { click: onClick } })
+
+  const button = screen.getByRole('button')
+  await user.click(button)
+
+  expect(onClick).toHaveBeenCalledOnce()
+})
+```
+
+## Slots
+
+The slots feature was deprecated in favor of snippets. If you have components
+that still use slots, you can create a wrapper component to test them.
+
+### `deprecated-slot.svelte`
+
+```svelte file=./deprecated-slot.svelte
+<h1>
+  <slot />
+</h1>
+```
+
+### `deprecated-slot.test.svelte`
+
+```svelte file=./deprecated-slot.test.svelte
+<script>
+  import Subject from './deprecated-slot.svelte'
+</script>
+
+<Subject>
+  <span data-testid="child"></span>
+</Subject>
+```
+
+### `deprecated-slot.test.js`
+
+```js file=deprecated-slot.test.js
+import { render, screen, within } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import SubjectTest from './deprecated-slot.test.svelte'
+
+test('heading with slot', () => {
+  render(SubjectTest)
+
+  const heading = screen.getByRole('heading')
+  const child = within(heading).getByTestId('child')
+
+  expect(child).toBeInTheDocument()
+})
+```
diff --git a/examples/events/event.svelte b/examples/events/event.svelte
new file mode 100644
index 0000000..3562da1
--- /dev/null
+++ b/examples/events/event.svelte
@@ -0,0 +1,5 @@
+<script>
+  let { onclick } = $props()
+</script>
+
+<button {onclick}>click me</button>
diff --git a/examples/events/event.test.js b/examples/events/event.test.js
new file mode 100644
index 0000000..c37902b
--- /dev/null
+++ b/examples/events/event.test.js
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test, vi } from 'vitest'
+
+import Subject from './event.svelte'
+
+test('onclick event', async () => {
+  const user = userEvent.setup()
+  const onclick = vi.fn()
+
+  render(Subject, { onclick })
+
+  const button = screen.getByRole('button')
+  await user.click(button)
+
+  expect(onclick).toHaveBeenCalledOnce()
+})
diff --git a/examples/events/readme.md b/examples/events/readme.md
new file mode 100644
index 0000000..7af235a
--- /dev/null
+++ b/examples/events/readme.md
@@ -0,0 +1,43 @@
+# Events
+
+Events can be tested using spy functions. If you're using Vitest you can use
+[vi.fn()][] to create a spy.
+
+[vi.fn()]: https://vitest.dev/api/vi.html#vi-fn
+
+## Table of contents
+
+- [`event.svelte`](#eventsvelte)
+- [`event.test.js`](#eventtestjs)
+
+## `event.svelte`
+
+```svelte file=./event.svelte
+<script>
+  let { onclick } = $props()
+</script>
+
+<button {onclick}>click me</button>
+```
+
+## `event.test.js`
+
+```js file=./event.test.js
+import { render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { expect, test, vi } from 'vitest'
+
+import Subject from './event.svelte'
+
+test('onclick event', async () => {
+  const user = userEvent.setup()
+  const onclick = vi.fn()
+
+  render(Subject, { onclick })
+
+  const button = screen.getByRole('button')
+  await user.click(button)
+
+  expect(onclick).toHaveBeenCalledOnce()
+})
+```
diff --git a/examples/readme.md b/examples/readme.md
new file mode 100644
index 0000000..e8c7e26
--- /dev/null
+++ b/examples/readme.md
@@ -0,0 +1,8 @@
+# `@testing-library/svelte` examples
+
+- [Basic](./basic)
+- [Events](./events)
+- [Snippets](./snippets)
+- [Contexts](./contexts)
+- [Binds](./binds)
+- [Deprecated Svelte 3 and 4 features](./deprecated)
diff --git a/examples/snippets/basic-snippet.svelte b/examples/snippets/basic-snippet.svelte
new file mode 100644
index 0000000..55e1f71
--- /dev/null
+++ b/examples/snippets/basic-snippet.svelte
@@ -0,0 +1,7 @@
+<script>
+  let { children } = $props()
+</script>
+
+<h1>
+  {@render children?.()}
+</h1>
diff --git a/examples/snippets/basic-snippet.test.js b/examples/snippets/basic-snippet.test.js
new file mode 100644
index 0000000..11cf95c
--- /dev/null
+++ b/examples/snippets/basic-snippet.test.js
@@ -0,0 +1,13 @@
+import { render, screen, within } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import SubjectTest from './basic-snippet.test.svelte'
+
+test('basic snippet', () => {
+  render(SubjectTest)
+
+  const heading = screen.getByRole('heading')
+  const child = within(heading).getByTestId('child')
+
+  expect(child).toBeInTheDocument()
+})
diff --git a/examples/snippets/basic-snippet.test.svelte b/examples/snippets/basic-snippet.test.svelte
new file mode 100644
index 0000000..e96cac7
--- /dev/null
+++ b/examples/snippets/basic-snippet.test.svelte
@@ -0,0 +1,7 @@
+<script>
+  import Subject from './basic-snippet.svelte'
+</script>
+
+<Subject>
+  <span data-testid="child"></span>
+</Subject>
diff --git a/examples/snippets/complex-snippet.svelte b/examples/snippets/complex-snippet.svelte
new file mode 100644
index 0000000..81e8a70
--- /dev/null
+++ b/examples/snippets/complex-snippet.svelte
@@ -0,0 +1,9 @@
+<script>
+  let { name, message } = $props()
+
+  const greeting = $derived(`Hello, ${name}!`)
+</script>
+
+<p>
+  {@render message?.(greeting)}
+</p>
diff --git a/examples/snippets/complex-snippet.test.js b/examples/snippets/complex-snippet.test.js
new file mode 100644
index 0000000..55885ef
--- /dev/null
+++ b/examples/snippets/complex-snippet.test.js
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/svelte'
+import { createRawSnippet } from 'svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './complex-snippet.svelte'
+
+test('renders greeting in message snippet', () => {
+  render(Subject, {
+    name: 'Alice',
+    message: createRawSnippet((greeting) => ({
+      render: () => `<span data-testid="message">${greeting()}</span>`,
+    })),
+  })
+
+  const message = screen.getByTestId('message')
+
+  expect(message).toHaveTextContent('Hello, Alice!')
+})
diff --git a/examples/snippets/readme.md b/examples/snippets/readme.md
new file mode 100644
index 0000000..30a70d6
--- /dev/null
+++ b/examples/snippets/readme.md
@@ -0,0 +1,108 @@
+# Snippets
+
+Snippets are difficult to test directly. It's usually easier to structure your
+code so that you can test the user-facing results, leaving any snippets as an
+implementation detail. However, if snippets are an important developer-facing
+API of your component, there are several strategies you can use.
+
+## Table of contents
+
+- [Basic snippets example](#basic-snippets-example)
+  - [`basic-snippet.svelte`](#basic-snippetsvelte)
+  - [`basic-snippet.test.svelte`](#basic-snippettestsvelte)
+  - [`basic-snippet.test.js`](#basic-snippettestjs)
+- [Using `createRawSnippet`](#using-createrawsnippet)
+  - [`complex-snippet.svelte`](#complex-snippetsvelte)
+  - [`complex-snippet.test.js`](#complex-snippettestjs)
+
+## Basic snippets example
+
+For simple snippets, you can use a wrapper component and "dummy" children to
+test them. Setting `data-testid` attributes can be helpful when testing slots in
+this manner.
+
+### `basic-snippet.svelte`
+
+```svelte file=./basic-snippet.svelte
+<script>
+  let { children } = $props()
+</script>
+
+<h1>
+  {@render children?.()}
+</h1>
+```
+
+### `basic-snippet.test.svelte`
+
+```svelte file=./basic-snippet.test.svelte
+<script>
+  import Subject from './basic-snippet.svelte'
+</script>
+
+<Subject>
+  <span data-testid="child"></span>
+</Subject>
+```
+
+### `basic-snippet.test.js`
+
+```js file=./basic-snippet.test.js
+import { render, screen, within } from '@testing-library/svelte'
+import { expect, test } from 'vitest'
+
+import SubjectTest from './basic-snippet.test.svelte'
+
+test('basic snippet', () => {
+  render(SubjectTest)
+
+  const heading = screen.getByRole('heading')
+  const child = within(heading).getByTestId('child')
+
+  expect(child).toBeInTheDocument()
+})
+```
+
+## Using `createRawSnippet`
+
+For more complex snippets, e.g. where you want to check arguments, you can use
+Svelte's [createRawSnippet][] API.
+
+[createRawSnippet]: https://svelte.dev/docs/svelte/svelte#createRawSnippet
+
+### `complex-snippet.svelte`
+
+```svelte file=./complex-snippet.svelte
+<script>
+  let { name, message } = $props()
+
+  const greeting = $derived(`Hello, ${name}!`)
+</script>
+
+<p>
+  {@render message?.(greeting)}
+</p>
+```
+
+### `complex-snippet.test.js`
+
+```js file=./complex-snippet.test.js
+import { render, screen } from '@testing-library/svelte'
+import { createRawSnippet } from 'svelte'
+import { expect, test } from 'vitest'
+
+import Subject from './complex-snippet.svelte'
+
+test('renders greeting in message snippet', () => {
+  render(Subject, {
+    name: 'Alice',
+    message: createRawSnippet((greeting) => ({
+      render: () => `<span data-testid="message">${greeting()}</span>`,
+    })),
+  })
+
+  const message = screen.getByTestId('message')
+
+  expect(message).toHaveTextContent('Hello, Alice!')
+})
+```
diff --git a/jest.config.js b/jest.config.js
index 4857b64..f4daad1 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,26 +1,28 @@
 import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
 
 const SVELTE_TRANSFORM_PATTERN =
-  SVELTE_VERSION >= '5' ? '^.+\\.svelte(?:\\.js)?$' : '^.+\\.svelte$'
+  SVELTE_VERSION >= '5'
+    ? String.raw`^.+\.svelte(?:\.js)?$`
+    : String.raw`^.+\.svelte$`
 
 export default {
-  testMatch: ['<rootDir>/src/__tests__/**/*.test.js'],
+  testMatch: ['<rootDir>/tests/**/*.test.js'],
   transform: {
     [SVELTE_TRANSFORM_PATTERN]: 'svelte-jester',
   },
   moduleFileExtensions: ['js', 'svelte'],
   extensionsToTreatAsEsm: ['.svelte'],
   testEnvironment: 'jsdom',
-  setupFilesAfterEnv: ['<rootDir>/src/__tests__/_jest-setup.js'],
-  injectGlobals: false,
+  setupFilesAfterEnv: ['<rootDir>/tests/_jest-setup.js'],
+  injectGlobals: true,
   moduleNameMapper: {
-    '^vitest$': '<rootDir>/src/__tests__/_jest-vitest-alias.js',
+    '^vitest$': '<rootDir>/tests/_jest-vitest-alias.js',
+    [String.raw`^@testing-library\/svelte$`]: '<rootDir>/src/index.js',
   },
   resetMocks: true,
   restoreMocks: true,
   collectCoverageFrom: ['<rootDir>/src/**/*'],
   coveragePathIgnorePatterns: [
-    '/__tests__/',
     '<rootDir>/src/vite.js',
     '<rootDir>/src/vitest.js',
   ],
diff --git a/package.json b/package.json
index c4924f5..14e8cf9 100644
--- a/package.json
+++ b/package.json
@@ -49,31 +49,30 @@
   ],
   "files": [
     "src",
-    "types",
-    "!__tests__"
+    "types"
   ],
   "scripts": {
-    "toc": "doctoc README.md",
+    "all": "npm-run-all contributors:generate docs format types build test:vitest:* test:jest test:examples",
+    "all:legacy": "npm-run-all types:legacy test:vitest:* test:jest",
+    "docs": "remark --output --use remark-toc --use remark-code-import --use unified-prettier README.md examples",
     "lint": "prettier . --check && eslint .",
-    "lint:delta": "npm-run-all -p prettier:delta eslint:delta",
-    "prettier:delta": "prettier --check `./scripts/changed-files`",
-    "eslint:delta": "eslint `./scripts/changed-files`",
     "format": "prettier . --write && eslint . --fix",
-    "format:delta": "npm-run-all format:prettier:delta format:eslint:delta",
-    "format:prettier:delta": "prettier --write `./scripts/changed-files`",
-    "format:eslint:delta": "eslint --fix `./scripts/changed-files`",
-    "setup": "npm install && npm run validate",
+    "setup": "npm run install:5 && npm run all",
     "test": "vitest run --coverage",
     "test:watch": "vitest",
-    "test:vitest:jsdom": "vitest run --coverage --environment jsdom",
-    "test:vitest:happy-dom": "vitest run --coverage --environment happy-dom",
+    "test:vitest:jsdom": "vitest run tests --coverage --environment jsdom",
+    "test:vitest:happy-dom": "vitest run tests --coverage --environment happy-dom",
     "test:jest": "npx --node-options=\"--experimental-vm-modules --no-warnings\" jest --coverage",
+    "test:examples": "vitest run examples --coverage",
     "types": "svelte-check",
-    "validate": "npm-run-all test:vitest:* test:jest types build",
-    "build": "tsc -p tsconfig.build.json",
+    "types:legacy": "svelte-check --tsconfig tsconfig.legacy.json",
+    "build": "tsc -p tsconfig.build.json && cp src/component-types.d.ts types",
     "contributors:add": "all-contributors add",
     "contributors:generate": "all-contributors generate",
-    "preview-release": "./scripts/preview-release"
+    "preview-release": "./scripts/preview-release",
+    "install:3": "./scripts/install-dependencies 3",
+    "install:4": "./scripts/install-dependencies 4",
+    "install:5": "./scripts/install-dependencies 5"
   },
   "peerDependencies": {
     "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
@@ -89,41 +88,45 @@
     }
   },
   "dependencies": {
-    "@testing-library/dom": "^10.0.0"
+    "@testing-library/dom": "9.x.x || 10.x.x"
   },
   "devDependencies": {
+    "@eslint/js": "^9.26.0",
     "@jest/globals": "^29.7.0",
-    "@sveltejs/vite-plugin-svelte": "^3.1.1",
-    "@testing-library/jest-dom": "^6.3.0",
-    "@testing-library/user-event": "^14.5.2",
-    "@typescript-eslint/eslint-plugin": "7.15.0",
-    "@typescript-eslint/parser": "7.15.0",
-    "@vitest/coverage-v8": "^1.5.2",
+    "@sveltejs/vite-plugin-svelte": "^5.0.3",
+    "@testing-library/jest-dom": "^6.6.3",
+    "@testing-library/user-event": "^14.6.1",
+    "@vitest/coverage-v8": "^3.1.3",
+    "@vitest/eslint-plugin": "^1.1.44",
     "all-contributors-cli": "^6.26.1",
-    "doctoc": "^2.2.1",
-    "eslint": "8.57.0",
-    "eslint-config-prettier": "9.1.0",
-    "eslint-config-standard": "17.1.0",
-    "eslint-plugin-import": "2.29.1",
-    "eslint-plugin-json-files": "^4.1.0",
-    "eslint-plugin-n": "16.6.2",
-    "eslint-plugin-promise": "6.4.0",
-    "eslint-plugin-simple-import-sort": "12.1.0",
-    "eslint-plugin-svelte": "2.41.0",
-    "eslint-plugin-vitest-globals": "1.5.0",
-    "expect-type": "^0.19.0",
-    "happy-dom": "^14.7.1",
+    "eslint": "^9.26.0",
+    "eslint-config-prettier": "^10.1.5",
+    "eslint-plugin-jest-dom": "^5.5.0",
+    "eslint-plugin-promise": "^7.2.1",
+    "eslint-plugin-simple-import-sort": "^12.1.1",
+    "eslint-plugin-svelte": "^3.5.1",
+    "eslint-plugin-testing-library": "^7.1.1",
+    "eslint-plugin-unicorn": "^59.0.1",
+    "expect-type": "^1.2.1",
+    "globals": "^16.1.0",
+    "happy-dom": "^17.4.6",
     "jest": "^29.7.0",
     "jest-environment-jsdom": "^29.7.0",
-    "jsdom": "^24.0.0",
+    "jsdom": "^26.1.0",
     "npm-run-all": "^4.1.5",
-    "prettier": "3.3.2",
-    "prettier-plugin-svelte": "3.2.5",
-    "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
-    "svelte-check": "^3.6.3",
+    "prettier": "^3.5.3",
+    "prettier-plugin-svelte": "^3.3.3",
+    "remark-cli": "^12.0.1",
+    "remark-code-import": "^1.2.0",
+    "remark-toc": "^9.0.0",
+    "svelte": "^5.28.2",
+    "svelte-check": "^4.1.7",
     "svelte-jester": "^5.0.0",
-    "typescript": "^5.3.3",
-    "vite": "^5.1.1",
-    "vitest": "^1.5.2"
+    "typescript": "^5.8.3",
+    "typescript-eslint": "^8.32.0",
+    "typescript-svelte-plugin": "^0.3.46",
+    "unified-prettier": "^2.0.1",
+    "vite": "^6.3.5",
+    "vitest": "^3.1.3"
   }
 }
diff --git a/prettier.config.js b/prettier.config.js
new file mode 100644
index 0000000..55343f2
--- /dev/null
+++ b/prettier.config.js
@@ -0,0 +1,21 @@
+export default {
+  semi: false,
+  singleQuote: true,
+  trailingComma: 'es5',
+  plugins: ['prettier-plugin-svelte'],
+  overrides: [
+    {
+      files: '*.svelte',
+      options: {
+        parser: 'svelte',
+      },
+    },
+    {
+      files: 'examples/**/*.md',
+      options: {
+        printWidth: 80,
+        proseWrap: 'always',
+      },
+    },
+  ],
+}
diff --git a/scripts/changed-files b/scripts/changed-files
deleted file mode 100755
index 1275bd8..0000000
--- a/scripts/changed-files
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/usr/bin/env bash
-
-git diff --name-only --diff-filter=d main
diff --git a/scripts/install-dependencies b/scripts/install-dependencies
new file mode 100755
index 0000000..aef6257
--- /dev/null
+++ b/scripts/install-dependencies
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Install dependencies for a given version of Svelte
+set -euxo pipefail
+
+svelte_version="${1-}"
+node_version=$(node --version | sed 's/^v\([0-9]*\).*/\1/')
+env_dir="tests/envs/svelte$svelte_version"
+env_dir_by_node="$env_dir/node$node_version"
+
+if [[ -d $env_dir_by_node ]]; then
+  env_dir="$env_dir_by_node"
+fi
+
+if [[ "$svelte_version" == "5" ]]; then
+  rm -rf coverage node_modules
+  npm install
+  exit 0
+fi
+
+if [[ -z "$svelte_version" ]]; then
+  echo "Invalid usage: missing Svelte version" >&2;
+  exit 1
+fi
+
+if [[ ! -d "$env_dir" ]]; then
+  echo "Error: package.json for Svelte $svelte_version, Node $node_version not found" 1>&2
+  exit 2
+fi
+
+rm -rf coverage node_modules "$env_dir/node_modules"
+pushd "$env_dir"
+npm install --no-package-lock --engine-strict
+npm ls "$env_dir" svelte
+popd
+mv "$env_dir/node_modules" .
diff --git a/src/__tests__/_jest-setup.js b/src/__tests__/_jest-setup.js
deleted file mode 100644
index d1c255c..0000000
--- a/src/__tests__/_jest-setup.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import '@testing-library/jest-dom/jest-globals'
-
-import { afterEach } from '@jest/globals'
-import { act, cleanup } from '@testing-library/svelte'
-
-afterEach(async () => {
-  await act()
-  cleanup()
-})
diff --git a/src/__tests__/act.test.js b/src/__tests__/act.test.js
deleted file mode 100644
index 75c9ded..0000000
--- a/src/__tests__/act.test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { setTimeout } from 'node:timers/promises'
-
-import { act, render } from '@testing-library/svelte'
-import { describe, expect, test } from 'vitest'
-
-import Comp from './fixtures/Comp.svelte'
-
-describe('act', () => {
-  test('state updates are flushed', async () => {
-    const { getByText } = render(Comp)
-    const button = getByText('Button')
-
-    expect(button).toHaveTextContent('Button')
-
-    await act(() => {
-      button.click()
-    })
-
-    expect(button).toHaveTextContent('Button Clicked')
-  })
-
-  test('accepts async functions', async () => {
-    const { getByText } = render(Comp)
-    const button = getByText('Button')
-
-    await act(async () => {
-      await setTimeout(100)
-      button.click()
-    })
-
-    expect(button).toHaveTextContent('Button Clicked')
-  })
-})
diff --git a/src/__tests__/events.test.js b/src/__tests__/events.test.js
deleted file mode 100644
index e0aba4d..0000000
--- a/src/__tests__/events.test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { fireEvent, render } from '@testing-library/svelte'
-import { describe, expect, test } from 'vitest'
-
-import Comp from './fixtures/Comp.svelte'
-
-describe('events', () => {
-  test('state changes are flushed after firing an event', async () => {
-    const { getByText } = render(Comp, { props: { name: 'World' } })
-    const button = getByText('Button')
-
-    const result = fireEvent.click(button)
-
-    await expect(result).resolves.toBe(true)
-    expect(button).toHaveTextContent('Button Clicked')
-  })
-
-  test('calling `fireEvent` directly works too', async () => {
-    const { getByText } = render(Comp, { props: { name: 'World' } })
-    const button = getByText('Button')
-
-    const result = fireEvent(
-      button,
-      new MouseEvent('click', {
-        bubbles: true,
-        cancelable: true,
-      })
-    )
-
-    await expect(result).resolves.toBe(true)
-    expect(button).toHaveTextContent('Button Clicked')
-  })
-})
diff --git a/src/__tests__/fixtures/Simple.svelte b/src/__tests__/fixtures/Simple.svelte
deleted file mode 100644
index c9c2f15..0000000
--- a/src/__tests__/fixtures/Simple.svelte
+++ /dev/null
@@ -1,7 +0,0 @@
-<script lang="ts">
-  export let name: string
-  export let count: number
-</script>
-
-<h1>hello {name}</h1>
-<p>count: {count}</p>
diff --git a/src/__tests__/types.test-d.ts b/src/__tests__/types.test-d.ts
deleted file mode 100644
index 9927a05..0000000
--- a/src/__tests__/types.test-d.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { expectTypeOf } from 'expect-type'
-import type { ComponentProps, SvelteComponent } from 'svelte'
-import { describe, test } from 'vitest'
-
-import * as subject from '../index.js'
-import Simple from './fixtures/Simple.svelte'
-
-describe('types', () => {
-  test('render is a function that accepts a Svelte component', () => {
-    subject.render(Simple, { name: 'Alice', count: 42 })
-    subject.render(Simple, { props: { name: 'Alice', count: 42 } })
-  })
-
-  test('rerender is a function that accepts partial props', async () => {
-    const { rerender } = subject.render(Simple, { name: 'Alice', count: 42 })
-
-    await rerender({ name: 'Bob' })
-    await rerender({ count: 0 })
-  })
-
-  test('invalid prop types are rejected', () => {
-    // @ts-expect-error: name should be a string
-    subject.render(Simple, { name: 42 })
-
-    // @ts-expect-error: name should be a string
-    subject.render(Simple, { props: { name: 42 } })
-  })
-
-  test('render result has container and component', () => {
-    const result = subject.render(Simple, { name: 'Alice', count: 42 })
-
-    expectTypeOf(result).toMatchTypeOf<{
-      container: HTMLElement
-      component: SvelteComponent<{ name: string }>
-      debug: (el?: HTMLElement) => void
-      rerender: (props: Partial<ComponentProps<Simple>>) => Promise<void>
-      unmount: () => void
-    }>()
-  })
-
-  test('render result has default queries', () => {
-    const result = subject.render(Simple, { name: 'Alice', count: 42 })
-
-    expectTypeOf(result.getByRole).parameters.toMatchTypeOf<
-      [role: subject.ByRoleMatcher, options?: subject.ByRoleOptions]
-    >()
-  })
-
-  test('render result can have custom queries', () => {
-    const [getByVibes] = subject.buildQueries(
-      (_container: HTMLElement, vibes: string) => {
-        throw new Error(`unimplemented ${vibes}`)
-      },
-      () => '',
-      () => ''
-    )
-    const result = subject.render(
-      Simple,
-      { name: 'Alice', count: 42 },
-      { queries: { getByVibes } }
-    )
-
-    expectTypeOf(result.getByVibes).parameters.toMatchTypeOf<[vibes: string]>()
-  })
-
-  test('act is an async function', () => {
-    expectTypeOf(subject.act).toMatchTypeOf<() => Promise<void>>()
-  })
-
-  test('act accepts a sync function', () => {
-    expectTypeOf(subject.act).toMatchTypeOf<(fn: () => void) => Promise<void>>()
-  })
-
-  test('act accepts an async function', () => {
-    expectTypeOf(subject.act).toMatchTypeOf<
-      (fn: () => Promise<void>) => Promise<void>
-    >()
-  })
-
-  test('fireEvent is an async function', () => {
-    expectTypeOf(subject.fireEvent).toMatchTypeOf<
-      (
-        element: Element | Node | Document | Window,
-        event: Event
-      ) => Promise<boolean>
-    >()
-  })
-
-  test('fireEvent[eventName] is an async function', () => {
-    expectTypeOf(subject.fireEvent.click).toMatchTypeOf<
-      (
-        element: Element | Node | Document | Window,
-        options?: {}
-      ) => Promise<boolean>
-    >()
-  })
-})
diff --git a/src/component-types.d.ts b/src/component-types.d.ts
new file mode 100644
index 0000000..007f1e4
--- /dev/null
+++ b/src/component-types.d.ts
@@ -0,0 +1,61 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import type {
+  Component as ModernComponent,
+  ComponentConstructorOptions as LegacyConstructorOptions,
+  ComponentProps,
+  EventDispatcher,
+  mount,
+  SvelteComponent as LegacyComponent,
+  SvelteComponentTyped as Svelte3LegacyComponent,
+} from 'svelte'
+
+type IS_MODERN_SVELTE = ModernComponent extends (...args: any[]) => any
+  ? true
+  : false
+
+type IS_LEGACY_SVELTE_4 =
+  EventDispatcher<any> extends (...args: any[]) => any ? true : false
+
+/** A compiled, imported Svelte component. */
+export type Component<
+  P extends Record<string, any> = any,
+  E extends Record<string, any> = any,
+> = IS_MODERN_SVELTE extends true
+  ? ModernComponent<P, E> | LegacyComponent<P>
+  : IS_LEGACY_SVELTE_4 extends true
+    ? LegacyComponent<P>
+    : Svelte3LegacyComponent<P>
+
+/**
+ * The type of an imported, compiled Svelte component.
+ *
+ * In Svelte 5, this distinction no longer matters.
+ * In Svelte 4, this is the Svelte component class constructor.
+ */
+export type ComponentType<C> = C extends LegacyComponent
+  ? new (...args: any[]) => C
+  : C
+
+/** The props of a component. */
+export type Props<C extends Component> = ComponentProps<C>
+
+/**
+ * The exported fields of a component.
+ *
+ * In Svelte 5, this is the set of variables marked as `export`'d.
+ * In Svelte 4, this is simply the instance of the component class.
+ */
+export type Exports<C> = IS_MODERN_SVELTE extends true
+  ? C extends ModernComponent<any, infer E>
+    ? E
+    : C & { $set: never; $on: never; $destroy: never }
+  : C
+
+/**
+ * Options that may be passed to `mount` when rendering the component.
+ *
+ * In Svelte 4, these are the options passed to the component constructor.
+ */
+export type MountOptions<C extends Component> = IS_MODERN_SVELTE extends true
+  ? Parameters<typeof mount<Props<C>, Exports<C>>>[1]
+  : LegacyConstructorOptions<Props<C>>
diff --git a/src/core/index.js b/src/core/index.js
index f4a40aa..9e41adf 100644
--- a/src/core/index.js
+++ b/src/core/index.js
@@ -7,10 +7,7 @@
  */
 import * as LegacyCore from './legacy.js'
 import * as ModernCore from './modern.svelte.js'
-import {
-  createValidateOptions,
-  UnknownSvelteOptionsError,
-} from './validate-options.js'
+import { createValidateOptions } from './validate-options.js'
 
 const { mount, unmount, updateProps, allowedOptions } =
   ModernCore.IS_MODERN_SVELTE ? ModernCore : LegacyCore
@@ -18,10 +15,5 @@ const { mount, unmount, updateProps, allowedOptions } =
 /** Validate component options. */
 const validateOptions = createValidateOptions(allowedOptions)
 
-export {
-  mount,
-  UnknownSvelteOptionsError,
-  unmount,
-  updateProps,
-  validateOptions,
-}
+export { mount, unmount, updateProps, validateOptions }
+export { UnknownSvelteOptionsError } from './validate-options.js'
diff --git a/src/core/modern.svelte.js b/src/core/modern.svelte.js
index 3da78b4..34893f5 100644
--- a/src/core/modern.svelte.js
+++ b/src/core/modern.svelte.js
@@ -26,6 +26,7 @@ const mount = (Component, options) => {
   const props = $state(options.props ?? {})
   const component = Svelte.mount(Component, { ...options, props })
 
+  Svelte.flushSync()
   propsByComponent.set(component, props)
 
   return component
@@ -34,7 +35,7 @@ const mount = (Component, options) => {
 /** Remove the component from the DOM. */
 const unmount = (component) => {
   propsByComponent.delete(component)
-  Svelte.unmount(component)
+  Svelte.flushSync(() => Svelte.unmount(component))
 }
 
 /**
diff --git a/src/index.js b/src/index.js
index 2704824..2c88a28 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,15 +1,22 @@
-/* eslint-disable import/export */
-import { act, cleanup } from './pure.js'
+import { act, cleanup, setup } from './pure.js'
 
-// If we're running in a test runner that supports afterEach
-// then we'll automatically run cleanup afterEach test
+// If we're running in a test runner that supports beforeEach/afterEach
+// we'll automatically run setup and cleanup before and after each test
 // this ensures that tests run in isolation from each other
 // if you don't like this then set the STL_SKIP_AUTO_CLEANUP env variable.
-if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) {
-  afterEach(async () => {
-    await act()
-    cleanup()
-  })
+if (typeof process !== 'undefined' && !process.env.STL_SKIP_AUTO_CLEANUP) {
+  if (typeof beforeEach === 'function') {
+    beforeEach(() => {
+      setup()
+    })
+  }
+
+  if (typeof afterEach === 'function') {
+    afterEach(async () => {
+      await act()
+      cleanup()
+    })
+  }
 }
 
 // export all base queries, screen, etc.
diff --git a/src/pure.js b/src/pure.js
index edb94b3..583d063 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -1,9 +1,11 @@
 import {
+  configure as configureDTL,
   fireEvent as baseFireEvent,
+  getConfig as getDTLConfig,
   getQueriesForElement,
   prettyDOM,
 } from '@testing-library/dom'
-import { tick } from 'svelte'
+import * as Svelte from 'svelte'
 
 import { mount, unmount, updateProps, validateOptions } from './core/index.js'
 
@@ -13,8 +15,8 @@ const componentCache = new Set()
 /**
  * Customize how Svelte renders the component.
  *
- * @template {import('svelte').SvelteComponent} C
- * @typedef {import('svelte').ComponentProps<C> | Partial<import('svelte').ComponentConstructorOptions<import('svelte').ComponentProps<C>>>} SvelteComponentOptions
+ * @template {import('./component-types.js').Component} C
+ * @typedef {import('./component-types.js').Props<C> | Partial<import('./component-types.js').MountOptions<C>>} SvelteComponentOptions
  */
 
 /**
@@ -30,15 +32,15 @@ const componentCache = new Set()
 /**
  * The rendered component and bound testing functions.
  *
- * @template {import('svelte').SvelteComponent} C
+ * @template {import('./component-types.js').Component} C
  * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries]
  *
  * @typedef {{
  *   container: HTMLElement
  *   baseElement: HTMLElement
- *   component: C
+ *   component: import('./component-types.js').Exports<C>
  *   debug: (el?: HTMLElement | DocumentFragment) => void
- *   rerender: (props: Partial<import('svelte').ComponentProps<C>>) => Promise<void>
+ *   rerender: (props: Partial<import('./component-types.js').Props<C>>) => Promise<void>
  *   unmount: () => void
  * } & {
  *   [P in keyof Q]: import('@testing-library/dom').BoundFunction<Q[P]>
@@ -48,10 +50,10 @@ const componentCache = new Set()
 /**
  * Render a component into the document.
  *
- * @template {import('svelte').SvelteComponent} C
+ * @template {import('./component-types.js').Component} C
  * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries]
  *
- * @param {import('svelte').ComponentType<C>} Component - The component to render.
+ * @param {import('./component-types.js').ComponentType<C>} Component - The component to render.
  * @param {SvelteComponentOptions<C>} options - Customize how Svelte renders the component.
  * @param {RenderOptions<Q>} renderOptions - Customize how Testing Library sets up the document and binds queries.
  * @returns {RenderResult<C, Q>} The rendered component and bound testing functions.
@@ -65,6 +67,7 @@ const render = (Component, options = {}, renderOptions = {}) => {
   const queries = getQueriesForElement(baseElement, renderOptions.queries)
 
   const target =
+    // eslint-disable-next-line unicorn/prefer-dom-node-append
     options.target ?? baseElement.appendChild(document.createElement('div'))
 
   targetCache.add(target)
@@ -93,7 +96,7 @@ const render = (Component, options = {}, renderOptions = {}) => {
       }
 
       updateProps(component, props)
-      await tick()
+      await Svelte.tick()
     },
     unmount: () => {
       cleanupComponent(component)
@@ -102,6 +105,33 @@ const render = (Component, options = {}, renderOptions = {}) => {
   }
 }
 
+/** @type {import('@testing-library/dom'.Config | undefined} */
+let originalDTLConfig
+
+/**
+ * Configure `@testing-library/dom` for usage with Svelte.
+ *
+ * Ensures events fired from `@testing-library/dom`
+ * and `@testing-library/user-event` wait for Svelte
+ * to flush changes to the DOM before proceeding.
+ */
+const setup = () => {
+  originalDTLConfig = getDTLConfig()
+
+  configureDTL({
+    asyncWrapper: act,
+    eventWrapper: Svelte.flushSync ?? ((cb) => cb()),
+  })
+}
+
+/** Reset dom-testing-library config. */
+const cleanupDTL = () => {
+  if (originalDTLConfig) {
+    configureDTL(originalDTLConfig)
+    originalDTLConfig = undefined
+  }
+}
+
 /** Remove a component from the component cache. */
 const cleanupComponent = (component) => {
   const inCache = componentCache.delete(component)
@@ -116,27 +146,35 @@ const cleanupTarget = (target) => {
   const inCache = targetCache.delete(target)
 
   if (inCache && target.parentNode === document.body) {
-    document.body.removeChild(target)
+    target.remove()
   }
 }
 
-/** Unmount all components and remove elements added to `<body>`. */
+/** Unmount components, remove elements added to `<body>`, and reset `@testing-library/dom`. */
 const cleanup = () => {
-  componentCache.forEach(cleanupComponent)
-  targetCache.forEach(cleanupTarget)
+  for (const component of componentCache) {
+    cleanupComponent(component)
+  }
+  for (const target of targetCache) {
+    cleanupTarget(target)
+  }
+  cleanupDTL()
 }
 
 /**
  * Call a function and wait for Svelte to flush pending changes.
  *
- * @param {() => unknown} [fn] - A function, which may be `async`, to call before flushing updates.
- * @returns {Promise<void>}
+ * @template T
+ * @param {(() => Promise<T>) | () => T} [fn] - A function, which may be `async`, to call before flushing updates.
+ * @returns {Promise<T>}
  */
 const act = async (fn) => {
+  let result
   if (fn) {
-    await fn()
+    result = await fn()
   }
-  return tick()
+  await Svelte.tick()
+  return result
 }
 
 /**
@@ -157,18 +195,10 @@ const act = async (fn) => {
  *
  * @type {FireFunction & FireObject}
  */
-const fireEvent = async (...args) => {
-  const event = baseFireEvent(...args)
-  await tick()
-  return event
-}
+const fireEvent = async (...args) => act(() => baseFireEvent(...args))
 
-Object.keys(baseFireEvent).forEach((key) => {
-  fireEvent[key] = async (...args) => {
-    const event = baseFireEvent[key](...args)
-    await tick()
-    return event
-  }
-})
+for (const [key, baseEvent] of Object.entries(baseFireEvent)) {
+  fireEvent[key] = async (...args) => act(() => baseEvent(...args))
+}
 
-export { act, cleanup, fireEvent, render }
+export { act, cleanup, fireEvent, render, setup }
diff --git a/src/vite.js b/src/vite.js
index 0062b89..b57c886 100644
--- a/src/vite.js
+++ b/src/vite.js
@@ -1,5 +1,5 @@
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
+import path from 'node:path'
+import url from 'node:url'
 
 /**
  * Vite plugin to configure @testing-library/svelte.
@@ -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)
+    }
   },
 })
 
@@ -45,8 +50,8 @@ const addBrowserCondition = (config) => {
   const browserConditionIndex = conditions.indexOf('browser')
 
   if (
-    nodeConditionIndex >= 0 &&
-    (nodeConditionIndex < browserConditionIndex || browserConditionIndex < 0)
+    nodeConditionIndex !== -1 &&
+    (nodeConditionIndex < browserConditionIndex || browserConditionIndex === -1)
   ) {
     conditions.splice(nodeConditionIndex, 0, 'browser')
   }
@@ -64,12 +69,55 @@ const addAutoCleanup = (config) => {
   const test = config.test ?? {}
   let setupFiles = test.setupFiles ?? []
 
+  if (test.globals) {
+    return
+  }
+
   if (typeof setupFiles === 'string') {
     setupFiles = [setupFiles]
   }
 
-  setupFiles.push(join(dirname(fileURLToPath(import.meta.url)), './vitest.js'))
+  setupFiles.push(
+    path.join(path.dirname(url.fileURLToPath(import.meta.url)), './vitest.js')
+  )
 
   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/src/vitest.js b/src/vitest.js
index 71977e6..ebbbb41 100644
--- a/src/vitest.js
+++ b/src/vitest.js
@@ -1,7 +1,11 @@
-import { act, cleanup } from '@testing-library/svelte'
-import { afterEach } from 'vitest'
+import { act, cleanup, setup } from '@testing-library/svelte'
+import { beforeEach } from 'vitest'
 
-afterEach(async () => {
-  await act()
-  cleanup()
+beforeEach(() => {
+  setup()
+
+  return async () => {
+    await act()
+    cleanup()
+  }
 })
diff --git a/src/__tests__/utils.js b/tests/_env.js
similarity index 79%
rename from src/__tests__/utils.js
rename to tests/_env.js
index 68be33c..96b30f4 100644
--- a/src/__tests__/utils.js
+++ b/tests/_env.js
@@ -1,9 +1,11 @@
 import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
 
-export const IS_JSDOM = window.navigator.userAgent.includes('jsdom')
+export const IS_JSDOM = globalThis.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/tests/_jest-setup.js b/tests/_jest-setup.js
new file mode 100644
index 0000000..2c9b6f7
--- /dev/null
+++ b/tests/_jest-setup.js
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/jest-globals'
diff --git a/src/__tests__/_jest-vitest-alias.js b/tests/_jest-vitest-alias.js
similarity index 81%
rename from src/__tests__/_jest-vitest-alias.js
rename to tests/_jest-vitest-alias.js
index 6628c80..a09c310 100644
--- a/src/__tests__/_jest-vitest-alias.js
+++ b/tests/_jest-vitest-alias.js
@@ -11,9 +11,10 @@ export {
   jest as vi,
 } from '@jest/globals'
 
-// Add support for describe.skipIf and test.skipIf
+// Add support for describe.skipIf, test.skipIf, and test.runIf
 describe.skipIf = (condition) => (condition ? describe.skip : describe)
 test.skipIf = (condition) => (condition ? test.skip : test)
+test.runIf = (condition) => (condition ? test : test.skip)
 
 // Add support for `stubGlobal`
 jest.stubGlobal = (property, stub) => {
diff --git a/src/__tests__/_vitest-setup.js b/tests/_vitest-setup.js
similarity index 100%
rename from src/__tests__/_vitest-setup.js
rename to tests/_vitest-setup.js
diff --git a/tests/act.test.js b/tests/act.test.js
new file mode 100644
index 0000000..c6caae2
--- /dev/null
+++ b/tests/act.test.js
@@ -0,0 +1,46 @@
+import { setTimeout } from 'node:timers/promises'
+
+import { act, render, screen } from '@testing-library/svelte'
+import { userEvent } from '@testing-library/user-event'
+import { describe, expect, test } from 'vitest'
+
+import Comp from './fixtures/Comp.svelte'
+
+describe('act', () => {
+  test('state updates are flushed', async () => {
+    render(Comp)
+    const button = screen.getByText('Button')
+
+    expect(button).toHaveTextContent('Button')
+
+    await act(() => {
+      // eslint-disable-next-line testing-library/no-node-access
+      button.click()
+    })
+
+    expect(button).toHaveTextContent('Button Clicked')
+  })
+
+  test('accepts async functions', async () => {
+    render(Comp)
+    const button = screen.getByText('Button')
+
+    await act(async () => {
+      await setTimeout(10)
+      // eslint-disable-next-line testing-library/no-node-access
+      button.click()
+    })
+
+    expect(button).toHaveTextContent('Button Clicked')
+  })
+
+  test('wires act into user-event', async () => {
+    const user = userEvent.setup()
+    render(Comp)
+    const button = screen.getByText('Button')
+
+    await user.click(button)
+
+    expect(button).toHaveTextContent('Button Clicked')
+  })
+})
diff --git a/src/__tests__/auto-cleanup.test.js b/tests/auto-cleanup.test.js
similarity index 60%
rename from src/__tests__/auto-cleanup.test.js
rename to tests/auto-cleanup.test.js
index 803001e..391146e 100644
--- a/src/__tests__/auto-cleanup.test.js
+++ b/tests/auto-cleanup.test.js
@@ -1,20 +1,28 @@
 import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
 
-const globalAfterEach = vi.fn()
+import { IS_JEST } from './_env.js'
+
+// TODO(mcous, 2024-12-08): clearing module cache and re-importing
+// in Jest breaks Svelte's environment checking heuristics.
+// Re-implement this test in a more accurate environment, without mocks.
+describe.skipIf(IS_JEST)('auto-cleanup', () => {
+  const globalBeforeEach = vi.fn()
+  const globalAfterEach = vi.fn()
 
-describe('auto-cleanup', () => {
   beforeEach(() => {
     vi.resetModules()
+    globalThis.beforeEach = globalBeforeEach
     globalThis.afterEach = globalAfterEach
   })
 
   afterEach(() => {
     delete process.env.STL_SKIP_AUTO_CLEANUP
+    delete globalThis.beforeEach
     delete globalThis.afterEach
   })
 
   test('calls afterEach with cleanup if globally defined', async () => {
-    const { render } = await import('../index.js')
+    const { render } = await import('@testing-library/svelte')
 
     expect(globalAfterEach).toHaveBeenCalledTimes(1)
     expect(globalAfterEach).toHaveBeenLastCalledWith(expect.any(Function))
@@ -30,8 +38,9 @@ describe('auto-cleanup', () => {
   test('does not call afterEach if process STL_SKIP_AUTO_CLEANUP is set', async () => {
     process.env.STL_SKIP_AUTO_CLEANUP = 'true'
 
-    await import('../index.js')
+    await import('@testing-library/svelte')
 
+    expect(globalBeforeEach).toHaveBeenCalledTimes(0)
     expect(globalAfterEach).toHaveBeenCalledTimes(0)
   })
 })
diff --git a/src/__tests__/cleanup.test.js b/tests/cleanup.test.js
similarity index 100%
rename from src/__tests__/cleanup.test.js
rename to tests/cleanup.test.js
diff --git a/src/__tests__/context.test.js b/tests/context.test.js
similarity index 62%
rename from src/__tests__/context.test.js
rename to tests/context.test.js
index da54b9d..e9d83eb 100644
--- a/src/__tests__/context.test.js
+++ b/tests/context.test.js
@@ -1,4 +1,4 @@
-import { render } from '@testing-library/svelte'
+import { render, screen } from '@testing-library/svelte'
 import { expect, test } from 'vitest'
 
 import Comp from './fixtures/Context.svelte'
@@ -6,9 +6,9 @@ import Comp from './fixtures/Context.svelte'
 test('can set a context', () => {
   const message = 'Got it'
 
-  const { getByText } = render(Comp, {
+  render(Comp, {
     context: new Map(Object.entries({ foo: { message } })),
   })
 
-  expect(getByText(message)).toBeTruthy()
+  expect(screen.getByText(message)).toBeInTheDocument()
 })
diff --git a/src/__tests__/debug.test.js b/tests/debug.test.js
similarity index 100%
rename from src/__tests__/debug.test.js
rename to tests/debug.test.js
diff --git a/tests/envs/svelte3/node16/package.json b/tests/envs/svelte3/node16/package.json
new file mode 100644
index 0000000..8acb921
--- /dev/null
+++ b/tests/envs/svelte3/node16/package.json
@@ -0,0 +1,24 @@
+{
+  "private": true,
+  "engines": {
+    "node": "16.x.x"
+  },
+  "devDependencies": {
+    "@sveltejs/vite-plugin-svelte": "2.x.x",
+    "@testing-library/dom": "9.x.x",
+    "@testing-library/jest-dom": "^6.6.3",
+    "@testing-library/user-event": "^14.6.1",
+    "@vitest/coverage-v8": "0.x.x",
+    "expect-type": "^1.2.1",
+    "happy-dom": "14.x.x",
+    "jest": "^29.7.0",
+    "jest-environment-jsdom": "^29.7.0",
+    "jsdom": "22.x.x",
+    "npm-run-all": "^4.1.5",
+    "svelte": "3.x.x",
+    "svelte-check": "3.x.x",
+    "svelte-jester": "3.x.x",
+    "vite": "4.x.x",
+    "vitest": "0.x.x"
+  }
+}
diff --git a/tests/envs/svelte3/package.json b/tests/envs/svelte3/package.json
new file mode 100644
index 0000000..d563d97
--- /dev/null
+++ b/tests/envs/svelte3/package.json
@@ -0,0 +1,24 @@
+{
+  "private": true,
+  "engines": {
+    "node": ">=18"
+  },
+  "devDependencies": {
+    "@sveltejs/vite-plugin-svelte": "2.x.x",
+    "@testing-library/dom": "^10.4.0",
+    "@testing-library/jest-dom": "^6.6.3",
+    "@testing-library/user-event": "^14.6.1",
+    "@vitest/coverage-v8": "0.x.x",
+    "expect-type": "^1.2.1",
+    "happy-dom": "^17.4.6",
+    "jest": "^29.7.0",
+    "jest-environment-jsdom": "^29.7.0",
+    "jsdom": "^26.1.0",
+    "npm-run-all": "^4.1.5",
+    "svelte": "3.x.x",
+    "svelte-check": "3.x.x",
+    "svelte-jester": "3.x.x",
+    "vite": "4.x.x",
+    "vitest": "0.x.x"
+  }
+}
diff --git a/tests/envs/svelte4/node16/package.json b/tests/envs/svelte4/node16/package.json
new file mode 100644
index 0000000..ce420a1
--- /dev/null
+++ b/tests/envs/svelte4/node16/package.json
@@ -0,0 +1,24 @@
+{
+  "private": true,
+  "engines": {
+    "node": "16.x.x"
+  },
+  "devDependencies": {
+    "@sveltejs/vite-plugin-svelte": "2.x.x",
+    "@testing-library/dom": "9.x.x",
+    "@testing-library/jest-dom": "^6.6.3",
+    "@testing-library/user-event": "^14.6.1",
+    "@vitest/coverage-v8": "0.x.x",
+    "expect-type": "^1.2.1",
+    "happy-dom": "14.x.x",
+    "jest": "^29.7.0",
+    "jest-environment-jsdom": "^29.7.0",
+    "jsdom": "22.x.x",
+    "npm-run-all": "^4.1.5",
+    "svelte": "4.x.x",
+    "svelte-check": "3.x.x",
+    "svelte-jester": "3.x.x",
+    "vite": "4.x.x",
+    "vitest": "0.x.x"
+  }
+}
diff --git a/tests/envs/svelte4/package.json b/tests/envs/svelte4/package.json
new file mode 100644
index 0000000..b90351d
--- /dev/null
+++ b/tests/envs/svelte4/package.json
@@ -0,0 +1,24 @@
+{
+  "private": true,
+  "engines": {
+    "node": ">=18"
+  },
+  "devDependencies": {
+    "@sveltejs/vite-plugin-svelte": "3.x.x",
+    "@testing-library/dom": "^10.4.0",
+    "@testing-library/jest-dom": "^6.6.3",
+    "@testing-library/user-event": "^14.6.1",
+    "@vitest/coverage-v8": "2.x.x",
+    "expect-type": "^1.2.1",
+    "happy-dom": "^17.4.6",
+    "jest": "^29.7.0",
+    "jest-environment-jsdom": "^29.7.0",
+    "jsdom": "^26.1.0",
+    "npm-run-all": "^4.1.5",
+    "svelte": "4.x.x",
+    "svelte-check": "^4.1.7",
+    "svelte-jester": "^5.0.0",
+    "vite": "5.x.x",
+    "vitest": "2.x.x"
+  }
+}
diff --git a/tests/events.test.js b/tests/events.test.js
new file mode 100644
index 0000000..db47d62
--- /dev/null
+++ b/tests/events.test.js
@@ -0,0 +1,44 @@
+import { fireEvent as fireEventDTL } from '@testing-library/dom'
+import { fireEvent, render, screen } from '@testing-library/svelte'
+import { describe, expect, test } from 'vitest'
+
+import { IS_SVELTE_5 } from './_env.js'
+import Comp from './fixtures/Comp.svelte'
+
+describe('events', () => {
+  test('state changes are flushed after firing an event', async () => {
+    render(Comp, { props: { name: 'World' } })
+    const button = screen.getByText('Button')
+
+    const result = fireEvent.click(button)
+
+    await expect(result).resolves.toBe(true)
+    expect(button).toHaveTextContent('Button Clicked')
+  })
+
+  test('calling `fireEvent` directly works too', async () => {
+    render(Comp, { props: { name: 'World' } })
+    const button = screen.getByText('Button')
+
+    const result = fireEvent(
+      button,
+      new MouseEvent('click', { bubbles: true, cancelable: true })
+    )
+
+    await expect(result).resolves.toBe(true)
+    expect(button).toHaveTextContent('Button Clicked')
+  })
+
+  test.runIf(IS_SVELTE_5)('state changes are flushed synchronously', () => {
+    render(Comp, { props: { name: 'World' } })
+    const button = screen.getByText('Button')
+
+    const result = fireEventDTL(
+      button,
+      new MouseEvent('click', { bubbles: true, cancelable: true })
+    )
+
+    expect(result).toBe(true)
+    expect(button).toHaveTextContent('Button Clicked')
+  })
+})
diff --git a/src/__tests__/fixtures/Comp.svelte b/tests/fixtures/Comp.svelte
similarity index 100%
rename from src/__tests__/fixtures/Comp.svelte
rename to tests/fixtures/Comp.svelte
diff --git a/src/__tests__/fixtures/CompRunes.svelte b/tests/fixtures/CompRunes.svelte
similarity index 100%
rename from src/__tests__/fixtures/CompRunes.svelte
rename to tests/fixtures/CompRunes.svelte
diff --git a/src/__tests__/fixtures/Context.svelte b/tests/fixtures/Context.svelte
similarity index 100%
rename from src/__tests__/fixtures/Context.svelte
rename to tests/fixtures/Context.svelte
diff --git a/src/__tests__/fixtures/Mounter.svelte b/tests/fixtures/Mounter.svelte
similarity index 91%
rename from src/__tests__/fixtures/Mounter.svelte
rename to tests/fixtures/Mounter.svelte
index 27205dd..68f72f9 100644
--- a/src/__tests__/fixtures/Mounter.svelte
+++ b/tests/fixtures/Mounter.svelte
@@ -16,4 +16,4 @@
   })
 </script>
 
-<button></button>
+<button>click me</button>
diff --git a/src/__tests__/fixtures/Transitioner.svelte b/tests/fixtures/Transitioner.svelte
similarity index 100%
rename from src/__tests__/fixtures/Transitioner.svelte
rename to tests/fixtures/Transitioner.svelte
diff --git a/tests/fixtures/Typed.svelte b/tests/fixtures/Typed.svelte
new file mode 100644
index 0000000..dad8e14
--- /dev/null
+++ b/tests/fixtures/Typed.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+  import { createEventDispatcher } from 'svelte'
+
+  export let name: string
+  export let count: number
+
+  export const hello = 'hello'
+
+  const dispatch = createEventDispatcher<{ greeting: string }>()
+</script>
+
+<h1>hello {name}</h1>
+<p>count: {count}</p>
+<button on:click={() => dispatch('greeting', 'hello')}>greet</button>
diff --git a/tests/fixtures/TypedRunes.svelte b/tests/fixtures/TypedRunes.svelte
new file mode 100644
index 0000000..0fb690b
--- /dev/null
+++ b/tests/fixtures/TypedRunes.svelte
@@ -0,0 +1,8 @@
+<script lang="ts">
+  const { name, count }: { name: string; count: number } = $props()
+
+  export const hello = 'hello'
+</script>
+
+<h1>hello {name}</h1>
+<p>count: {count}</p>
diff --git a/src/__tests__/mount.test.js b/tests/mount.test.js
similarity index 86%
rename from src/__tests__/mount.test.js
rename to tests/mount.test.js
index e25c429..df6351d 100644
--- a/src/__tests__/mount.test.js
+++ b/tests/mount.test.js
@@ -1,4 +1,4 @@
-import { act, render, screen } from '@testing-library/svelte'
+import { render, screen } from '@testing-library/svelte'
 import { describe, expect, test, vi } from 'vitest'
 
 import Mounter from './fixtures/Mounter.svelte'
@@ -14,20 +14,17 @@ describe('mount and destroy', () => {
     const content = screen.getByRole('button')
 
     expect(content).toBeInTheDocument()
-    await act()
     expect(onMounted).toHaveBeenCalledTimes(1)
   })
 
   test('component is destroyed', async () => {
     const { unmount } = renderSubject()
 
-    await act()
     unmount()
 
     const content = screen.queryByRole('button')
 
     expect(content).not.toBeInTheDocument()
-    await act()
     expect(onDestroyed).toHaveBeenCalledTimes(1)
   })
 })
diff --git a/src/__tests__/multi-base.test.js b/tests/multi-base.test.js
similarity index 100%
rename from src/__tests__/multi-base.test.js
rename to tests/multi-base.test.js
diff --git a/tests/render-runes.test-d.ts b/tests/render-runes.test-d.ts
new file mode 100644
index 0000000..1e42544
--- /dev/null
+++ b/tests/render-runes.test-d.ts
@@ -0,0 +1,69 @@
+import * as subject from '@testing-library/svelte'
+import { expectTypeOf } from 'expect-type'
+import { describe, test, vi } from 'vitest'
+
+import LegacyComponent from './fixtures/Typed.svelte'
+import Component from './fixtures/TypedRunes.svelte'
+
+describe('types', () => {
+  test('render is a function that accepts a Svelte component', () => {
+    subject.render(Component, { name: 'Alice', count: 42 })
+    subject.render(Component, { props: { name: 'Alice', count: 42 } })
+  })
+
+  test('rerender is a function that accepts partial props', async () => {
+    const { rerender } = subject.render(Component, { name: 'Alice', count: 42 })
+
+    await rerender({ name: 'Bob' })
+    await rerender({ count: 0 })
+  })
+
+  test('invalid prop types are rejected', () => {
+    // @ts-expect-error: name should be a string
+    subject.render(Component, { name: 42 })
+
+    // @ts-expect-error: name should be a string
+    subject.render(Component, { props: { name: 42 } })
+  })
+
+  test('render result has container and component', () => {
+    const result = subject.render(Component, { name: 'Alice', count: 42 })
+
+    expectTypeOf(result).toExtend<{
+      container: HTMLElement
+      component: { hello: string }
+      debug: (el?: HTMLElement) => void
+      rerender: (props: { name?: string; count?: number }) => Promise<void>
+      unmount: () => void
+    }>()
+  })
+})
+
+describe('legacy component types', () => {
+  test('render accepts events', () => {
+    const onGreeting = vi.fn()
+    subject.render(LegacyComponent, {
+      props: { name: 'Alice', count: 42 },
+      events: { greeting: onGreeting },
+    })
+  })
+
+  test('component $set and $on are not allowed', () => {
+    const onGreeting = vi.fn()
+    const { component } = subject.render(LegacyComponent, {
+      name: 'Alice',
+      count: 42,
+    })
+
+    expectTypeOf(component).toExtend<{ hello: string }>()
+
+    // @ts-expect-error: Svelte 5 mount does not return `$set`
+    component.$on('greeting', onGreeting)
+
+    // @ts-expect-error: Svelte 5 mount does not return `$set`
+    component.$set({ name: 'Bob' })
+
+    // @ts-expect-error: Svelte 5 mount does not return `$destroy`
+    component.$destroy()
+  })
+})
diff --git a/tests/render-utilities.test-d.ts b/tests/render-utilities.test-d.ts
new file mode 100644
index 0000000..c72f761
--- /dev/null
+++ b/tests/render-utilities.test-d.ts
@@ -0,0 +1,65 @@
+import * as subject from '@testing-library/svelte'
+import { expectTypeOf } from 'expect-type'
+import { describe, test } from 'vitest'
+
+import Component from './fixtures/Comp.svelte'
+
+describe('render query and utility types', () => {
+  test('render result has default queries', () => {
+    const result = subject.render(Component, { name: 'Alice' })
+
+    expectTypeOf(result.getByRole).parameters.toExtend<
+      [role: subject.ByRoleMatcher, options?: subject.ByRoleOptions]
+    >()
+  })
+
+  test('render result can have custom queries', () => {
+    const [getByVibes] = subject.buildQueries(
+      (_container: HTMLElement, vibes: string) => {
+        throw new Error(`unimplemented ${vibes}`)
+      },
+      () => '',
+      () => ''
+    )
+    const result = subject.render(
+      Component,
+      { name: 'Alice' },
+      { queries: { getByVibes } }
+    )
+
+    expectTypeOf(result.getByVibes).parameters.toExtend<[vibes: string]>()
+  })
+
+  test('act is an async function', () => {
+    expectTypeOf(subject.act).toExtend<() => Promise<void>>()
+  })
+
+  test('act accepts a sync function', () => {
+    expectTypeOf(subject.act).toExtend<(fn: () => void) => Promise<void>>()
+  })
+
+  test('act accepts an async function', () => {
+    expectTypeOf(subject.act).toExtend<
+      (fn: () => Promise<void>) => Promise<void>
+    >()
+  })
+
+  test('fireEvent is an async function', () => {
+    expectTypeOf(subject.fireEvent).toExtend<
+      (
+        element: Element | Node | Document | Window,
+        event: Event
+      ) => Promise<boolean>
+    >()
+  })
+
+  test('fireEvent[eventName] is an async function', () => {
+    expectTypeOf(subject.fireEvent.click).toExtend<
+      (
+        element: Element | Node | Document | Window,
+        // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+        options?: {}
+      ) => Promise<boolean>
+    >()
+  })
+})
diff --git a/tests/render.test-d.ts b/tests/render.test-d.ts
new file mode 100644
index 0000000..eb1c639
--- /dev/null
+++ b/tests/render.test-d.ts
@@ -0,0 +1,58 @@
+import * as subject from '@testing-library/svelte'
+import { expectTypeOf } from 'expect-type'
+import { ComponentProps } from 'svelte'
+import { describe, test } from 'vitest'
+
+import Component from './fixtures/Typed.svelte'
+
+describe('types', () => {
+  test('render is a function that accepts a Svelte component', () => {
+    subject.render(Component, { name: 'Alice', count: 42 })
+    subject.render(Component, { props: { name: 'Alice', count: 42 } })
+  })
+
+  test('rerender is a function that accepts partial props', async () => {
+    const { rerender } = subject.render(Component, { name: 'Alice', count: 42 })
+
+    await rerender({ name: 'Bob' })
+    await rerender({ count: 0 })
+  })
+
+  test('non-components are rejected', () => {
+    // eslint-disable-next-line @typescript-eslint/no-extraneous-class
+    class NotComponent {}
+
+    // @ts-expect-error: component should be a Svelte component
+    subject.render(NotComponent)
+  })
+
+  test('invalid prop types are rejected', () => {
+    // @ts-expect-error: name should be a string
+    subject.render(Component, { name: 42 })
+
+    // @ts-expect-error: name should be a string
+    subject.render(Component, { props: { name: 42 } })
+  })
+
+  test('render result has container and component', () => {
+    const result = subject.render(Component, { name: 'Alice', count: 42 })
+
+    expectTypeOf(result).toExtend<{
+      container: HTMLElement
+      component: { hello: string }
+      debug: (el?: HTMLElement) => void
+      rerender: (props: { name?: string; count?: number }) => Promise<void>
+      unmount: () => void
+    }>()
+  })
+
+  test('render function may be wrapped', () => {
+    const renderSubject = (props: ComponentProps<Component>) => {
+      return subject.render(Component, props)
+    }
+
+    renderSubject({ name: 'Alice', count: 42 })
+    // @ts-expect-error: name should be a string
+    renderSubject(Component, { name: 42 })
+  })
+})
diff --git a/src/__tests__/render.test.js b/tests/render.test.js
similarity index 76%
rename from src/__tests__/render.test.js
rename to tests/render.test.js
index f396751..19bf2c3 100644
--- a/src/__tests__/render.test.js
+++ b/tests/render.test.js
@@ -1,7 +1,7 @@
-import { render } from '@testing-library/svelte'
+import { render, screen } from '@testing-library/svelte'
 import { beforeAll, describe, expect, test } from 'vitest'
 
-import { COMPONENT_FIXTURES } from './utils.js'
+import { COMPONENT_FIXTURES } from './_env.js'
 
 describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => {
   const props = { name: 'World' }
@@ -12,14 +12,14 @@ describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => {
   })
 
   test('renders component into the document', () => {
-    const { getByText } = render(Comp, { props })
+    render(Comp, { props })
 
-    expect(getByText('Hello World!')).toBeInTheDocument()
+    expect(screen.getByText('Hello World!')).toBeInTheDocument()
   })
 
   test('accepts props directly', () => {
-    const { getByText } = render(Comp, props)
-    expect(getByText('Hello World!')).toBeInTheDocument()
+    render(Comp, props)
+    expect(screen.getByText('Hello World!')).toBeInTheDocument()
   })
 
   test('throws error when mixing svelte component options and props', () => {
@@ -35,8 +35,8 @@ describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => {
   })
 
   test('should return a container object wrapping the DOM of the rendered component', () => {
-    const { container, getByTestId } = render(Comp, props)
-    const firstElement = getByTestId('test')
+    const { container } = render(Comp, props)
+    const firstElement = screen.getByTestId('test')
 
     expect(container.firstChild).toBe(firstElement)
   })
@@ -73,17 +73,14 @@ describe.each(COMPONENT_FIXTURES)('render ($mode)', ({ component }) => {
     const baseElement = document.body
     const target = document.createElement('section')
     const anchor = document.createElement('div')
-    baseElement.appendChild(target)
-    target.appendChild(anchor)
+    baseElement.append(target)
+    target.append(anchor)
 
-    const { getByTestId } = render(
-      Comp,
-      { props, target, anchor },
-      { baseElement }
-    )
-    const firstElement = getByTestId('test')
+    render(Comp, { props, target, anchor }, { baseElement })
+    const firstElement = screen.getByTestId('test')
 
     expect(target.firstChild).toBe(firstElement)
+    // eslint-disable-next-line testing-library/no-node-access
     expect(target.lastChild).toBe(anchor)
   })
 })
diff --git a/src/__tests__/rerender.test.js b/tests/rerender.test.js
similarity index 88%
rename from src/__tests__/rerender.test.js
rename to tests/rerender.test.js
index 52acd0f..2fdff0d 100644
--- a/src/__tests__/rerender.test.js
+++ b/tests/rerender.test.js
@@ -1,7 +1,7 @@
 import { act, render, screen } from '@testing-library/svelte'
 import { beforeAll, describe, expect, test, vi } from 'vitest'
 
-import { COMPONENT_FIXTURES, IS_SVELTE_5, MODE_RUNES } from './utils.js'
+import { COMPONENT_FIXTURES, IS_SVELTE_5, MODE_RUNES } from './_env.js'
 
 describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => {
   let Comp
@@ -39,8 +39,8 @@ describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => {
       ? { name: 'World' }
       : { accessors: true, props: { name: 'World' } }
 
-    const { component, getByText } = render(Comp, componentOptions)
-    const element = getByText('Hello World!')
+    const { component } = render(Comp, componentOptions)
+    const element = screen.getByText('Hello World!')
 
     expect(element).toBeInTheDocument()
     expect(component.name).toBe('World')
diff --git a/src/__tests__/transition.test.js b/tests/transition.test.js
similarity index 76%
rename from src/__tests__/transition.test.js
rename to tests/transition.test.js
index a9c8e02..27b236e 100644
--- a/src/__tests__/transition.test.js
+++ b/tests/transition.test.js
@@ -2,16 +2,17 @@ import { render, screen, waitFor } from '@testing-library/svelte'
 import { userEvent } from '@testing-library/user-event'
 import { beforeEach, describe, expect, test, vi } from 'vitest'
 
+import { IS_JSDOM, IS_SVELTE_5 } from './_env.js'
 import Transitioner from './fixtures/Transitioner.svelte'
-import { IS_JSDOM, IS_SVELTE_5 } from './utils.js'
 
 describe.skipIf(IS_SVELTE_5)('transitions', () => {
-  beforeEach(() => {
-    if (!IS_JSDOM) return
-
-    const raf = (fn) => setTimeout(() => fn(new Date()), 16)
-    vi.stubGlobal('requestAnimationFrame', raf)
-  })
+  if (IS_JSDOM) {
+    beforeEach(() => {
+      vi.stubGlobal('requestAnimationFrame', (fn) =>
+        setTimeout(() => fn(new Date()), 16)
+      )
+    })
+  }
 
   test('on:introend', async () => {
     const user = userEvent.setup()
diff --git a/tests/vite-plugin.test.js b/tests/vite-plugin.test.js
new file mode 100644
index 0000000..92fc8f5
--- /dev/null
+++ b/tests/vite-plugin.test.js
@@ -0,0 +1,227 @@
+import { svelteTesting } from '@testing-library/svelte/vite'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { IS_JEST } from './_env.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,
+      },
+    })
+  })
+})
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 0baa218..bfc566b 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -8,5 +8,5 @@
     "rootDir": "src",
     "outDir": "types"
   },
-  "exclude": ["src/**/__tests__/**"]
+  "include": ["src"]
 }
diff --git a/tsconfig.json b/tsconfig.json
index f79cace..fda8aba 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,7 +5,12 @@
     "noEmit": true,
     "skipLibCheck": true,
     "strict": true,
-    "types": ["svelte", "vite/client", "vitest", "vitest/globals"]
+    "types": ["svelte", "vite/client", "vitest", "vitest/globals"],
+    "baseUrl": "./",
+    "paths": {
+      "@testing-library/svelte": ["./src"]
+    },
+    "plugins": [{ "name": "typescript-svelte-plugin" }]
   },
-  "include": ["src"]
+  "include": ["src", "tests"]
 }
diff --git a/tsconfig.legacy.json b/tsconfig.legacy.json
new file mode 100644
index 0000000..304d872
--- /dev/null
+++ b/tsconfig.legacy.json
@@ -0,0 +1,8 @@
+{
+  "extends": ["./tsconfig.json"],
+  "exclude": [
+    "tests/render-runes.test-d.ts",
+    "tests/fixtures/CompRunes.svelte",
+    "tests/fixtures/TypedRunes.svelte"
+  ]
+}
diff --git a/vite.config.js b/vite.config.js
index 76baf61..65e1ca7 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,20 +1,26 @@
+import { createRequire } from 'node:module'
+
 import { svelte } from '@sveltejs/vite-plugin-svelte'
+import { svelteTesting } from '@testing-library/svelte/vite'
 import { defineConfig } from 'vite'
 
-import { svelteTesting } from './src/vite.js'
+const require = createRequire(import.meta.url)
 
-// https://vitejs.dev/config/
 export default defineConfig({
-  plugins: [svelte(), svelteTesting()],
+  plugins: [svelte({ hot: false }), svelteTesting()],
   test: {
     environment: 'jsdom',
-    setupFiles: ['./src/__tests__/_vitest-setup.js'],
+    setupFiles: ['./tests/_vitest-setup.js'],
     mockReset: true,
     unstubGlobals: true,
+    unstubEnvs: true,
     coverage: {
       provider: 'v8',
       include: ['src/**/*'],
-      exclude: ['**/__tests__/**', 'src/vite.js', 'src/vitest.js'],
+    },
+    alias: {
+      '@testing-library/svelte/vite': require.resolve('./src/vite.js'),
+      '@testing-library/svelte': require.resolve('./src/index.js'),
     },
   },
 })
