Skip to content

Commit 11d99e0

Browse files
authored
fix(core): fix props proxy target to avoid double $effect runs (#494)
Fixes #493
1 parent 959f8c5 commit 11d99e0

5 files changed

Lines changed: 114 additions & 18 deletions

File tree

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const SVELTE_TRANSFORM_PATTERN =
66
: String.raw`^.+\.svelte$`
77

88
export default {
9-
testMatch: ['<rootDir>/tests/**/*.test.js'],
9+
testMatch: ['<rootDir>/tests/**/*.test.{js,svelte.js}'],
1010
transform: {
1111
[SVELTE_TRANSFORM_PATTERN]: 'svelte-jester',
1212
},

packages/svelte-core/src/props.svelte.js

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
22
* Create a shallowly reactive props object.
33
*
4-
* This allows us to update props on `rerender`
5-
* without turing `props` into a deep set of Proxy objects
4+
* This allows us to update props on `rerender` without turning the user's
5+
* props into a deeply reactive `$state` proxy.
66
*
77
* @template {Record<string, unknown>} Props
88
* @param {Props} initialProps
@@ -11,21 +11,26 @@
1111
const createProps = (initialProps = {}) => {
1212
let currentProps = $state.raw(initialProps)
1313

14-
const props = new Proxy(initialProps, {
15-
get(_, key) {
16-
return currentProps[key]
17-
},
18-
set(_, key, value) {
19-
currentProps[key] = value
20-
return true
21-
},
22-
has(_, key) {
23-
return Reflect.has(currentProps, key)
24-
},
25-
ownKeys() {
26-
return Reflect.ownKeys(currentProps)
27-
},
28-
})
14+
const props = new Proxy(
15+
{},
16+
{
17+
get(_, key) {
18+
return Reflect.get(currentProps, key)
19+
},
20+
set(_, key, value) {
21+
return Reflect.set(currentProps, key, value)
22+
},
23+
has(_, key) {
24+
return Reflect.has(currentProps, key)
25+
},
26+
ownKeys() {
27+
return Reflect.ownKeys(currentProps)
28+
},
29+
getOwnPropertyDescriptor(_, key) {
30+
return Reflect.getOwnPropertyDescriptor(currentProps, key)
31+
},
32+
}
33+
)
2934

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

tests/bind.test.svelte.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { render, screen } from '@testing-library/svelte'
2+
import { userEvent } from '@testing-library/user-event'
3+
import { describe, expect, test, vi } from 'vitest'
4+
5+
import { IS_SVELTE_5 } from './_env.js'
6+
import Subject from './fixtures/Binder.svelte'
7+
8+
describe.runIf(IS_SVELTE_5)('binds', () => {
9+
test('binding via getter/setter', async () => {
10+
const user = userEvent.setup()
11+
let value = false
12+
const props = {
13+
get value() {
14+
return value
15+
},
16+
set value(nextValue) {
17+
value = nextValue
18+
},
19+
}
20+
21+
render(Subject, props)
22+
23+
const input = screen.getByRole('checkbox')
24+
await user.click(input)
25+
26+
expect(value).toBe(true)
27+
})
28+
29+
test('binding via getter/setter does not double-trigger effects', async () => {
30+
const user = userEvent.setup()
31+
const onEffectRun = vi.fn()
32+
let value = false
33+
const props = {
34+
onEffectRun,
35+
get value() {
36+
return value
37+
},
38+
set value(nextValue) {
39+
value = nextValue
40+
},
41+
}
42+
43+
render(Subject, props)
44+
expect(onEffectRun).toHaveBeenCalledTimes(1)
45+
46+
const input = screen.getByRole('checkbox')
47+
await user.click(input)
48+
49+
expect(onEffectRun).toHaveBeenCalledTimes(2)
50+
})
51+
52+
test('binding via $state', async () => {
53+
const user = userEvent.setup()
54+
const props = $state({ value: false })
55+
56+
render(Subject, props)
57+
58+
const input = screen.getByRole('checkbox')
59+
await user.click(input)
60+
61+
expect(props.value).toBe(true)
62+
})
63+
64+
test('binding via $state does not double-trigger effects', async () => {
65+
const user = userEvent.setup()
66+
const onEffectRun = vi.fn()
67+
const props = $state({ value: false, onEffectRun })
68+
69+
render(Subject, props)
70+
expect(onEffectRun).toHaveBeenCalledTimes(1)
71+
72+
const input = screen.getByRole('checkbox')
73+
await user.click(input)
74+
75+
expect(onEffectRun).toHaveBeenCalledTimes(2)
76+
})
77+
})

tests/fixtures/Binder.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script>
2+
let { onEffectRun, value = $bindable(false) } = $props()
3+
4+
let checked = $state()
5+
6+
$effect(() => {
7+
value = checked
8+
onEffectRun?.(checked)
9+
})
10+
</script>
11+
12+
<input type="checkbox" bind:checked />
13+
<output>checked={value}</output>

vite.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default defineConfig({
77
test: {
88
environment: 'jsdom',
99
setupFiles: ['./tests/_vitest-setup.js'],
10+
include: ['{examples,tests}/**/*.test.{js,svelte.js}'],
1011
mockReset: true,
1112
unstubGlobals: true,
1213
unstubEnvs: true,

0 commit comments

Comments
 (0)