Skip to content

Commit 416bc85

Browse files
authored
breaking: add $bindable() rune to denote bindable props (#10851)
Alternative to / closes #10804 closes #10768 closes #10711
1 parent 2cabc88 commit 416bc85

File tree

46 files changed

+330
-93
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+330
-93
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 inside a $props() declaration`,
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: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ export function analyze_component(root, options) {
436436
);
437437
}
438438
} else {
439-
instance.scope.declare(b.id('$$props'), 'prop', 'synthetic');
439+
instance.scope.declare(b.id('$$props'), 'bindable_prop', 'synthetic');
440440
instance.scope.declare(b.id('$$restProps'), 'rest_prop', 'synthetic');
441441

442442
for (const { ast, scope, scopes } of [module, instance, template]) {
@@ -466,7 +466,10 @@ export function analyze_component(root, options) {
466466
}
467467

468468
for (const [name, binding] of instance.scope.declarations) {
469-
if (binding.kind === 'prop' && binding.node.name !== '$$props') {
469+
if (
470+
(binding.kind === 'prop' || binding.kind === 'bindable_prop') &&
471+
binding.node.name !== '$$props'
472+
) {
470473
const references = binding.references.filter(
471474
(r) => r.node !== binding.node && r.path.at(-1)?.type !== 'ExportSpecifier'
472475
);
@@ -759,7 +762,7 @@ const legacy_scope_tweaker = {
759762
(binding.kind === 'normal' &&
760763
(binding.declaration_kind === 'let' || binding.declaration_kind === 'var')))
761764
) {
762-
binding.kind = 'prop';
765+
binding.kind = 'bindable_prop';
763766
if (specifier.exported.name !== specifier.local.name) {
764767
binding.prop_alias = specifier.exported.name;
765768
}
@@ -797,7 +800,7 @@ const legacy_scope_tweaker = {
797800
for (const declarator of node.declaration.declarations) {
798801
for (const id of extract_identifiers(declarator.id)) {
799802
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(id.name));
800-
binding.kind = 'prop';
803+
binding.kind = 'bindable_prop';
801804
}
802805
}
803806
}
@@ -886,11 +889,24 @@ const runes_scope_tweaker = {
886889
property.key.type === 'Identifier'
887890
? property.key.name
888891
: /** @type {string} */ (/** @type {import('estree').Literal} */ (property.key).value);
889-
const initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
892+
let initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
890893

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

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,17 +299,19 @@ const validation = {
299299
error(node, 'invalid-binding-expression');
300300
}
301301

302+
const binding = context.state.scope.get(left.name);
303+
302304
if (
303305
assignee.type === 'Identifier' &&
304306
node.name !== 'this' // bind:this also works for regular variables
305307
) {
306-
const binding = context.state.scope.get(left.name);
307308
// reassignment
308309
if (
309310
!binding ||
310311
(binding.kind !== 'state' &&
311312
binding.kind !== 'frozen_state' &&
312313
binding.kind !== 'prop' &&
314+
binding.kind !== 'bindable_prop' &&
313315
binding.kind !== 'each' &&
314316
binding.kind !== 'store_sub' &&
315317
!binding.mutated)
@@ -328,8 +330,6 @@ const validation = {
328330
// TODO handle mutations of non-state/props in runes mode
329331
}
330332

331-
const binding = context.state.scope.get(left.name);
332-
333333
if (node.name === 'group') {
334334
if (!binding) {
335335
error(node, 'INTERNAL', 'Cannot find declaration for bind:group');
@@ -780,7 +780,25 @@ function validate_call_expression(node, scope, path) {
780780
error(node, 'invalid-props-location');
781781
}
782782

783-
if (rune === '$state' || rune === '$derived' || rune === '$derived.by') {
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+
796+
if (
797+
rune === '$state' ||
798+
rune === '$state.frozen' ||
799+
rune === '$derived' ||
800+
rune === '$derived.by'
801+
) {
784802
if (parent.type === 'VariableDeclarator') return;
785803
if (parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) return;
786804
error(node, 'invalid-state-location', rune);
@@ -873,6 +891,8 @@ export const validation_runes_js = {
873891
error(node, 'invalid-rune-args-length', rune, [0, 1]);
874892
} else if (rune === '$props') {
875893
error(node, 'invalid-props-location');
894+
} else if (rune === '$bindable') {
895+
error(node, 'invalid-bindable-location');
876896
}
877897
},
878898
AssignmentExpression(node, { state }) {
@@ -1022,6 +1042,9 @@ export const validation_runes = merge(validation, a11y_validators, {
10221042
}
10231043
},
10241044
CallExpression(node, { state, path }) {
1045+
if (get_rune(node, state.scope) === '$bindable' && node.arguments.length > 1) {
1046+
error(node, 'invalid-rune-args-length', '$bindable', [0, 1]);
1047+
}
10251048
validate_call_expression(node, state.scope, path);
10261049
},
10271050
EachBlock(node, { next, state }) {
@@ -1062,7 +1085,7 @@ export const validation_runes = merge(validation, a11y_validators, {
10621085
state.has_props_rune = true;
10631086

10641087
if (args.length > 0) {
1065-
error(node, 'invalid-rune-args-length', '$props', [0]);
1088+
error(node, 'invalid-rune-args-length', rune, [0]);
10661089
}
10671090

10681091
if (node.id.type !== 'ObjectPattern') {

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

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ export function client_component(source, analysis, options) {
239239
);
240240
});
241241

242-
const properties = analysis.exports.map(({ name, alias }) => {
242+
const component_returned_object = analysis.exports.map(({ name, alias }) => {
243243
const expression = serialize_get_binding(b.id(name), instance_state);
244244

245245
if (expression.type === 'Identifier' && !options.dev) {
@@ -249,10 +249,26 @@ export function client_component(source, analysis, options) {
249249
return b.get(alias ?? name, [b.return(expression)]);
250250
});
251251

252-
if (analysis.accessors) {
253-
for (const [name, binding] of analysis.instance.scope.declarations) {
254-
if (binding.kind !== 'prop' || name.startsWith('$$')) continue;
252+
const properties = [...analysis.instance.scope.declarations].filter(
253+
([name, binding]) =>
254+
(binding.kind === 'prop' || binding.kind === 'bindable_prop') && !name.startsWith('$$')
255+
);
255256

257+
if (analysis.runes && options.dev) {
258+
/** @type {import('estree').Literal[]} */
259+
const bindable = [];
260+
for (const [name, binding] of properties) {
261+
if (binding.kind === 'bindable_prop') {
262+
bindable.push(b.literal(binding.prop_alias ?? name));
263+
}
264+
}
265+
instance.body.unshift(
266+
b.stmt(b.call('$.validate_prop_bindings', b.id('$$props'), b.array(bindable)))
267+
);
268+
}
269+
270+
if (analysis.accessors) {
271+
for (const [name, binding] of properties) {
256272
const key = binding.prop_alias ?? name;
257273

258274
const getter = b.get(key, [b.return(b.call(b.id(name)))]);
@@ -271,12 +287,12 @@ export function client_component(source, analysis, options) {
271287
};
272288
}
273289

274-
properties.push(getter, setter);
290+
component_returned_object.push(getter, setter);
275291
}
276292
}
277293

278294
if (options.legacy.componentApi) {
279-
properties.push(
295+
component_returned_object.push(
280296
b.init('$set', b.id('$.update_legacy_props')),
281297
b.init(
282298
'$on',
@@ -292,7 +308,7 @@ export function client_component(source, analysis, options) {
292308
)
293309
);
294310
} else if (options.dev) {
295-
properties.push(
311+
component_returned_object.push(
296312
b.init(
297313
'$set',
298314
b.thunk(
@@ -360,16 +376,16 @@ export function client_component(source, analysis, options) {
360376

361377
append_styles();
362378
component_block.body.push(
363-
properties.length > 0
364-
? b.return(b.call('$.pop', b.object(properties)))
379+
component_returned_object.length > 0
380+
? b.return(b.call('$.pop', b.object(component_returned_object)))
365381
: b.stmt(b.call('$.pop'))
366382
);
367383

368384
if (analysis.uses_rest_props) {
369385
/** @type {string[]} */
370386
const named_props = analysis.exports.map(({ name, alias }) => alias ?? name);
371387
for (const [name, binding] of analysis.instance.scope.declarations) {
372-
if (binding.kind === 'prop') named_props.push(binding.prop_alias ?? name);
388+
if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name);
373389
}
374390

375391
component_block.body.unshift(
@@ -476,9 +492,7 @@ export function client_component(source, analysis, options) {
476492
/** @type {import('estree').Property[]} */
477493
const props_str = [];
478494

479-
for (const [name, binding] of analysis.instance.scope.declarations) {
480-
if (binding.kind !== 'prop' || name.startsWith('$$')) continue;
481-
495+
for (const [name, binding] of properties) {
482496
const key = binding.prop_alias ?? name;
483497
const prop_def = typeof ce === 'boolean' ? {} : ce.props?.[key] || {};
484498
if (

packages/svelte/src/compiler/phases/3-transform/client/utils.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export function serialize_get_binding(node, state) {
7878
return typeof binding.expression === 'function' ? binding.expression(node) : binding.expression;
7979
}
8080

81-
if (binding.kind === 'prop') {
81+
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') {
8282
if (binding.node.name === '$$props') {
8383
// Special case for $$props which only exists in the old world
8484
// TODO this probably shouldn't have a 'prop' binding kind
@@ -377,6 +377,7 @@ export function serialize_set_binding(node, context, fallback, options) {
377377
binding.kind !== 'state' &&
378378
binding.kind !== 'frozen_state' &&
379379
binding.kind !== 'prop' &&
380+
binding.kind !== 'bindable_prop' &&
380381
binding.kind !== 'each' &&
381382
binding.kind !== 'legacy_reactive' &&
382383
!is_store
@@ -389,7 +390,7 @@ export function serialize_set_binding(node, context, fallback, options) {
389390

390391
const serialize = () => {
391392
if (left === node.left) {
392-
if (binding.kind === 'prop') {
393+
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') {
393394
return b.call(left, value);
394395
} else if (is_store) {
395396
return b.call('$.store_set', serialize_get_binding(b.id(left_name), state), value);
@@ -467,7 +468,7 @@ export function serialize_set_binding(node, context, fallback, options) {
467468
b.call('$.untrack', b.id('$' + left_name))
468469
);
469470
} else if (!state.analysis.runes) {
470-
if (binding.kind === 'prop') {
471+
if (binding.kind === 'bindable_prop') {
471472
return b.call(
472473
left,
473474
b.sequence([
@@ -571,7 +572,7 @@ function get_hoistable_params(node, context) {
571572
params.push(b.id(binding.expression.object.arguments[0].name));
572573
} else if (
573574
// If we are referencing a simple $$props value, then we need to reference the object property instead
574-
binding.kind === 'prop' &&
575+
(binding.kind === 'prop' || binding.kind === 'bindable_prop') &&
575576
!binding.reassigned &&
576577
binding.initial === null &&
577578
!context.state.analysis.accessors

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const global_visitors = {
5252
binding?.kind === 'each' ||
5353
binding?.kind === 'legacy_reactive' ||
5454
binding?.kind === 'prop' ||
55+
binding?.kind === 'bindable_prop' ||
5556
is_store
5657
) {
5758
/** @type {import('estree').Expression[]} */
@@ -64,7 +65,7 @@ export const global_visitors = {
6465
fn += '_store';
6566
args.push(serialize_get_binding(b.id(name), state), b.call('$' + name));
6667
} else {
67-
if (binding.kind === 'prop') fn += '_prop';
68+
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') fn += '_prop';
6869
args.push(b.id(name));
6970
}
7071

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const javascript_visitors_legacy = {
4040
state.scope.get_bindings(declarator)
4141
);
4242
const has_state = bindings.some((binding) => binding.kind === 'state');
43-
const has_props = bindings.some((binding) => binding.kind === 'prop');
43+
const has_props = bindings.some((binding) => binding.kind === 'bindable_prop');
4444

4545
if (!has_state && !has_props) {
4646
const init = declarator.init;
@@ -80,7 +80,7 @@ export const javascript_visitors_legacy = {
8080
declarations.push(
8181
b.declarator(
8282
path.node,
83-
binding.kind === 'prop'
83+
binding.kind === 'bindable_prop'
8484
? get_prop_source(binding, state, binding.prop_alias ?? name, value)
8585
: value
8686
)
@@ -168,7 +168,7 @@ export const javascript_visitors_legacy = {
168168

169169
// If the binding is a prop, we need to deep read it because it could be fine-grained $state
170170
// from a runes-component, where mutations don't trigger an update on the prop as a whole.
171-
if (name === '$$props' || name === '$$restProps' || binding.kind === 'prop') {
171+
if (name === '$$props' || name === '$$restProps' || binding.kind === 'bindable_prop') {
172172
serialized = b.call('$.deep_read_state', serialized);
173173
}
174174

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

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -207,33 +207,30 @@ 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+
const initial =
215+
binding.initial &&
216+
/** @type {import('estree').Expression} */ (visit(binding.initial));
221217

222218
if (binding.reassigned || state.analysis.accessors || initial) {
223219
declarations.push(b.declarator(id, get_prop_source(binding, state, name, initial)));
224220
}
225221
} else {
226222
// RestElement
227-
declarations.push(
228-
b.declarator(
229-
property.argument,
230-
b.call(
231-
'$.rest_props',
232-
b.id('$$props'),
233-
b.array(seen.map((name) => b.literal(name)))
234-
)
235-
)
236-
);
223+
/** @type {import('estree').Expression[]} */
224+
const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))];
225+
226+
if (state.options.dev) {
227+
// include rest name, so we can provide informative error messages
228+
args.push(
229+
b.literal(/** @type {import('estree').Identifier} */ (property.argument).name)
230+
);
231+
}
232+
233+
declarations.push(b.declarator(property.argument, b.call('$.rest_props', ...args)));
237234
}
238235
}
239236

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,7 @@ function serialize_event_handler(node, { state, visit }) {
13821382
binding.kind === 'legacy_reactive' ||
13831383
binding.kind === 'derived' ||
13841384
binding.kind === 'prop' ||
1385+
binding.kind === 'bindable_prop' ||
13851386
binding.kind === 'store_sub')
13861387
) {
13871388
handler = dynamic_handler();

0 commit comments

Comments
 (0)