Skip to content

Commit

Permalink
refactor(core): consolidate options validation and cleanup into core
Browse files Browse the repository at this point in the history
  • Loading branch information
mcous committed Nov 26, 2024
1 parent 76b1865 commit 194a601
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 125 deletions.
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

0 comments on commit 194a601

Please sign in to comment.