Skip to content
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

refactor(core): consolidate options validation and cleanup into core #412

Draft
wants to merge 1 commit into
base: core-1
Choose a base branch
from
Draft
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
24 changes: 24 additions & 0 deletions src/core/cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** @type {Set<() => void} */
const cleanupTasks = new Set()

/** Register later cleanup task */
const addCleanupTask = (onCleanup) => {
cleanupTasks.add(onCleanup)
return onCleanup
}

/** Remove a cleanup task without running it. */
const removeCleanupTask = (onCleanup) => {
cleanupTasks.delete(onCleanup)
}

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

cleanupTasks.clear()
}

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

const { mount, unmount, updateProps, allowedOptions } =
MountModern.IS_MODERN_SVELTE ? MountModern : MountLegacy

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

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

import { addCleanupTask, removeCleanupTask } from './cleanup.js'

/** Allowed options for the component constructor. */
const allowedOptions = [
const ALLOWED_OPTIONS = [
'target',
'accessors',
'anchor',
Expand All @@ -15,32 +17,31 @@ 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 mount = (Component, options) => {
const component = new Component(options)

if (typeof onDestroy === 'function') {
component.$$.on_destroy.push(() => {
onDestroy(component)
})
/** Remove the component from the DOM. */
const unmount = () => {
component.$destroy()
removeCleanupTask(unmount)
}

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

/** Remove the component from the DOM. */
const unmount = (component) => {
component.$destroy()
}
// This `$$.on_destroy` listener is included for strict backwards compatibility
// with previous versions of `@testing-library/svelte`.
// It's unnecessary and will be removed in a future major version.
component.$$.on_destroy.push(() => {
removeCleanupTask(unmount)
})

addCleanupTask(unmount)

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

export { allowedOptions, mount, unmount, updateProps }
export { ALLOWED_OPTIONS, mount }
36 changes: 15 additions & 21 deletions src/core/mount-modern.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@
*/
import * as Svelte from 'svelte'

/** Props signals for each rendered component. */
const propsByComponent = new Map()
import { addCleanupTask, removeCleanupTask } from './cleanup.js'

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

/** Allowed options to the `mount` call. */
const allowedOptions = [
const ALLOWED_OPTIONS = [
'target',
'anchor',
'props',
Expand All @@ -26,26 +25,21 @@ const mount = (Component, options) => {
const props = $state(options.props ?? {})
const component = Svelte.mount(Component, { ...options, props })

Svelte.flushSync()
propsByComponent.set(component, props)
/** Remove the component from the DOM. */
const unmount = () => {
Svelte.flushSync(() => Svelte.unmount(component))
removeCleanupTask(unmount)
}

return component
}
/** Update the component's props. */
const rerender = (nextProps) => {
Svelte.flushSync(() => Object.assign(props, nextProps))
}

/** Remove the component from the DOM. */
const unmount = (component) => {
propsByComponent.delete(component)
Svelte.flushSync(() => Svelte.unmount(component))
}
addCleanupTask(unmount)
Svelte.flushSync()

/**
* 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, unmount, rerender }
}

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

import * as MountLegacy from './mount-legacy.js'
import * as MountModern from './mount-modern.svelte.js'

const mountComponent = MountModern.IS_MODERN_SVELTE
? MountModern.mount
: MountLegacy.mount

/**
* Render a Svelte component into the document.
*
* @template {import('./types.js').Component} C
* @param {import('./types.js').ComponentType<C>} Component
* @param {import('./types.js').MountOptions<C>} options
* @returns {{
* component: C
* unmount: () => void
* rerender: (props: Partial<import('./types.js').Props<C>>) => Promise<void>
* }}
*/
const mount = (Component, options = {}) => {
const { component, unmount, rerender } = mountComponent(Component, options)

return {
component,
unmount,
rerender: async (props) => {
rerender(props)
// Await the next tick for Svelte 4, which cannot flush changes synchronously
await tick()
},
}
}

export { mount }
55 changes: 48 additions & 7 deletions src/core/prepare.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { addCleanupTask } from './cleanup.js'
import * as MountLegacy from './mount-legacy.js'
import * as MountModern from './mount-modern.svelte.js'

const ALLOWED_OPTIONS = MountModern.IS_MODERN_SVELTE
? MountModern.ALLOWED_OPTIONS
: MountLegacy.ALLOWED_OPTIONS

/** An error thrown for incorrect options and clashes between props and Svelte options. */
class UnknownSvelteOptionsError extends TypeError {
constructor(unknownOptions, allowedOptions) {
constructor(unknownOptions) {
super(`Unknown options.

Unknown: [ ${unknownOptions.join(', ')} ]
Allowed: [ ${allowedOptions.join(', ')} ]
Allowed: [ ${ALLOWED_OPTIONS.join(', ')} ]

To pass both Svelte options and props to a component,
or to use props that share a name with a Svelte option,
Expand All @@ -15,9 +24,41 @@ class UnknownSvelteOptionsError extends TypeError {
}
}

const createValidateOptions = (allowedOptions) => (options) => {
/**
* Prepare DOM elements for rendering.
*
* @template {import('./types.js').Component} C
* @param {import('./types.js').PropsOrMountOptions<C>} propsOrOptions
* @param {{ baseElement?: HTMLElement }} renderOptions
* @returns {{
* baseElement: HTMLElement
* target: HTMLElement
* mountOptions: import('./types.js').MountOptions<C>
* }}
*/
const prepare = (propsOrOptions = {}, renderOptions = {}) => {
const mountOptions = validateMountOptions(propsOrOptions)

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

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

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

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

/** Prevent incorrect options and clashes between props and Svelte options. */
const validateMountOptions = (options) => {
const isProps = !Object.keys(options).some((option) =>
allowedOptions.includes(option)
ALLOWED_OPTIONS.includes(option)
)

if (isProps) {
Expand All @@ -26,14 +67,14 @@ const createValidateOptions = (allowedOptions) => (options) => {

// Check if any props and Svelte options were accidentally mixed.
const unknownOptions = Object.keys(options).filter(
(option) => !allowedOptions.includes(option)
(option) => !ALLOWED_OPTIONS.includes(option)
)

if (unknownOptions.length > 0) {
throw new UnknownSvelteOptionsError(unknownOptions, allowedOptions)
throw new UnknownSvelteOptionsError(unknownOptions)
}

return options
}

export { createValidateOptions, UnknownSvelteOptionsError }
export { prepare, UnknownSvelteOptionsError }
5 changes: 5 additions & 0 deletions src/core/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,8 @@ export type Exports<C> = C extends LegacyComponent
export type MountOptions<C extends Component> = C extends LegacyComponent
? LegacyConstructorOptions<Props<C>>
: Parameters<typeof mount<Props<C>, Exports<C>>>[1]

/** Component props or partial mount options. */
export type PropsOrMountOptions<C extends Component> =
| Props<C>
| Partial<MountOptions<C>>
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
Loading