diff --git a/package.json b/package.json index b6a0c37..bf1654d 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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`", diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 0000000..12e5397 --- /dev/null +++ b/src/core/README.md @@ -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 + + + + +- [Usage](#usage) +- [API](#api) + - [`prepareDocument`](#preparedocument) + - [`mount`](#mount) + - [`cleanup`](#cleanup) + + + +## 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 `
` 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) => Promise` | 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() +``` diff --git a/src/core/cleanup.js b/src/core/cleanup.js new file mode 100644 index 0000000..83e32b9 --- /dev/null +++ b/src/core/cleanup.js @@ -0,0 +1,30 @@ +/** @type {Map 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 } diff --git a/src/core/index.js b/src/core/index.js index f4a40aa..2ef084a 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -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' diff --git a/src/core/legacy.js b/src/core/legacy.js index c9e6d1c..3de08b5 100644 --- a/src/core/legacy.js +++ b/src/core/legacy.js @@ -4,6 +4,8 @@ * Supports Svelte <= 4. */ +import { removeItemFromCleanup } from './cleanup.js' + /** Allowed options for the component constructor. */ const allowedOptions = [ 'target', @@ -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 } diff --git a/src/core/modern.svelte.js b/src/core/modern.svelte.js index 3da78b4..1642afc 100644 --- a/src/core/modern.svelte.js +++ b/src/core/modern.svelte.js @@ -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' @@ -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 } diff --git a/src/core/mount.js b/src/core/mount.js new file mode 100644 index 0000000..bd43148 --- /dev/null +++ b/src/core/mount.js @@ -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 | Partial>>} propsOrOptions + * @param {{ baseElement?: HTMLElement }} renderOptions + * @returns {{ + * baseElement: HTMLElement + * target: HTMLElement + * options: import('svelte').ComponentConstructorOptions> + * }} + */ +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} Component + * @param {import('svelte').ComponentConstructorOptions>} options + * @returns {{ + * component: C + * rerender: (props: Partial>) => Promise + * 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 } diff --git a/src/core/validate-options.js b/src/core/validate-options.js index c0d794b..4e2f60e 100644 --- a/src/core/validate-options.js +++ b/src/core/validate-options.js @@ -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) ) @@ -36,4 +36,4 @@ const createValidateOptions = (allowedOptions) => (options) => { return options } -export { createValidateOptions, UnknownSvelteOptionsError } +export { UnknownSvelteOptionsError, validateOptions } diff --git a/src/index.js b/src/index.js index 2704824..fbfecb9 100644 --- a/src/index.js +++ b/src/index.js @@ -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 @@ -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' diff --git a/src/pure.js b/src/pure.js index edb94b3..66af185 100644 --- a/src/pure.js +++ b/src/pure.js @@ -5,10 +5,7 @@ import { } from '@testing-library/dom' import { tick } from 'svelte' -import { mount, unmount, updateProps, validateOptions } from './core/index.js' - -const targetCache = new Set() -const componentCache = new Set() +import { mount, prepareDocument } from './core/index.js' /** * Customize how Svelte renders the component. @@ -20,7 +17,7 @@ const componentCache = new Set() /** * Customize how Testing Library sets up the document and binds queries. * - * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries] + * @template {import('@testing-library/dom').Queries} [Q=import('@testing-library/dom').queries] * @typedef {{ * baseElement?: HTMLElement * queries?: Q @@ -31,8 +28,7 @@ const componentCache = new Set() * The rendered component and bound testing functions. * * @template {import('svelte').SvelteComponent} C - * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries] - * + * @template {import('@testing-library/dom').Queries} [Q=import('@testing-library/dom').queries] * @typedef {{ * container: HTMLElement * baseElement: HTMLElement @@ -49,41 +45,30 @@ const componentCache = new Set() * Render a component into the document. * * @template {import('svelte').SvelteComponent} C - * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries] - * + * @template {import('@testing-library/dom').Queries} [Q=import('@testing-library/dom').queries] * @param {import('svelte').ComponentType} Component - The component to render. - * @param {SvelteComponentOptions} options - Customize how Svelte renders the component. + * @param {SvelteComponentOptions} propsOrOptions - Customize how Svelte renders the component. * @param {RenderOptions} renderOptions - Customize how Testing Library sets up the document and binds queries. * @returns {RenderResult} The rendered component and bound testing functions. */ -const render = (Component, options = {}, renderOptions = {}) => { - options = validateOptions(options) - - const baseElement = - renderOptions.baseElement ?? options.target ?? document.body - - const queries = getQueriesForElement(baseElement, renderOptions.queries) - - const target = - options.target ?? baseElement.appendChild(document.createElement('div')) - - targetCache.add(target) +const render = (Component, propsOrOptions = {}, renderOptions = {}) => { + const { baseElement, target, options } = prepareDocument( + propsOrOptions, + renderOptions + ) - const component = mount( + const { component, unmount, rerender } = mount( Component.default ?? Component, - { ...options, target }, - cleanupComponent + options ) - componentCache.add(component) + const queries = getQueriesForElement(baseElement, renderOptions.queries) return { baseElement, component, container: target, - debug: (el = baseElement) => { - console.log(prettyDOM(el)) - }, + debug: (el = baseElement) => console.log(prettyDOM(el)), rerender: async (props) => { if (props.props) { console.warn( @@ -92,40 +77,13 @@ const render = (Component, options = {}, renderOptions = {}) => { props = props.props } - updateProps(component, props) - await tick() - }, - unmount: () => { - cleanupComponent(component) + await rerender(props) }, + unmount, ...queries, } } -/** Remove a component from the component cache. */ -const cleanupComponent = (component) => { - const inCache = componentCache.delete(component) - - if (inCache) { - unmount(component) - } -} - -/** Remove a target element from the target cache. */ -const cleanupTarget = (target) => { - const inCache = targetCache.delete(target) - - if (inCache && target.parentNode === document.body) { - document.body.removeChild(target) - } -} - -/** Unmount all components and remove elements added to ``. */ -const cleanup = () => { - componentCache.forEach(cleanupComponent) - targetCache.forEach(cleanupTarget) -} - /** * Call a function and wait for Svelte to flush pending changes. * @@ -171,4 +129,4 @@ Object.keys(baseFireEvent).forEach((key) => { } }) -export { act, cleanup, fireEvent, render } +export { act, fireEvent, render }