Skip to content

Commit 6d6e94c

Browse files
committed
breaking: add $bindable() rune to denote bindable props
alternative to #10804
1 parent 6f8a451 commit 6d6e94c

File tree

31 files changed

+135
-178
lines changed

31 files changed

+135
-178
lines changed

packages/svelte/src/compiler/errors.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ const runes = {
182182
`$props() assignment must not contain nested properties or computed keys`,
183183
'invalid-props-location': () =>
184184
`$props() can only be used at the top level of components as a variable declaration initializer`,
185+
'invalid-bindable-location': () => `$bindable() can only be used as part of the $props() rune`,
185186
/** @param {string} rune */
186187
'invalid-state-location': (rune) =>
187188
`${rune}(...) can only be used as a variable declaration initializer or a class field`,

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ export function analyze_component(root, options) {
422422
options,
423423
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
424424
parent_element: null,
425-
has_props_rune: [false, false],
425+
has_props_rune: false,
426426
component_slots: new Set(),
427427
expression: null,
428428
private_derived_state: [],
@@ -446,7 +446,7 @@ export function analyze_component(root, options) {
446446
analysis,
447447
options,
448448
parent_element: null,
449-
has_props_rune: [false, false],
449+
has_props_rune: false,
450450
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
451451
instance_scope: instance.scope,
452452
reactive_statement: null,
@@ -857,8 +857,7 @@ const runes_scope_tweaker = {
857857
rune !== '$state.frozen' &&
858858
rune !== '$derived' &&
859859
rune !== '$derived.by' &&
860-
rune !== '$props' &&
861-
rune !== '$props.bindable'
860+
rune !== '$props'
862861
)
863862
return;
864863

@@ -874,12 +873,10 @@ const runes_scope_tweaker = {
874873
? 'derived'
875874
: path.is_rest
876875
? 'rest_prop'
877-
: rune === '$props.bindable'
878-
? 'bindable_prop'
879-
: 'prop';
876+
: 'prop';
880877
}
881878

882-
if (rune === '$props' || rune === '$props.bindable') {
879+
if (rune === '$props') {
883880
for (const property of /** @type {import('estree').ObjectPattern} */ (node.id).properties) {
884881
if (property.type !== 'Property') continue;
885882

@@ -891,11 +888,24 @@ const runes_scope_tweaker = {
891888
property.key.type === 'Identifier'
892889
? property.key.name
893890
: /** @type {string} */ (/** @type {import('estree').Literal} */ (property.key).value);
894-
const initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
891+
let initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
895892

896893
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(name));
897894
binding.prop_alias = alias;
898-
binding.initial = initial; // rewire initial from $props() to the actual initial value
895+
896+
// rewire initial from $props() to the actual initial value, stripping $bindable() if necessary
897+
if (
898+
initial?.type === 'CallExpression' &&
899+
initial.callee.type === 'Identifier' &&
900+
initial.callee.name === '$bindable'
901+
) {
902+
binding.initial = /** @type {import('estree').Expression | null} */ (
903+
initial.arguments[0] ?? null
904+
);
905+
binding.kind = 'bindable_prop';
906+
} else {
907+
binding.initial = initial;
908+
}
899909
}
900910
}
901911
},

packages/svelte/src/compiler/phases/2-analyze/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface AnalysisState {
1515
options: ValidatedCompileOptions;
1616
ast_type: 'instance' | 'template' | 'module';
1717
parent_element: string | null;
18-
has_props_rune: [props: boolean, bindings: boolean];
18+
has_props_rune: boolean;
1919
/** Which slots the current parent component has */
2020
component_slots: Set<string>;
2121
/** The current {expression}, if any */

packages/svelte/src/compiler/phases/2-analyze/validation.js

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -775,11 +775,24 @@ function validate_call_expression(node, scope, path) {
775775

776776
const parent = /** @type {import('#compiler').SvelteNode} */ (get_parent(path, -1));
777777

778-
if (rune === '$props' || rune === '$props.bindable') {
778+
if (rune === '$props') {
779779
if (parent.type === 'VariableDeclarator') return;
780780
error(node, 'invalid-props-location');
781781
}
782782

783+
if (rune === '$bindable') {
784+
if (parent.type === 'AssignmentPattern' && path.at(-3)?.type === 'ObjectPattern') {
785+
const declarator = path.at(-4);
786+
if (
787+
declarator?.type === 'VariableDeclarator' &&
788+
get_rune(declarator.init, scope) === '$props'
789+
) {
790+
return;
791+
}
792+
}
793+
error(node, 'invalid-bindable-location');
794+
}
795+
783796
if (
784797
rune === '$state' ||
785798
rune === '$state.frozen' ||
@@ -876,7 +889,7 @@ export const validation_runes_js = {
876889
error(node, 'invalid-rune-args-length', rune, [1]);
877890
} else if (rune === '$state' && args.length > 1) {
878891
error(node, 'invalid-rune-args-length', rune, [0, 1]);
879-
} else if (rune === '$props' || rune === '$props.bindable') {
892+
} else if (rune === '$props') {
880893
error(node, 'invalid-props-location');
881894
}
882895
},
@@ -1059,15 +1072,12 @@ export const validation_runes = merge(validation, a11y_validators, {
10591072
error(node, 'invalid-rune-args-length', rune, [1]);
10601073
} else if (rune === '$state' && args.length > 1) {
10611074
error(node, 'invalid-rune-args-length', rune, [0, 1]);
1062-
} else if (rune === '$props' || rune === '$props.bindable') {
1063-
if (
1064-
(rune === '$props' && state.has_props_rune[0]) ||
1065-
(rune === '$props.bindable' && state.has_props_rune[1])
1066-
) {
1075+
} else if (rune === '$props') {
1076+
if (rune === '$props' && state.has_props_rune) {
10671077
error(node, 'duplicate-props-rune');
10681078
}
10691079

1070-
state.has_props_rune[rune === '$props' ? 0 : 1] = true;
1080+
state.has_props_rune = true;
10711081

10721082
if (args.length > 0) {
10731083
error(node, 'invalid-rune-args-length', rune, [0]);

packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export const javascript_visitors_runes = {
192192
continue;
193193
}
194194

195-
if (rune === '$props' || rune === '$props.bindable') {
195+
if (rune === '$props') {
196196
assert.equal(declarator.id.type, 'ObjectPattern');
197197

198198
/** @type {string[]} */
@@ -207,17 +207,14 @@ export const javascript_visitors_runes = {
207207

208208
seen.push(name);
209209

210-
let id = property.value;
211-
let initial = undefined;
212-
213-
if (property.value.type === 'AssignmentPattern') {
214-
id = property.value.left;
215-
initial = /** @type {import('estree').Expression} */ (visit(property.value.right));
216-
}
217-
210+
let id =
211+
property.value.type === 'AssignmentPattern' ? property.value.left : property.value;
218212
assert.equal(id.type, 'Identifier');
219-
220213
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(id.name));
214+
let initial = /** @type {import('estree').Expression | null} */ (binding.initial);
215+
if (initial) {
216+
initial = /** @type {import('estree').Expression} */ (visit(initial));
217+
}
221218

222219
if (binding.reassigned || state.analysis.accessors || initial) {
223220
declarations.push(b.declarator(id, get_prop_source(binding, state, name, initial)));
@@ -226,9 +223,6 @@ export const javascript_visitors_runes = {
226223
// RestElement
227224
/** @type {import('estree').Expression[]} */
228225
const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))];
229-
if (rune === '$props.bindable') {
230-
args.push(b.literal(true));
231-
}
232226
declarations.push(b.declarator(property.argument, b.call('$.rest_props', ...args)));
233227
}
234228
}

packages/svelte/src/compiler/phases/3-transform/server/transform-server.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,8 +690,22 @@ const javascript_visitors_runes = {
690690
continue;
691691
}
692692

693-
if (rune === '$props' || rune === '$props.bindable') {
694-
declarations.push(b.declarator(declarator.id, b.id('$$props')));
693+
if (rune === '$props') {
694+
// remove $bindable() from props declaration
695+
const id = walk(declarator.id, null, {
696+
AssignmentPattern(node) {
697+
if (
698+
node.right.type === 'CallExpression' &&
699+
get_rune(node.right, state.scope) === '$bindable'
700+
) {
701+
const right = node.right.arguments.length
702+
? /** @type {import('estree').Expression} */ (visit(node.right.arguments[0]))
703+
: b.id('undefined');
704+
return b.assignment_pattern(node.left, right);
705+
}
706+
}
707+
});
708+
declarations.push(b.declarator(id, b.id('$$props')));
695709
continue;
696710
}
697711

packages/svelte/src/compiler/phases/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const Runes = /** @type {const} */ ([
3232
'$state',
3333
'$state.frozen',
3434
'$props',
35-
'$props.bindable',
35+
'$bindable',
3636
'$derived',
3737
'$derived.by',
3838
'$effect',

packages/svelte/src/compiler/utils/builders.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export function array_pattern(elements) {
1717
return { type: 'ArrayPattern', elements };
1818
}
1919

20+
/**
21+
* @param {import('estree').Pattern} left
22+
* @param {import('estree').Expression} right
23+
* @returns {import('estree').AssignmentPattern}
24+
*/
25+
export function assignment_pattern(left, right) {
26+
return { type: 'AssignmentPattern', left, right };
27+
}
28+
2029
/**
2130
* @param {Array<import('estree').Pattern>} params
2231
* @param {import('estree').BlockStatement | import('estree').Expression} body

packages/svelte/src/internal/client/reactivity/props.js

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,39 +36,29 @@ export function update_pre_prop(fn, d = 1) {
3636
/**
3737
* The proxy handler for rest props (i.e. `const { x, ...rest } = $props()`).
3838
* Is passed the full `$$props` object and excludes the named props.
39-
* @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Array<string | symbol>, p: boolean }>}}
39+
* @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Array<string | symbol> }>}}
4040
*/
4141
const rest_props_handler = {
4242
get(target, key) {
4343
if (target.exclude.includes(key)) return;
4444
return target.props[key];
4545
},
46-
set(target, key, value) {
47-
if (target.exclude.includes(key) || !(key in target.props)) return false;
46+
set(_, key) {
4847
if (DEV) {
49-
if (!target.p) {
50-
throw new Error(
51-
`Cannot set read-only property '${String(key)}' of rest element of $props(). Only rest elements from $props.bindable() can be written to.'`
52-
);
53-
} else if (!get_descriptor(target.props, key)?.set) {
54-
throw new Error(
55-
`Cannot write to property '${String(key)}' of rest element of $props.bindable(). It is readonly because it was not declared using bind: on the consumer component.`
56-
);
57-
}
48+
throw new Error(
49+
`Cannot write to property '${String(key)}' of rest element of $props(). It is always readonly.`
50+
);
5851
}
59-
target.props[key] = value;
60-
return true;
52+
return false;
6153
},
6254
getOwnPropertyDescriptor(target, key) {
6355
if (target.exclude.includes(key)) return;
6456
if (key in target.props) {
65-
return target.p
66-
? get_descriptor(target.props, key)
67-
: {
68-
enumerable: true,
69-
configurable: true,
70-
value: target.props[key]
71-
};
57+
return {
58+
enumerable: true,
59+
configurable: true,
60+
value: target.props[key]
61+
};
7262
}
7363
},
7464
has(target, key) {
@@ -83,11 +73,10 @@ const rest_props_handler = {
8373
/**
8474
* @param {Record<string, unknown>} props
8575
* @param {string[]} rest
86-
* @param {boolean} [preserve_setters]
8776
* @returns {Record<string, unknown>}
8877
*/
89-
export function rest_props(props, rest, preserve_setters = false) {
90-
return new Proxy({ props, exclude: rest, p: preserve_setters }, rest_props_handler);
78+
export function rest_props(props, rest) {
79+
return new Proxy({ props, exclude: rest }, rest_props_handler);
9180
}
9281

9382
/**

packages/svelte/src/internal/client/validate.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export function validate_prop_bindings($$props, bindable) {
150150
if (setter) {
151151
throw new Error(
152152
`Cannot use bind:${key} on this component because the property was not declared as bindable. ` +
153-
`To mark a property as bindable, use let \`{ ${key} } = $props.bindable()\` within the component.`
153+
`To mark a property as bindable, use the $bindable() rune like this: \`let { ${key} = $bindable() } = $props()\``
154154
);
155155
}
156156
}

packages/svelte/src/main/ambient.d.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -172,27 +172,23 @@ declare namespace $effect {
172172
* Declares the props that a component accepts. Example:
173173
*
174174
* ```ts
175-
* let { optionalProp = 42, requiredProp }: { optionalProp?: number; requiredProps: string } = $props();
175+
* let { optionalProp = 42, requiredProp, bindableProp = $bindable() }: { optionalProp?: number; requiredProps: string; bindableProp: boolean } = $props();
176176
* ```
177177
*
178-
* Props declared with `$props()` cannot be used with `bind:`, use `$props.bindable()` for these instead.
179-
*
180178
* https://svelte-5-preview.vercel.app/docs/runes#$props
181179
*/
182180
declare function $props(): any;
183181

184-
declare namespace $props {
185-
/**
186-
* Declares the props that a component accepts and which consumers can `bind:` to. Example:
187-
*
188-
* ```ts
189-
* let { optionalProp, requiredProp }: { optionalProp?: number; requiredProps: string } = $props.bindable();
190-
* ```
191-
*
192-
* https://svelte-5-preview.vercel.app/docs/runes#$props
193-
*/
194-
function bindable(): any;
195-
}
182+
/**
183+
* Declares a prop as bindable, meaning the parent component can use `bind:propName={value}` to bind to it.
184+
*
185+
* ```ts
186+
* let { propName = $bindable() }: { propName: boolean } = $props();
187+
* ```
188+
*
189+
* https://svelte-5-preview.vercel.app/docs/runes#$bindable
190+
*/
191+
declare function $bindable<T>(t?: T): T;
196192

197193
/**
198194
* Inspects one or more values whenever they, or the properties they contain, change. Example:
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<script>
2-
let { value: _, ...properties } = $props();
3-
let { value } = $props.bindable();
2+
let { value = $bindable(), ...properties } = $props();
43
</script>
54

65
<button {...properties} onclick={() => value++}>{value}</button>
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<script>
2-
let { checked: _, ...rest } = $props();
3-
let { checked } = $props.bindable();
2+
let { checked = $bindable(), ...rest } = $props();
43
</script>
54

65
<input type="checkbox" bind:checked {...rest} />

packages/svelte/tests/runtime-runes/samples/each-bind-this-member/main.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script>
2-
let { items = [{ src: 'https://ds' }] } = $props.bindable();
2+
let { items = $bindable([{ src: 'https://ds' }]) } = $props();
33
</script>
44

55
{#each items as item, i}

packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/Counter.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script>
22
/** @type {{ object: { count: number }}} */
3-
let { object } = $props.bindable();
3+
let { object = $bindable() } = $props();
44
</script>
55

66
<button onclick={() => object.count += 1}>

packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner-5/sub.svelte

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<script>
2-
let { inc } = $props();
3-
let { count } = $props.bindable();
2+
let { inc, count = $bindable() } = $props();
43
</script>
54

65
<button onclick={inc}>{count.a} (ok)</button>

packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/Counter.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script>
22
/** @type {{ object: { count: number }}} */
3-
let { object } = $props.bindable();
3+
let { object = $bindable() } = $props();
44
</script>
55

66
<button onclick={() => object.count += 1}>

0 commit comments

Comments
 (0)