Skip to content

fix: $destroy and createRoot are no more #328

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 24, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ This library has `peerDependencies` listings for `svelte >= 3`.
You may also be interested in installing `@testing-library/jest-dom` so you can use
[the custom jest matchers](https://github.com/testing-library/jest-dom).

### Svelte 5 support

If you are riding the bleeding edge of Svelte 5, you'll need to either
import from `@testing-library/svelte/svelte5` instead of `@testing-library/svelte`, or have your `vite.config.js` contains the following alias:

```
export default defineConfig(({ }) => ({
test: {
alias: {
'./pure.js': './svelte5.js'
}
},
}))
```

## Docs

See the [**docs**](https://testing-library.com/docs/svelte-testing-library/intro) over at the Testing Library website.
Expand Down
10 changes: 4 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
"description": "Simple and complete Svelte testing utilities that encourage good testing practices.",
"main": "src/index.js",
"exports": {
".": {
"types": "./types/index.d.ts",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, we need to make sure we don't drop this types export, or it'll break TS using more modern moduleResolution modes

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't the types key right below the exports take care of that, though?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, no. If TS sees "exports" in moduleResolute: nodenext or bundler and there's no exports[].types it'll get sad

See #228

"default": "./src/index.js"
},
".": "./src/index.js",
"./svelte5": "./src/svelte5-index.js",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a corresponding types esport for Svelte 5?

"./vitest": {
"default": "./src/vitest.js"
}
Expand Down Expand Up @@ -56,8 +54,8 @@
"test": "vitest run --coverage",
"test:watch": "vitest",
"test:update": "vitest run --update",
"test:vitest:jsdom": "vitest run --coverage --environment jsdom",
"test:vitest:happy-dom": "vitest run --coverage --environment happy-dom",
"test:vitest:jsdom": "VITEST_ENV=jsdom vitest run --coverage --environment jsdom",
"test:vitest:happy-dom": "VITEST_ENV=happy-dom vitest run --coverage --environment happy-dom",
"types": "svelte-check",
"validate": "npm-run-all test:vitest:* types",
"contributors:add": "all-contributors add",
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/cleanup.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test, vi } from 'vitest'
import { VERSION as SVELTE_VERSION } from 'svelte/compiler'

import { act, cleanup, render } from '..'
import Mounter from './fixtures/Mounter.svelte'
Expand All @@ -15,7 +16,7 @@ describe('cleanup', () => {
expect(document.body).toBeEmptyDOMElement()
})

test('cleanup unmounts component', async () => {
test.runIf(SVELTE_VERSION < '5')('cleanup unmounts component', async () => {
await act(renderSubject)
cleanup()

Expand Down
18 changes: 11 additions & 7 deletions src/__tests__/context.test.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { expect, test } from 'vitest'
import { VERSION as SVELTE_VERSION } from 'svelte/compiler'

import { render } from '..'
import Comp from './fixtures/Context.svelte'

test('can set a context', () => {
const message = 'Got it'
test.skipIf(SVELTE_VERSION >= '5' && process.env.VITEST_ENV == 'happy-dom')(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In another test I used the user-agent to test for jsdom vs happy-dom. It's ever-so-slightly janky, but I like it because it doesn't require adding more environment variables / setup. Using an environment variable makes it harder to use vitest directly, which I do with some frequency to noodle on stuff

Related, should we have a little __tests__/environment.ts helper file that exports IS_SVELTE_5, IS_HAPPY_DOM, IS_JSDOM, etc. to make these a little less repetitive?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we have a little tests/environment.ts helper file that exports IS_SVELTE_5, IS_HAPPY_DOM, IS_JSDOM, etc. to make these a little less repetitive?

Yeah, that would make a lot of sense.

'can set a context',
() => {
const message = 'Got it'

const { getByText } = render(Comp, {
context: new Map(Object.entries({ foo: { message } })),
})
const { getByText } = render(Comp, {
context: new Map(Object.entries({ foo: { message } })),
})

expect(getByText(message)).toBeTruthy()
})
expect(getByText(message)).toBeTruthy()
}
)
40 changes: 22 additions & 18 deletions src/__tests__/mount.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test, vi } from 'vitest'
import { VERSION as SVELTE_VERSION } from 'svelte/compiler'

import { act, render, screen } from '..'
import Mounter from './fixtures/Mounter.svelte'
Expand All @@ -7,27 +8,30 @@ const onMounted = vi.fn()
const onDestroyed = vi.fn()
const renderSubject = () => render(Mounter, { onMounted, onDestroyed })

describe('mount and destroy', () => {
test('component is mounted', async () => {
renderSubject()
describe.skipIf(SVELTE_VERSION >= '5' && process.env.VITEST_ENV == 'happy-dom')(
'mount and destroy',
() => {
test('component is mounted', async () => {
renderSubject()

const content = screen.getByRole('button')
const content = screen.getByRole('button')

expect(content).toBeInTheDocument()
await act()
expect(onMounted).toHaveBeenCalledOnce()
})
expect(content).toBeInTheDocument()
await act()
expect(onMounted).toHaveBeenCalledOnce()
})

test('component is destroyed', async () => {
const { unmount } = renderSubject()
test('component is destroyed', async () => {
const { unmount } = renderSubject()

await act()
unmount()
await act()
unmount()

const content = screen.queryByRole('button')
const content = screen.queryByRole('button')

expect(content).not.toBeInTheDocument()
await act()
expect(onDestroyed).toHaveBeenCalledOnce()
})
})
expect(content).not.toBeInTheDocument()
await act()
expect(onDestroyed).toHaveBeenCalledOnce()
})
}
)
2 changes: 1 addition & 1 deletion src/__tests__/render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe('render', () => {
})

test('correctly find component constructor on the default property', () => {
const { getByText } = render(CompDefault, { props: { name: 'World' } })
const { getByText } = stlRender(CompDefault, { props: { name: 'World' } })

expect(getByText('Hello World!')).toBeInTheDocument()
})
Expand Down
13 changes: 9 additions & 4 deletions src/pure.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import {
getQueriesForElement,
prettyDOM,
} from '@testing-library/dom'
import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
import * as Svelte from 'svelte'

const IS_SVELTE_5 = typeof Svelte.createRoot === 'function'
const IS_SVELTE_5 = /^5\./.test(SVELTE_VERSION)
const targetCache = new Set()
const componentCache = new Set()

if (IS_SVELTE_5)
console.warn('for Svelte 5, use `@testing-library/svelte/svelte5`')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I'm open to the separate entry points approach! That being said, I don't see anything in this PR that really justifies it. Is it a future-proofing thing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the previous comment, it's to deal with import from 'svelte/legacy'. We could probably dig out darker magic to do it another way, but I do feel there will be other things in Svelte 5 that will be easier if we just have different files implementing the same(ish) interface rather than having a file that tries to satisfy all versions at once.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doh, yeah duh, my bad, that makes total sense.


Aside: this makes me think dropping 3.0 in the next branch (in a separate PR to be responsible about it) will make things easier.

I'm not totally sure what to do about collecting multiple breaking changes without a prerelease version. Maybe we should switch over to an beta branch for breaking changes? Or consider switching next to be a prerelease in semantic-release.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside: this makes me think dropping 3.0 in the next branch (in a separate PR to be responsible about it) will make things easier.

What would we cut off?


const svelteComponentOptions = IS_SVELTE_5
? ['target', 'props', 'events', 'context', 'intro', 'recover']
: ['accessors', 'anchor', 'props', 'hydrate', 'intro', 'context']
Expand Down Expand Up @@ -54,9 +58,10 @@ const render = (
const renderComponent = (options) => {
options = { target, ...checkProps(options) }

const component = IS_SVELTE_5
? Svelte.createRoot(ComponentConstructor, options)
: new ComponentConstructor(options)
if (IS_SVELTE_5)
throw new Error('for Svelte 5, use `@testing-library/svelte/svelte5`')

const component = new ComponentConstructor(options)

componentCache.add(component)

Expand Down
15 changes: 15 additions & 0 deletions src/svelte5-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { act, cleanup } from './svelte5.js'

// If we're running in a test runner that supports afterEach
// then we'll automatically run cleanup afterEach test
// this ensures that tests run in isolation from each other
// if you don't like this then either import the `pure` module
// or set the STL_SKIP_AUTO_CLEANUP env variable to 'true'.
if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) {
afterEach(async () => {
await act()
cleanup()
})
}

export * from './svelte5.js'
145 changes: 145 additions & 0 deletions src/svelte5.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
Copy link
Collaborator

@mcous mcous Feb 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a lot of duplication for little payoff. If you want to have separate entry points, would it be better to re-export pure.js's exports and only override the public methods that changed?

Alternatively, can we move the split to an internal facade instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My development steps are

  1. Make it
  2. Make it work
  3. Make it pretty

This PR is at stage (2). all the functions that aren't different in svelte5.js should really be imported from pure.js. But first I was interested to see how deep the hole had to be. And then backfill and tidy up the garden again.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing, happy to defer to whatever approach you want to take. Just wanted to point out opportunities nip stuff in the bud LoC-wise

fireEvent as dtlFireEvent,
getQueriesForElement,
prettyDOM,
} from '@testing-library/dom'
import { VERSION as SVELTE_VERSION } from 'svelte/compiler'
import { createClassComponent as createComponentSvelte5 } from 'svelte/legacy'
import * as Svelte from 'svelte'

const IS_SVELTE_5 = /^5\./.test(SVELTE_VERSION)
const targetCache = new Set()
const componentCache = new Set()

const svelteComponentOptions = IS_SVELTE_5
? ['target', 'props', 'events', 'context', 'intro', 'recover']
: ['accessors', 'anchor', 'props', 'hydrate', 'intro', 'context']

const render = (
Component,
{ target, ...options } = {},
{ container, queries } = {}
) => {
container = container || document.body
target = target || container.appendChild(document.createElement('div'))
targetCache.add(target)

const ComponentConstructor = Component.default || Component

const checkProps = (options) => {
const isProps = !Object.keys(options).some((option) =>
svelteComponentOptions.includes(option)
)

// Check if any props and Svelte options were accidentally mixed.
if (!isProps) {
const unrecognizedOptions = Object.keys(options).filter(
(option) => !svelteComponentOptions.includes(option)
)

if (unrecognizedOptions.length > 0) {
throw Error(`
Unknown options were found [${unrecognizedOptions}]. This might happen if you've mixed
passing in props with Svelte options into the render function. Valid Svelte options
are [${svelteComponentOptions}]. You can either change the prop names, or pass in your
props for that component via the \`props\` option.\n\n
Eg: const { /** Results **/ } = render(MyComponent, { props: { /** props here **/ } })\n\n
`)
}

return options
}

return { props: options }
}

const renderComponent = (options) => {
options = { target, ...checkProps(options) }

const component = IS_SVELTE_5
? createComponentSvelte5({ component: ComponentConstructor, ...options })
: new ComponentConstructor(options)

componentCache.add(component)

// TODO(mcous, 2024-02-11): remove this behavior in the next major version
// It is unnecessary has no path to implementation in Svelte v5
if (!IS_SVELTE_5) {
component.$$.on_destroy.push(() => {
componentCache.delete(component)
})
}

return component
}

let component = renderComponent(options)

return {
container,
component,
debug: (el = container) => console.log(prettyDOM(el)),
rerender: async (props) => {
if (props.props) {
console.warn(
'rerender({ props: {...} }) deprecated, use rerender({...}) instead'
)
props = props.props
}
component.$set(props)
await Svelte.tick()
},
unmount: () => {
cleanupComponent(component)
},
...getQueriesForElement(container, queries),
}
}

const cleanupComponent = (component) => {
const inCache = componentCache.delete(component)

if (inCache) {
component.$destroy()
}
}

const cleanupTarget = (target) => {
const inCache = targetCache.delete(target)

if (inCache && target.parentNode === document.body) {
document.body.removeChild(target)
}
}

const cleanup = () => {
componentCache.forEach(cleanupComponent)
targetCache.forEach(cleanupTarget)
}

const act = async (fn) => {
if (fn) {
await fn()
}
return Svelte.tick()
}

const fireEvent = async (...args) => {
const event = dtlFireEvent(...args)
await Svelte.tick()
return event
}

Object.keys(dtlFireEvent).forEach((key) => {
fireEvent[key] = async (...args) => {
const event = dtlFireEvent[key](...args)
await Svelte.tick()
return event
}
})

/* eslint-disable import/export */

export * from '@testing-library/dom'

export { render, cleanup, fireEvent, act }
6 changes: 6 additions & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { defineConfig } from 'vite'
import { VERSION as SVELTE_VERSION } from 'svelte/compiler'

const alias = {}

if (SVELTE_VERSION >= '5') alias['./pure.js'] = './svelte5.js'

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
Expand All @@ -20,5 +25,6 @@ export default defineConfig(({ mode }) => ({
provider: 'v8',
include: ['src'],
},
alias,
},
}))