Skip to content

Commit 734e290

Browse files
authored
fix(svelte5): do not deeply proxify passed-in props (#456)
Fixes #455
1 parent 9517f83 commit 734e290

File tree

13 files changed

+221
-173
lines changed

13 files changed

+221
-173
lines changed

src/component-types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
1+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-deprecated */
22
import type {
33
Component as ModernComponent,
44
ComponentConstructorOptions as LegacyConstructorOptions,

src/core/cleanup.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/** @type {Set<() => void>} */
2+
const cleanupTasks = new Set()
3+
4+
/**
5+
* Register later cleanup task
6+
*
7+
* @param {() => void} onCleanup
8+
*/
9+
const addCleanupTask = (onCleanup) => {
10+
cleanupTasks.add(onCleanup)
11+
return onCleanup
12+
}
13+
14+
/**
15+
* Remove a cleanup task without running it.
16+
*
17+
* @param {() => void} onCleanup
18+
*/
19+
const removeCleanupTask = (onCleanup) => {
20+
cleanupTasks.delete(onCleanup)
21+
}
22+
23+
/** Clean up all components and elements added to the document. */
24+
const cleanup = () => {
25+
for (const handleCleanup of cleanupTasks.values()) {
26+
handleCleanup()
27+
}
28+
29+
cleanupTasks.clear()
30+
}
31+
32+
export { addCleanupTask, cleanup, removeCleanupTask }

src/core/index.js

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,9 @@
55
* Will switch to legacy, class-based mounting logic
66
* if it looks like we're in a Svelte <= 4 environment.
77
*/
8-
import * as LegacyCore from './legacy.js'
9-
import * as ModernCore from './modern.svelte.js'
10-
import { createValidateOptions } from './validate-options.js'
11-
12-
const { mount, unmount, updateProps, allowedOptions } =
13-
ModernCore.IS_MODERN_SVELTE ? ModernCore : LegacyCore
14-
15-
/** Validate component options. */
16-
const validateOptions = createValidateOptions(allowedOptions)
17-
18-
export { mount, unmount, updateProps, validateOptions }
19-
export { UnknownSvelteOptionsError } from './validate-options.js'
8+
export { addCleanupTask, cleanup } from './cleanup.js'
9+
export { mount } from './mount.js'
10+
export {
11+
UnknownSvelteOptionsError,
12+
validateOptions,
13+
} from './validate-options.js'

src/core/legacy.js

Lines changed: 0 additions & 46 deletions
This file was deleted.

src/core/modern.svelte.js

Lines changed: 0 additions & 51 deletions
This file was deleted.

src/core/mount.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Component rendering core, with support for Svelte 3, 4, and 5
3+
*/
4+
import * as Svelte from 'svelte'
5+
6+
import { addCleanupTask, removeCleanupTask } from './cleanup.js'
7+
import { createProps } from './props.svelte.js'
8+
9+
/** Whether we're using Svelte >= 5. */
10+
const IS_MODERN_SVELTE = typeof Svelte.mount === 'function'
11+
12+
/** Allowed options to the `mount` call or legacy component constructor. */
13+
const ALLOWED_MOUNT_OPTIONS = IS_MODERN_SVELTE
14+
? ['target', 'anchor', 'props', 'events', 'context', 'intro']
15+
: ['target', 'accessors', 'anchor', 'props', 'hydrate', 'intro', 'context']
16+
17+
/** Mount a modern Svelte 5 component into the DOM. */
18+
const mountModern = (Component, options) => {
19+
const [props, updateProps] = createProps(options.props)
20+
const component = Svelte.mount(Component, { ...options, props })
21+
22+
/** Remove the component from the DOM. */
23+
const unmount = () => {
24+
Svelte.flushSync(() => Svelte.unmount(component))
25+
removeCleanupTask(unmount)
26+
}
27+
28+
/** Update the component's props. */
29+
const rerender = (nextProps) => {
30+
Svelte.flushSync(() => updateProps(nextProps))
31+
}
32+
33+
addCleanupTask(unmount)
34+
Svelte.flushSync()
35+
36+
return { component, unmount, rerender }
37+
}
38+
39+
/** Mount a legacy Svelte 3 or 4 component into the DOM. */
40+
const mountLegacy = (Component, options) => {
41+
const component = new Component(options)
42+
43+
/** Remove the component from the DOM. */
44+
const unmount = () => {
45+
component.$destroy()
46+
removeCleanupTask(unmount)
47+
}
48+
49+
/** Update the component's props. */
50+
const rerender = (nextProps) => {
51+
component.$set(nextProps)
52+
}
53+
54+
// This `$$.on_destroy` listener is included for strict backwards compatibility
55+
// with previous versions of `@testing-library/svelte`.
56+
// It's unnecessary and will be removed in a future major version.
57+
component.$$.on_destroy.push(() => {
58+
removeCleanupTask(unmount)
59+
})
60+
61+
addCleanupTask(unmount)
62+
63+
return { component, unmount, rerender }
64+
}
65+
66+
/** The mount method in use. */
67+
const mountComponent = IS_MODERN_SVELTE ? mountModern : mountLegacy
68+
69+
/**
70+
* Render a Svelte component into the document.
71+
*
72+
* @template {import('./types.js').Component} C
73+
* @param {import('./types.js').ComponentType<C>} Component
74+
* @param {import('./types.js').MountOptions<C>} options
75+
* @returns {{
76+
* component: C
77+
* unmount: () => void
78+
* rerender: (props: Partial<import('./types.js').Props<C>>) => Promise<void>
79+
* }}
80+
*/
81+
const mount = (Component, options = {}) => {
82+
const { component, unmount, rerender } = mountComponent(Component, options)
83+
84+
return {
85+
component,
86+
unmount,
87+
rerender: async (props) => {
88+
rerender(props)
89+
// Await the next tick for Svelte 4, which cannot flush changes synchronously
90+
await Svelte.tick()
91+
},
92+
}
93+
}
94+
95+
export { ALLOWED_MOUNT_OPTIONS, mount }

src/core/props.svelte.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Create a shallowly reactive props object.
3+
*
4+
* This allows us to update props on `rerender`
5+
* without turing `props` into a deep set of Proxy objects
6+
*
7+
* @template {Record<string, unknown>} Props
8+
* @param {Props} initialProps
9+
* @returns {[Props, (nextProps: Partial<Props>) => void]}
10+
*/
11+
const createProps = (initialProps) => {
12+
const targetProps = initialProps ?? {}
13+
let currentProps = $state.raw(targetProps)
14+
15+
const props = new Proxy(targetProps, {
16+
get(_, key) {
17+
return currentProps[key]
18+
},
19+
set(_, key, value) {
20+
currentProps[key] = value
21+
return true
22+
},
23+
has(_, key) {
24+
return Reflect.has(currentProps, key)
25+
},
26+
ownKeys() {
27+
return Reflect.ownKeys(currentProps)
28+
},
29+
})
30+
31+
const update = (nextProps) => {
32+
currentProps = { ...currentProps, ...nextProps }
33+
}
34+
35+
return [props, update]
36+
}
37+
38+
export { createProps }

src/core/validate-options.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ALLOWED_MOUNT_OPTIONS } from './mount.js'
2+
13
class UnknownSvelteOptionsError extends TypeError {
24
constructor(unknownOptions, allowedOptions) {
35
super(`Unknown options.
@@ -15,9 +17,9 @@ class UnknownSvelteOptionsError extends TypeError {
1517
}
1618
}
1719

18-
const createValidateOptions = (allowedOptions) => (options) => {
20+
const validateOptions = (options) => {
1921
const isProps = !Object.keys(options).some((option) =>
20-
allowedOptions.includes(option)
22+
ALLOWED_MOUNT_OPTIONS.includes(option)
2123
)
2224

2325
if (isProps) {
@@ -26,14 +28,14 @@ const createValidateOptions = (allowedOptions) => (options) => {
2628

2729
// Check if any props and Svelte options were accidentally mixed.
2830
const unknownOptions = Object.keys(options).filter(
29-
(option) => !allowedOptions.includes(option)
31+
(option) => !ALLOWED_MOUNT_OPTIONS.includes(option)
3032
)
3133

3234
if (unknownOptions.length > 0) {
33-
throw new UnknownSvelteOptionsError(unknownOptions, allowedOptions)
35+
throw new UnknownSvelteOptionsError(unknownOptions, ALLOWED_MOUNT_OPTIONS)
3436
}
3537

3638
return options
3739
}
3840

39-
export { createValidateOptions, UnknownSvelteOptionsError }
41+
export { UnknownSvelteOptionsError, validateOptions }

0 commit comments

Comments
 (0)