Skip to content

Commit

Permalink
feat(core): add public API for core
Browse files Browse the repository at this point in the history
  • Loading branch information
mcous committed Jun 30, 2024
1 parent d64de4a commit 3f5fc85
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 129 deletions.
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

0 comments on commit 3f5fc85

Please sign in to comment.