Skip to content

feat(core): add public API for core #391

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

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all 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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"types": "./types/index.d.ts",
"default": "./src/index.js"
},
"./core": {
"types": "./types/core/index.d.ts",
"default": "./src/core/index.js"
},
"./svelte5": {
"types": "./types/index.d.ts",
"default": "./src/index.js"
Expand Down Expand Up @@ -53,7 +57,7 @@
"!__tests__"
],
"scripts": {
"toc": "doctoc README.md",
"toc": "doctoc README.md src/core/README.md",
"lint": "prettier . --check && eslint .",
"lint:delta": "npm-run-all -p prettier:delta eslint:delta",
"prettier:delta": "prettier --check `./scripts/changed-files`",
Expand Down
80 changes: 80 additions & 0 deletions src/core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# @testing-library/svelte/core

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

<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->

- [Usage](#usage)
- [API](#api)
- [`prepareDocument`](#preparedocument)
- [`mount`](#mount)
- [`cleanup`](#cleanup)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

## Usage

```ts
import { prepareDocument, mount, cleanup } from '@testing-library/svelte/core'
import MyCoolComponent from './my-cool-component.svelte'

const { baseElement, target, options } = prepareDocument({ awesome: true })
const { component, unmount, rerender } = mount(MyCoolComponent, options)

// later
cleanup()
```

## API

### `prepareDocument`

Validate options and prepare document elements for rendering.

```ts
const { baseElement, target, options } = prepareDocument(propsOrOptions, renderOptions)
```

| Argument | Type | Description |
| ---------------- | ---------------------------------------- | --------------------------------------------------------------------- |
| `propsOrOptions` | `Props` or partial [component options][] | The component's props, or options to pass to Svelte's client-side API |
| `renderOptions` | `{ baseElement?: HTMLElement }` | customize `baseElement`; will be `document.body` if unspecified |

| Result | Type | Description |
| ------------- | --------------------- | -------------------------------------------------------------------- |
| `baseElement` | `HTMLElement` | The base element, `document.body` by default |
| `target` | `HTMLElement` | The component's `target` element, a `<div>` by default |
| `options` | [component options][] | Validated and normalized Svelte options to pass to `renderComponent` |

[component options]: https://svelte.dev/docs/client-side-component-api

### `mount`

Mount a Svelte component into the document.

```ts
const { component, unmount, rerender } = mount(Component, options)
```

| Argument | Type | Description |
| ----------- | --------------------- | ---------------------------- |
| `Component` | [Svelte component][] | An imported Svelte component |
| `options` | [component options][] | Svelte component options |

| Result | Type | Description |
| ----------- | ------------------------------------------ | -------------------------------------------------- |
| `component` | [component instance][] | The component instance |
| `unmount` | `() => void` | Unmount the component from the document |
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props and wait for rerender |

[Svelte component]: https://svelte.dev/docs/svelte-components
[component instance]: https://svelte.dev/docs/client-side-component-api

### `cleanup`

Cleanup rendered components and added elements. Call this when your tests are over.

```ts
cleanup()
```
30 changes: 30 additions & 0 deletions src/core/cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/** @type {Map<unknown, () => void} */
const itemsToClean = new Map()

/** Register an item for later cleanup. */
const addItemToCleanup = (item, onCleanup) => {
itemsToClean.set(item, onCleanup)
}

/** Remove an individual item from cleanup without running its cleanup handler. */
const removeItemFromCleanup = (item) => {
itemsToClean.delete(item)
}

/** Clean up an individual item. */
const cleanupItem = (item) => {
const handleCleanup = itemsToClean.get(item)
handleCleanup?.()
itemsToClean.delete(item)
}

/** Clean up all components and elements added to the document. */
const cleanup = () => {
for (const handleCleanup of itemsToClean.values()) {
handleCleanup()
}

itemsToClean.clear()
}

export { addItemToCleanup, cleanup, cleanupItem, removeItemFromCleanup }
23 changes: 3 additions & 20 deletions src/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,6 @@
* Will switch to legacy, class-based mounting logic
* if it looks like we're in a Svelte <= 4 environment.
*/
import * as LegacyCore from './legacy.js'
import * as ModernCore from './modern.svelte.js'
import {
createValidateOptions,
UnknownSvelteOptionsError,
} from './validate-options.js'

const { mount, unmount, updateProps, allowedOptions } =
ModernCore.IS_MODERN_SVELTE ? ModernCore : LegacyCore

/** Validate component options. */
const validateOptions = createValidateOptions(allowedOptions)

export {
mount,
UnknownSvelteOptionsError,
unmount,
updateProps,
validateOptions,
}
export { cleanup } from './cleanup.js'
export { mount, prepareDocument } from './mount.js'
export { UnknownSvelteOptionsError } from './validate-options.js'
41 changes: 19 additions & 22 deletions src/core/legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Supports Svelte <= 4.
*/

import { removeItemFromCleanup } from './cleanup.js'

/** Allowed options for the component constructor. */
const allowedOptions = [
'target',
Expand All @@ -15,32 +17,27 @@ const allowedOptions = [
'context',
]

/**
* Mount the component into the DOM.
*
* The `onDestroy` callback is included for strict backwards compatibility
* with previous versions of this library. It's mostly unnecessary logic.
*/
const mount = (Component, options, onDestroy) => {
/** Mount the component into the DOM. */
const mountComponent = (Component, options) => {
const component = new Component(options)

if (typeof onDestroy === 'function') {
component.$$.on_destroy.push(() => {
onDestroy(component)
})
}
// This `$$.on_destroy` handler is included for strict backwards compatibility
// with previous versions of this library. It's mostly unnecessary logic.
component.$$.on_destroy.push(() => {
removeItemFromCleanup(component)
})

return component
}
/** Remove the component from the DOM. */
const unmountComponent = () => {
component.$destroy()
}

/** Remove the component from the DOM. */
const unmount = (component) => {
component.$destroy()
}
/** Update the component's props. */
const updateProps = (nextProps) => {
component.$set(nextProps)
}

/** Update the component's props. */
const updateProps = (component, nextProps) => {
component.$set(nextProps)
return { component, unmountComponent, updateProps }
}

export { allowedOptions, mount, unmount, updateProps }
export { allowedOptions, mountComponent }
33 changes: 11 additions & 22 deletions src/core/modern.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
*/
import * as Svelte from 'svelte'

/** Props signals for each rendered component. */
const propsByComponent = new Map()

/** Whether we're using Svelte >= 5. */
const IS_MODERN_SVELTE = typeof Svelte.mount === 'function'

Expand All @@ -22,29 +19,21 @@ const allowedOptions = [
]

/** Mount the component into the DOM. */
const mount = (Component, options) => {
const mountComponent = (Component, options) => {
const props = $state(options.props ?? {})
const component = Svelte.mount(Component, { ...options, props })

propsByComponent.set(component, props)

return component
}
/** Remove the component from the DOM. */
const unmountComponent = () => {
Svelte.unmount(component)
}

/** Remove the component from the DOM. */
const unmount = (component) => {
propsByComponent.delete(component)
Svelte.unmount(component)
}
/** Update the component's props. */
const updateProps = (nextProps) => {
Object.assign(props, nextProps)
}

/**
* Update the component's props.
*
* Relies on the `$state` signal added in `mount`.
*/
const updateProps = (component, nextProps) => {
const prevProps = propsByComponent.get(component)
Object.assign(prevProps, nextProps)
return { component, unmountComponent, updateProps }
}

export { allowedOptions, IS_MODERN_SVELTE, mount, unmount, updateProps }
export { allowedOptions, IS_MODERN_SVELTE, mountComponent }
75 changes: 75 additions & 0 deletions src/core/mount.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { tick } from 'svelte'

import { addItemToCleanup, removeItemFromCleanup } from './cleanup.js'
import * as LegacySvelte from './legacy.js'
import * as ModernSvelte from './modern.svelte.js'
import { validateOptions } from './validate-options.js'

const { mountComponent, allowedOptions } = ModernSvelte.IS_MODERN_SVELTE
? ModernSvelte
: LegacySvelte

/**
* Validate options and prepare document elements for rendering.
*
* @template {import('svelte').SvelteComponent} C
* @param {import('svelte').ComponentProps<C> | Partial<import('svelte').ComponentConstructorOptions<import('svelte').ComponentProps<C>>>} propsOrOptions
* @param {{ baseElement?: HTMLElement }} renderOptions
* @returns {{
* baseElement: HTMLElement
* target: HTMLElement
* options: import('svelte').ComponentConstructorOptions<import('svelte').ComponentProps<C>>
* }}
*/
const prepareDocument = (propsOrOptions = {}, renderOptions = {}) => {
const options = validateOptions(allowedOptions, propsOrOptions)

const baseElement =
renderOptions.baseElement ?? options.target ?? document.body

const target =
options.target ?? baseElement.appendChild(document.createElement('div'))

addItemToCleanup(target, () => {
if (target.parentNode === document.body) {
document.body.removeChild(target)
}
})

return { baseElement, target, options: { ...options, target } }
}

/**
* Render a Svelte component into the document.
*
* @template {import('svelte').SvelteComponent} C
* @param {import('svelte').ComponentType<C>} Component
* @param {import('svelte').ComponentConstructorOptions<import('svelte').ComponentProps<C>>} options
* @returns {{
* component: C
* rerender: (props: Partial<import('svelte').ComponentProps<C>>) => Promise<void>
* unmount: () => void
* }}
*/
const mount = (Component, options = {}) => {
const { component, unmountComponent, updateProps } = mountComponent(
Component,
options
)

const unmount = () => {
unmountComponent()
removeItemFromCleanup(component)
}

const rerender = async (props) => {
updateProps(props)
await tick()
}

addItemToCleanup(component, unmount)

return { component, unmount, rerender }
}

export { mount, prepareDocument }
4 changes: 2 additions & 2 deletions src/core/validate-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class UnknownSvelteOptionsError extends TypeError {
}
}

const createValidateOptions = (allowedOptions) => (options) => {
const validateOptions = (allowedOptions, options) => {
const isProps = !Object.keys(options).some((option) =>
allowedOptions.includes(option)
)
Expand All @@ -36,4 +36,4 @@ const createValidateOptions = (allowedOptions) => (options) => {
return options
}

export { createValidateOptions, UnknownSvelteOptionsError }
export { UnknownSvelteOptionsError, validateOptions }
7 changes: 4 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable import/export */
import { act, cleanup } from './pure.js'
import { cleanup } from './core/index.js'
import { act } from './pure.js'

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

// export svelte-specific functions and custom `fireEvent`
export { UnknownSvelteOptionsError } from './core/index.js'
export * from './pure.js'
// `fireEvent` must be named to take priority over wildcard from @testing-library/dom
export { cleanup, UnknownSvelteOptionsError } from './core/index.js'
export { fireEvent } from './pure.js'
export * from './pure.js'
Loading