Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/component-types.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-deprecated */
import type {
Component as ModernComponent,
ComponentConstructorOptions as LegacyConstructorOptions,
Expand Down
32 changes: 32 additions & 0 deletions src/core/cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** @type {Set<() => void>} */
const cleanupTasks = new Set()

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

/**
* Remove a cleanup task without running it.
*
* @param {() => void} onCleanup
*/
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 }
18 changes: 6 additions & 12 deletions src/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,9 @@
* 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 } from './validate-options.js'

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

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

export { mount, unmount, updateProps, validateOptions }
export { UnknownSvelteOptionsError } from './validate-options.js'
export { addCleanupTask, cleanup } from './cleanup.js'
export { mount } from './mount.js'
export {
UnknownSvelteOptionsError,
validateOptions,
} from './validate-options.js'
46 changes: 0 additions & 46 deletions src/core/legacy.js

This file was deleted.

51 changes: 0 additions & 51 deletions src/core/modern.svelte.js

This file was deleted.

95 changes: 95 additions & 0 deletions src/core/mount.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Component rendering core, with support for Svelte 3, 4, and 5
*/
import * as Svelte from 'svelte'

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

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

/** Allowed options to the `mount` call or legacy component constructor. */
const ALLOWED_MOUNT_OPTIONS = IS_MODERN_SVELTE
? ['target', 'anchor', 'props', 'events', 'context', 'intro']
: ['target', 'accessors', 'anchor', 'props', 'hydrate', 'intro', 'context']

/** Mount a modern Svelte 5 component into the DOM. */
const mountModern = (Component, options) => {
const [props, updateProps] = createProps(options.props)
const component = Svelte.mount(Component, { ...options, props })

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

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

addCleanupTask(unmount)
Svelte.flushSync()

return { component, unmount, rerender }
}

/** Mount a legacy Svelte 3 or 4 component into the DOM. */
const mountLegacy = (Component, options) => {
const component = new Component(options)

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

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

// 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)

return { component, unmount, rerender }
}

/** The mount method in use. */
const mountComponent = IS_MODERN_SVELTE ? mountModern : mountLegacy

/**
* 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 Svelte.tick()
},
}
}

export { ALLOWED_MOUNT_OPTIONS, mount }
38 changes: 38 additions & 0 deletions src/core/props.svelte.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Create a shallowly reactive props object.
*
* This allows us to update props on `rerender`
* without turing `props` into a deep set of Proxy objects
*
* @template {Record<string, unknown>} Props
* @param {Props} initialProps
* @returns {[Props, (nextProps: Partial<Props>) => void]}
*/
const createProps = (initialProps) => {
const targetProps = initialProps ?? {}
let currentProps = $state.raw(targetProps)

const props = new Proxy(targetProps, {
get(_, key) {
return currentProps[key]
},
set(_, key, value) {
currentProps[key] = value
return true
},
has(_, key) {
return Reflect.has(currentProps, key)
},
ownKeys() {
return Reflect.ownKeys(currentProps)
},
})

const update = (nextProps) => {
currentProps = { ...currentProps, ...nextProps }
}

return [props, update]
}

export { createProps }
12 changes: 7 additions & 5 deletions src/core/validate-options.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ALLOWED_MOUNT_OPTIONS } from './mount.js'

class UnknownSvelteOptionsError extends TypeError {
constructor(unknownOptions, allowedOptions) {
super(`Unknown options.
Expand All @@ -15,9 +17,9 @@ class UnknownSvelteOptionsError extends TypeError {
}
}

const createValidateOptions = (allowedOptions) => (options) => {
const validateOptions = (options) => {
const isProps = !Object.keys(options).some((option) =>
allowedOptions.includes(option)
ALLOWED_MOUNT_OPTIONS.includes(option)
)

if (isProps) {
Expand All @@ -26,14 +28,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_MOUNT_OPTIONS.includes(option)
)

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

return options
}

export { createValidateOptions, UnknownSvelteOptionsError }
export { UnknownSvelteOptionsError, validateOptions }
Loading