Skip to content

Commit 3f5fc85

Browse files
committed
feat(core): add public API for core
1 parent d64de4a commit 3f5fc85

File tree

10 files changed

+246
-129
lines changed

10 files changed

+246
-129
lines changed

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
"types": "./types/index.d.ts",
99
"default": "./src/index.js"
1010
},
11+
"./core": {
12+
"types": "./types/core/index.d.ts",
13+
"default": "./src/core/index.js"
14+
},
1115
"./svelte5": {
1216
"types": "./types/index.d.ts",
1317
"default": "./src/index.js"
@@ -53,7 +57,7 @@
5357
"!__tests__"
5458
],
5559
"scripts": {
56-
"toc": "doctoc README.md",
60+
"toc": "doctoc README.md src/core/README.md",
5761
"lint": "prettier . --check && eslint .",
5862
"lint:delta": "npm-run-all -p prettier:delta eslint:delta",
5963
"prettier:delta": "prettier --check `./scripts/changed-files`",

src/core/README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# @testing-library/svelte/core
2+
3+
Do you want to build your own Svelte testing library? You may want to use our rendering core, which abstracts away differences in Svelte versions to provide a simple API to render Svelte components into the document and clean them up afterwards
4+
5+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
6+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
7+
8+
- [Usage](#usage)
9+
- [API](#api)
10+
- [`prepareDocument`](#preparedocument)
11+
- [`mount`](#mount)
12+
- [`cleanup`](#cleanup)
13+
14+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
15+
16+
## Usage
17+
18+
```ts
19+
import { prepareDocument, mount, cleanup } from '@testing-library/svelte/core'
20+
import MyCoolComponent from './my-cool-component.svelte'
21+
22+
const { baseElement, target, options } = prepareDocument({ awesome: true })
23+
const { component, unmount, rerender } = mount(MyCoolComponent, options)
24+
25+
// later
26+
cleanup()
27+
```
28+
29+
## API
30+
31+
### `prepareDocument`
32+
33+
Validate options and prepare document elements for rendering.
34+
35+
```ts
36+
const { baseElement, target, options } = prepareDocument(propsOrOptions, renderOptions)
37+
```
38+
39+
| Argument | Type | Description |
40+
| ---------------- | ---------------------------------------- | --------------------------------------------------------------------- |
41+
| `propsOrOptions` | `Props` or partial [component options][] | The component's props, or options to pass to Svelte's client-side API |
42+
| `renderOptions` | `{ baseElement?: HTMLElement }` | customize `baseElement`; will be `document.body` if unspecified |
43+
44+
| Result | Type | Description |
45+
| ------------- | --------------------- | -------------------------------------------------------------------- |
46+
| `baseElement` | `HTMLElement` | The base element, `document.body` by default |
47+
| `target` | `HTMLElement` | The component's `target` element, a `<div>` by default |
48+
| `options` | [component options][] | Validated and normalized Svelte options to pass to `renderComponent` |
49+
50+
[component options]: https://svelte.dev/docs/client-side-component-api
51+
52+
### `mount`
53+
54+
Mount a Svelte component into the document.
55+
56+
```ts
57+
const { component, unmount, rerender } = mount(Component, options)
58+
```
59+
60+
| Argument | Type | Description |
61+
| ----------- | --------------------- | ---------------------------- |
62+
| `Component` | [Svelte component][] | An imported Svelte component |
63+
| `options` | [component options][] | Svelte component options |
64+
65+
| Result | Type | Description |
66+
| ----------- | ------------------------------------------ | -------------------------------------------------- |
67+
| `component` | [component instance][] | The component instance |
68+
| `unmount` | `() => void` | Unmount the component from the document |
69+
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props and wait for rerender |
70+
71+
[Svelte component]: https://svelte.dev/docs/svelte-components
72+
[component instance]: https://svelte.dev/docs/client-side-component-api
73+
74+
### `cleanup`
75+
76+
Cleanup rendered components and added elements. Call this when your tests are over.
77+
78+
```ts
79+
cleanup()
80+
```

src/core/cleanup.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/** @type {Map<unknown, () => void} */
2+
const itemsToClean = new Map()
3+
4+
/** Register an item for later cleanup. */
5+
const addItemToCleanup = (item, onCleanup) => {
6+
itemsToClean.set(item, onCleanup)
7+
}
8+
9+
/** Remove an individual item from cleanup without running its cleanup handler. */
10+
const removeItemFromCleanup = (item) => {
11+
itemsToClean.delete(item)
12+
}
13+
14+
/** Clean up an individual item. */
15+
const cleanupItem = (item) => {
16+
const handleCleanup = itemsToClean.get(item)
17+
handleCleanup?.()
18+
itemsToClean.delete(item)
19+
}
20+
21+
/** Clean up all components and elements added to the document. */
22+
const cleanup = () => {
23+
for (const handleCleanup of itemsToClean.values()) {
24+
handleCleanup()
25+
}
26+
27+
itemsToClean.clear()
28+
}
29+
30+
export { addItemToCleanup, cleanup, cleanupItem, removeItemFromCleanup }

src/core/index.js

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,6 @@
55
* Will switch to legacy, class-based mounting logic
66
* if it looks like we're in a Svelte <= 4 environment.
77
*/
8-
import * as LegacyCore from './legacy.js'
9-
import * as ModernCore from './modern.svelte.js'
10-
import {
11-
createValidateOptions,
12-
UnknownSvelteOptionsError,
13-
} from './validate-options.js'
14-
15-
const { mount, unmount, updateProps, allowedOptions } =
16-
ModernCore.IS_MODERN_SVELTE ? ModernCore : LegacyCore
17-
18-
/** Validate component options. */
19-
const validateOptions = createValidateOptions(allowedOptions)
20-
21-
export {
22-
mount,
23-
UnknownSvelteOptionsError,
24-
unmount,
25-
updateProps,
26-
validateOptions,
27-
}
8+
export { cleanup } from './cleanup.js'
9+
export { mount, prepareDocument } from './mount.js'
10+
export { UnknownSvelteOptionsError } from './validate-options.js'

src/core/legacy.js

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* Supports Svelte <= 4.
55
*/
66

7+
import { removeItemFromCleanup } from './cleanup.js'
8+
79
/** Allowed options for the component constructor. */
810
const allowedOptions = [
911
'target',
@@ -15,32 +17,27 @@ const allowedOptions = [
1517
'context',
1618
]
1719

18-
/**
19-
* Mount the component into the DOM.
20-
*
21-
* The `onDestroy` callback is included for strict backwards compatibility
22-
* with previous versions of this library. It's mostly unnecessary logic.
23-
*/
24-
const mount = (Component, options, onDestroy) => {
20+
/** Mount the component into the DOM. */
21+
const mountComponent = (Component, options) => {
2522
const component = new Component(options)
2623

27-
if (typeof onDestroy === 'function') {
28-
component.$$.on_destroy.push(() => {
29-
onDestroy(component)
30-
})
31-
}
24+
// This `$$.on_destroy` handler is included for strict backwards compatibility
25+
// with previous versions of this library. It's mostly unnecessary logic.
26+
component.$$.on_destroy.push(() => {
27+
removeItemFromCleanup(component)
28+
})
3229

33-
return component
34-
}
30+
/** Remove the component from the DOM. */
31+
const unmountComponent = () => {
32+
component.$destroy()
33+
}
3534

36-
/** Remove the component from the DOM. */
37-
const unmount = (component) => {
38-
component.$destroy()
39-
}
35+
/** Update the component's props. */
36+
const updateProps = (nextProps) => {
37+
component.$set(nextProps)
38+
}
4039

41-
/** Update the component's props. */
42-
const updateProps = (component, nextProps) => {
43-
component.$set(nextProps)
40+
return { component, unmountComponent, updateProps }
4441
}
4542

46-
export { allowedOptions, mount, unmount, updateProps }
43+
export { allowedOptions, mountComponent }

src/core/modern.svelte.js

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
*/
66
import * as Svelte from 'svelte'
77

8-
/** Props signals for each rendered component. */
9-
const propsByComponent = new Map()
10-
118
/** Whether we're using Svelte >= 5. */
129
const IS_MODERN_SVELTE = typeof Svelte.mount === 'function'
1310

@@ -22,29 +19,21 @@ const allowedOptions = [
2219
]
2320

2421
/** Mount the component into the DOM. */
25-
const mount = (Component, options) => {
22+
const mountComponent = (Component, options) => {
2623
const props = $state(options.props ?? {})
2724
const component = Svelte.mount(Component, { ...options, props })
2825

29-
propsByComponent.set(component, props)
30-
31-
return component
32-
}
26+
/** Remove the component from the DOM. */
27+
const unmountComponent = () => {
28+
Svelte.unmount(component)
29+
}
3330

34-
/** Remove the component from the DOM. */
35-
const unmount = (component) => {
36-
propsByComponent.delete(component)
37-
Svelte.unmount(component)
38-
}
31+
/** Update the component's props. */
32+
const updateProps = (nextProps) => {
33+
Object.assign(props, nextProps)
34+
}
3935

40-
/**
41-
* Update the component's props.
42-
*
43-
* Relies on the `$state` signal added in `mount`.
44-
*/
45-
const updateProps = (component, nextProps) => {
46-
const prevProps = propsByComponent.get(component)
47-
Object.assign(prevProps, nextProps)
36+
return { component, unmountComponent, updateProps }
4837
}
4938

50-
export { allowedOptions, IS_MODERN_SVELTE, mount, unmount, updateProps }
39+
export { allowedOptions, IS_MODERN_SVELTE, mountComponent }

src/core/mount.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { tick } from 'svelte'
2+
3+
import { addItemToCleanup, removeItemFromCleanup } from './cleanup.js'
4+
import * as LegacySvelte from './legacy.js'
5+
import * as ModernSvelte from './modern.svelte.js'
6+
import { validateOptions } from './validate-options.js'
7+
8+
const { mountComponent, allowedOptions } = ModernSvelte.IS_MODERN_SVELTE
9+
? ModernSvelte
10+
: LegacySvelte
11+
12+
/**
13+
* Validate options and prepare document elements for rendering.
14+
*
15+
* @template {import('svelte').SvelteComponent} C
16+
* @param {import('svelte').ComponentProps<C> | Partial<import('svelte').ComponentConstructorOptions<import('svelte').ComponentProps<C>>>} propsOrOptions
17+
* @param {{ baseElement?: HTMLElement }} renderOptions
18+
* @returns {{
19+
* baseElement: HTMLElement
20+
* target: HTMLElement
21+
* options: import('svelte').ComponentConstructorOptions<import('svelte').ComponentProps<C>>
22+
* }}
23+
*/
24+
const prepareDocument = (propsOrOptions = {}, renderOptions = {}) => {
25+
const options = validateOptions(allowedOptions, propsOrOptions)
26+
27+
const baseElement =
28+
renderOptions.baseElement ?? options.target ?? document.body
29+
30+
const target =
31+
options.target ?? baseElement.appendChild(document.createElement('div'))
32+
33+
addItemToCleanup(target, () => {
34+
if (target.parentNode === document.body) {
35+
document.body.removeChild(target)
36+
}
37+
})
38+
39+
return { baseElement, target, options: { ...options, target } }
40+
}
41+
42+
/**
43+
* Render a Svelte component into the document.
44+
*
45+
* @template {import('svelte').SvelteComponent} C
46+
* @param {import('svelte').ComponentType<C>} Component
47+
* @param {import('svelte').ComponentConstructorOptions<import('svelte').ComponentProps<C>>} options
48+
* @returns {{
49+
* component: C
50+
* rerender: (props: Partial<import('svelte').ComponentProps<C>>) => Promise<void>
51+
* unmount: () => void
52+
* }}
53+
*/
54+
const mount = (Component, options = {}) => {
55+
const { component, unmountComponent, updateProps } = mountComponent(
56+
Component,
57+
options
58+
)
59+
60+
const unmount = () => {
61+
unmountComponent()
62+
removeItemFromCleanup(component)
63+
}
64+
65+
const rerender = async (props) => {
66+
updateProps(props)
67+
await tick()
68+
}
69+
70+
addItemToCleanup(component, unmount)
71+
72+
return { component, unmount, rerender }
73+
}
74+
75+
export { mount, prepareDocument }

src/core/validate-options.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class UnknownSvelteOptionsError extends TypeError {
1515
}
1616
}
1717

18-
const createValidateOptions = (allowedOptions) => (options) => {
18+
const validateOptions = (allowedOptions, options) => {
1919
const isProps = !Object.keys(options).some((option) =>
2020
allowedOptions.includes(option)
2121
)
@@ -36,4 +36,4 @@ const createValidateOptions = (allowedOptions) => (options) => {
3636
return options
3737
}
3838

39-
export { createValidateOptions, UnknownSvelteOptionsError }
39+
export { UnknownSvelteOptionsError, validateOptions }

src/index.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable import/export */
2-
import { act, cleanup } from './pure.js'
2+
import { cleanup } from './core/index.js'
3+
import { act } from './pure.js'
34

45
// If we're running in a test runner that supports afterEach
56
// then we'll automatically run cleanup afterEach test
@@ -16,7 +17,7 @@ if (typeof afterEach === 'function' && !process.env.STL_SKIP_AUTO_CLEANUP) {
1617
export * from '@testing-library/dom'
1718

1819
// export svelte-specific functions and custom `fireEvent`
19-
export { UnknownSvelteOptionsError } from './core/index.js'
20-
export * from './pure.js'
2120
// `fireEvent` must be named to take priority over wildcard from @testing-library/dom
21+
export { cleanup, UnknownSvelteOptionsError } from './core/index.js'
2222
export { fireEvent } from './pure.js'
23+
export * from './pure.js'

0 commit comments

Comments
 (0)