From 19bfefd32fc2cb4a2a667701dced0e73b97e2e47 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Wed, 12 Nov 2025 11:35:41 -0500 Subject: [PATCH 1/3] fix(svelte5): do not deeply proxify passed-in props Fixes #455 --- src/component-types.d.ts | 2 +- src/core/cleanup.js | 32 +++++++++++ src/core/index.js | 18 ++---- src/core/legacy.js | 46 ---------------- src/core/modern.svelte.js | 51 ----------------- src/core/mount.js | 95 ++++++++++++++++++++++++++++++++ src/core/props.svelte.js | 38 +++++++++++++ src/core/validate-options.js | 12 ++-- src/pure.js | 70 +++++------------------ tests/act.test.js | 3 +- tests/fixtures/PropCloner.svelte | 7 +++ tests/rerender.test.js | 15 +++++ 12 files changed, 217 insertions(+), 172 deletions(-) create mode 100644 src/core/cleanup.js delete mode 100644 src/core/legacy.js delete mode 100644 src/core/modern.svelte.js create mode 100644 src/core/mount.js create mode 100644 src/core/props.svelte.js create mode 100644 tests/fixtures/PropCloner.svelte diff --git a/src/component-types.d.ts b/src/component-types.d.ts index 007f1e4..1281763 100644 --- a/src/component-types.d.ts +++ b/src/component-types.d.ts @@ -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, diff --git a/src/core/cleanup.js b/src/core/cleanup.js new file mode 100644 index 0000000..2aa70da --- /dev/null +++ b/src/core/cleanup.js @@ -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 } diff --git a/src/core/index.js b/src/core/index.js index 9e41adf..ffdd5ac 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -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' diff --git a/src/core/legacy.js b/src/core/legacy.js deleted file mode 100644 index c9e6d1c..0000000 --- a/src/core/legacy.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Legacy rendering core for svelte-testing-library. - * - * Supports Svelte <= 4. - */ - -/** Allowed options for the component constructor. */ -const allowedOptions = [ - 'target', - 'accessors', - 'anchor', - 'props', - 'hydrate', - 'intro', - '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) => { - const component = new Component(options) - - if (typeof onDestroy === 'function') { - component.$$.on_destroy.push(() => { - onDestroy(component) - }) - } - - return component -} - -/** Remove the component from the DOM. */ -const unmount = (component) => { - component.$destroy() -} - -/** Update the component's props. */ -const updateProps = (component, nextProps) => { - component.$set(nextProps) -} - -export { allowedOptions, mount, unmount, updateProps } diff --git a/src/core/modern.svelte.js b/src/core/modern.svelte.js deleted file mode 100644 index 34893f5..0000000 --- a/src/core/modern.svelte.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Modern rendering core for svelte-testing-library. - * - * Supports Svelte >= 5. - */ -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' - -/** Allowed options to the `mount` call. */ -const allowedOptions = [ - 'target', - 'anchor', - 'props', - 'events', - 'context', - 'intro', -] - -/** Mount the component into the DOM. */ -const mount = (Component, options) => { - const props = $state(options.props ?? {}) - const component = Svelte.mount(Component, { ...options, props }) - - Svelte.flushSync() - propsByComponent.set(component, props) - - return component -} - -/** Remove the component from the DOM. */ -const unmount = (component) => { - propsByComponent.delete(component) - Svelte.flushSync(() => Svelte.unmount(component)) -} - -/** - * 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) -} - -export { allowedOptions, IS_MODERN_SVELTE, mount, unmount, updateProps } diff --git a/src/core/mount.js b/src/core/mount.js new file mode 100644 index 0000000..56951ae --- /dev/null +++ b/src/core/mount.js @@ -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} Component + * @param {import('./types.js').MountOptions} options + * @returns {{ + * component: C + * unmount: () => void + * rerender: (props: Partial>) => Promise + * }} + */ +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 } diff --git a/src/core/props.svelte.js b/src/core/props.svelte.js new file mode 100644 index 0000000..81635c6 --- /dev/null +++ b/src/core/props.svelte.js @@ -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} Props + * @param {Props} initialProps + * @returns {[Props, (nextProps: Partial) => 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 = { ...Object.assign(targetProps, nextProps) } + } + + return [props, update] +} + +export { createProps } diff --git a/src/core/validate-options.js b/src/core/validate-options.js index c0d794b..294a317 100644 --- a/src/core/validate-options.js +++ b/src/core/validate-options.js @@ -1,3 +1,5 @@ +import { ALLOWED_MOUNT_OPTIONS } from './mount.js' + class UnknownSvelteOptionsError extends TypeError { constructor(unknownOptions, allowedOptions) { super(`Unknown options. @@ -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) { @@ -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 } diff --git a/src/pure.js b/src/pure.js index 583d063..fbb73c2 100644 --- a/src/pure.js +++ b/src/pure.js @@ -7,10 +7,7 @@ import { } from '@testing-library/dom' import * as Svelte from 'svelte' -import { mount, unmount, updateProps, validateOptions } from './core/index.js' - -const targetCache = new Set() -const componentCache = new Set() +import { addCleanupTask, mount, validateOptions } from './core/index.js' /** * Customize how Svelte renders the component. @@ -70,16 +67,17 @@ const render = (Component, options = {}, renderOptions = {}) => { // eslint-disable-next-line unicorn/prefer-dom-node-append options.target ?? baseElement.appendChild(document.createElement('div')) - targetCache.add(target) + addCleanupTask(() => { + if (target.parentNode === document.body) { + target.remove() + } + }) - const component = mount( + const { component, unmount, rerender } = mount( Component.default ?? Component, - { ...options, target }, - cleanupComponent + { ...options, target } ) - componentCache.add(component) - return { baseElement, component, @@ -95,19 +93,13 @@ const render = (Component, options = {}, renderOptions = {}) => { props = props.props } - updateProps(component, props) - await Svelte.tick() - }, - unmount: () => { - cleanupComponent(component) + await rerender(props) }, + unmount, ...queries, } } -/** @type {import('@testing-library/dom'.Config | undefined} */ -let originalDTLConfig - /** * Configure `@testing-library/dom` for usage with Svelte. * @@ -116,49 +108,16 @@ let originalDTLConfig * to flush changes to the DOM before proceeding. */ const setup = () => { - originalDTLConfig = getDTLConfig() + const originalDTLConfig = getDTLConfig() configureDTL({ asyncWrapper: act, eventWrapper: Svelte.flushSync ?? ((cb) => cb()), }) -} -/** Reset dom-testing-library config. */ -const cleanupDTL = () => { - if (originalDTLConfig) { + addCleanupTask(() => { configureDTL(originalDTLConfig) - originalDTLConfig = undefined - } -} - -/** 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) { - target.remove() - } -} - -/** Unmount components, remove elements added to ``, and reset `@testing-library/dom`. */ -const cleanup = () => { - for (const component of componentCache) { - cleanupComponent(component) - } - for (const target of targetCache) { - cleanupTarget(target) - } - cleanupDTL() + }) } /** @@ -201,4 +160,5 @@ for (const [key, baseEvent] of Object.entries(baseFireEvent)) { fireEvent[key] = async (...args) => act(() => baseEvent(...args)) } -export { act, cleanup, fireEvent, render, setup } +export { cleanup } from './core/index.js' +export { act, fireEvent, render, setup } diff --git a/tests/act.test.js b/tests/act.test.js index c6caae2..c8bb007 100644 --- a/tests/act.test.js +++ b/tests/act.test.js @@ -14,7 +14,6 @@ describe('act', () => { expect(button).toHaveTextContent('Button') await act(() => { - // eslint-disable-next-line testing-library/no-node-access button.click() }) @@ -27,7 +26,7 @@ describe('act', () => { await act(async () => { await setTimeout(10) - // eslint-disable-next-line testing-library/no-node-access + button.click() }) diff --git a/tests/fixtures/PropCloner.svelte b/tests/fixtures/PropCloner.svelte new file mode 100644 index 0000000..035f2fe --- /dev/null +++ b/tests/fixtures/PropCloner.svelte @@ -0,0 +1,7 @@ + diff --git a/tests/rerender.test.js b/tests/rerender.test.js index 2fdff0d..7c1a8eb 100644 --- a/tests/rerender.test.js +++ b/tests/rerender.test.js @@ -52,3 +52,18 @@ describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => { expect(element).toHaveTextContent('Hello Planet!') }) }) + +describe.runIf(IS_SVELTE_5)('reactive prop handling', () => { + let Comp + + beforeAll(async () => { + Comp = await import('./fixtures/PropCloner.svelte') + }) + + test('does not interfere with props values', () => { + const { component } = render(Comp, { input: { hello: 'world' } }) + const result = component.cloneInput() + + expect(result).toEqual({ hello: 'world' }) + }) +}) From ae97d409fa628f81a55b54025043be9ad07835a9 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Wed, 12 Nov 2025 12:23:33 -0500 Subject: [PATCH 2/3] fixup: jest compat --- tests/_jest-vitest-alias.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/_jest-vitest-alias.js b/tests/_jest-vitest-alias.js index a09c310..fa93be9 100644 --- a/tests/_jest-vitest-alias.js +++ b/tests/_jest-vitest-alias.js @@ -13,6 +13,7 @@ export { // Add support for describe.skipIf, test.skipIf, and test.runIf describe.skipIf = (condition) => (condition ? describe.skip : describe) +describe.runIf = (condition) => (condition ? describe : describe.skip) test.skipIf = (condition) => (condition ? test.skip : test) test.runIf = (condition) => (condition ? test : test.skip) From 054c3e1ee8d2bfcffd71cbeee877ae1bf9f659b0 Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Wed, 12 Nov 2025 12:29:20 -0500 Subject: [PATCH 3/3] fixup: jest compat again --- src/core/props.svelte.js | 2 +- tests/rerender.test.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/props.svelte.js b/src/core/props.svelte.js index 81635c6..f181e72 100644 --- a/src/core/props.svelte.js +++ b/src/core/props.svelte.js @@ -29,7 +29,7 @@ const createProps = (initialProps) => { }) const update = (nextProps) => { - currentProps = { ...Object.assign(targetProps, nextProps) } + currentProps = { ...currentProps, ...nextProps } } return [props, update] diff --git a/tests/rerender.test.js b/tests/rerender.test.js index 7c1a8eb..34e9398 100644 --- a/tests/rerender.test.js +++ b/tests/rerender.test.js @@ -1,7 +1,7 @@ import { act, render, screen } from '@testing-library/svelte' import { beforeAll, describe, expect, test, vi } from 'vitest' -import { COMPONENT_FIXTURES, IS_SVELTE_5, MODE_RUNES } from './_env.js' +import { COMPONENT_FIXTURES, IS_JEST, IS_SVELTE_5, MODE_RUNES } from './_env.js' describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => { let Comp @@ -53,7 +53,9 @@ describe.each(COMPONENT_FIXTURES)('rerender ($mode)', ({ mode, component }) => { }) }) -describe.runIf(IS_SVELTE_5)('reactive prop handling', () => { +// NOTE: Jest does not support `structuredClone`, used in this test +// to check that `input` isn't turned into a Proxy +describe.runIf(IS_SVELTE_5 && !IS_JEST)('reactive prop handling', () => { let Comp beforeAll(async () => {