Skip to content

Commit bf23f69

Browse files
authored
fix: robustify migration script (#12019)
* handle multiple slots of same kind correctly * put props below imports * migrate $$slots, better error position for slot-children-conflict error * handle event braces+modifier case * migrate jsdoc type with curly braces correctly * handle comments above types, fixes #11508 * changeset
1 parent 4cdc371 commit bf23f69

File tree

16 files changed

+178
-76
lines changed

16 files changed

+178
-76
lines changed

.changeset/clean-cats-wave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: robustify migration script

packages/svelte/src/compiler/migrate/index.js

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ export function migrate(source) {
6363

6464
if (state.props.length > 0 || analysis.uses_rest_props || analysis.uses_props) {
6565
const has_many_props = state.props.length > 3;
66-
const props_separator = has_many_props ? `\n${indent}${indent}` : ' ';
66+
const newline_separator = `\n${indent}${indent}`;
67+
const props_separator = has_many_props ? newline_separator : ' ';
6768
let props = '';
6869
if (analysis.uses_props) {
6970
props = `...${state.props_name}`;
@@ -99,11 +100,12 @@ export function migrate(source) {
99100
if (analysis.uses_props || analysis.uses_rest_props) {
100101
type = `interface ${type_name} { [key: string]: any }`;
101102
} else {
102-
type = `interface ${type_name} {${props_separator}${state.props
103+
type = `interface ${type_name} {${newline_separator}${state.props
103104
.map((prop) => {
104-
return `${prop.exported}${prop.optional ? '?' : ''}: ${prop.type}`;
105+
const comment = prop.comment ? `${prop.comment}${newline_separator}` : '';
106+
return `${comment}${prop.exported}${prop.optional ? '?' : ''}: ${prop.type};`;
105107
})
106-
.join(`,${props_separator}`)}${has_many_props ? `\n${indent}` : ' '}}`;
108+
.join(newline_separator)}\n${indent}}`;
107109
}
108110
} else {
109111
if (analysis.uses_props || analysis.uses_rest_props) {
@@ -162,7 +164,7 @@ export function migrate(source) {
162164
* str: MagicString;
163165
* analysis: import('../phases/types.js').ComponentAnalysis;
164166
* indent: string;
165-
* props: Array<{ local: string; exported: string; init: string; bindable: boolean; optional: boolean; type: string }>;
167+
* props: Array<{ local: string; exported: string; init: string; bindable: boolean; slot_name?: string; optional: boolean; type: string; comment?: string }>;
166168
* props_insertion_point: number;
167169
* has_props_rune: boolean;
168170
* props_name: string;
@@ -190,8 +192,11 @@ const instance_script = {
190192
}
191193
next();
192194
},
193-
Identifier(node, { state }) {
194-
handle_identifier(node, state);
195+
Identifier(node, { state, path }) {
196+
handle_identifier(node, state, path);
197+
},
198+
ImportDeclaration(node, { state }) {
199+
state.props_insertion_point = node.end ?? state.props_insertion_point;
195200
},
196201
ExportNamedDeclaration(node, { state, next }) {
197202
if (node.declaration) {
@@ -299,8 +304,8 @@ const instance_script = {
299304
)
300305
: '',
301306
optional: !!declarator.init,
302-
type: extract_type(declarator, state.str, path),
303-
bindable: binding.mutated || binding.reassigned
307+
bindable: binding.mutated || binding.reassigned,
308+
...extract_type_and_comment(declarator, state.str, path)
304309
});
305310
state.props_insertion_point = /** @type {number} */ (declarator.end);
306311
state.str.update(
@@ -423,8 +428,8 @@ const instance_script = {
423428

424429
/** @type {import('zimmerframe').Visitors<import('../types/template.js').SvelteNode, State>} */
425430
const template = {
426-
Identifier(node, { state }) {
427-
handle_identifier(node, state);
431+
Identifier(node, { state, path }) {
432+
handle_identifier(node, state, path);
428433
},
429434
RegularElement(node, { state, next }) {
430435
handle_events(node, state);
@@ -468,14 +473,15 @@ const template = {
468473
},
469474
SlotElement(node, { state, next }) {
470475
let name = 'children';
476+
let slot_name = 'default';
471477
let slot_props = '{ ';
472478

473479
for (const attr of node.attributes) {
474480
if (attr.type === 'SpreadAttribute') {
475481
slot_props += `...${state.str.original.substring(/** @type {number} */ (attr.expression.start), attr.expression.end)}, `;
476482
} else if (attr.type === 'Attribute') {
477483
if (attr.name === 'name') {
478-
name = state.scope.generate(/** @type {any} */ (attr.value)[0].data);
484+
slot_name = /** @type {any} */ (attr.value)[0].data;
479485
} else {
480486
const value =
481487
attr.value !== true
@@ -494,14 +500,24 @@ const template = {
494500
slot_props = '';
495501
}
496502

497-
state.props.push({
498-
local: name,
499-
exported: name,
500-
init: '',
501-
bindable: false,
502-
optional: true,
503-
type: `import('svelte').${slot_props ? 'Snippet<[any]>' : 'Snippet'}`
504-
});
503+
const existing_prop = state.props.find((prop) => prop.slot_name === slot_name);
504+
if (existing_prop) {
505+
name = existing_prop.local;
506+
} else if (slot_name !== 'default') {
507+
name = state.scope.generate(slot_name);
508+
}
509+
510+
if (!existing_prop) {
511+
state.props.push({
512+
local: name,
513+
exported: name,
514+
init: '',
515+
bindable: false,
516+
optional: true,
517+
slot_name,
518+
type: `import('svelte').${slot_props ? 'Snippet<[any]>' : 'Snippet'}`
519+
});
520+
}
505521

506522
if (node.fragment.nodes.length > 0) {
507523
next();
@@ -528,37 +544,46 @@ const template = {
528544
* @param {MagicString} str
529545
* @param {import('#compiler').SvelteNode[]} path
530546
*/
531-
function extract_type(declarator, str, path) {
547+
function extract_type_and_comment(declarator, str, path) {
548+
const parent = path.at(-1);
549+
550+
// Try to find jsdoc above the declaration
551+
let comment_node = /** @type {import('estree').Node} */ (parent)?.leadingComments?.at(-1);
552+
if (comment_node?.type !== 'Block') comment_node = undefined;
553+
554+
const comment_start = /** @type {any} */ (comment_node)?.start;
555+
const comment_end = /** @type {any} */ (comment_node)?.end;
556+
const comment = comment_node && str.original.substring(comment_start, comment_end);
557+
558+
if (comment_node) {
559+
str.update(comment_start, comment_end, '');
560+
}
561+
532562
if (declarator.id.typeAnnotation) {
533563
let start = declarator.id.typeAnnotation.start + 1; // skip the colon
534564
while (str.original[start] === ' ') {
535565
start++;
536566
}
537-
return str.original.substring(start, declarator.id.typeAnnotation.end);
567+
return { type: str.original.substring(start, declarator.id.typeAnnotation.end), comment };
538568
}
539569

540570
// try to find a comment with a type annotation, hinting at jsdoc
541-
const parent = path.at(-1);
542-
if (parent?.type === 'ExportNamedDeclaration' && parent.leadingComments) {
543-
const last = parent.leadingComments[parent.leadingComments.length - 1];
544-
if (last.type === 'Block') {
545-
const match = /@type {([^}]+)}/.exec(last.value);
546-
if (match) {
547-
str.update(/** @type {any} */ (last).start, /** @type {any} */ (last).end, '');
548-
return match[1];
549-
}
571+
if (parent?.type === 'ExportNamedDeclaration' && comment_node) {
572+
const match = /@type {(.+)}/.exec(comment_node.value);
573+
if (match) {
574+
return { type: match[1] };
550575
}
551576
}
552577

553578
// try to infer it from the init
554579
if (declarator.init?.type === 'Literal') {
555580
const type = typeof declarator.init.value;
556581
if (type === 'string' || type === 'number' || type === 'boolean') {
557-
return type;
582+
return { type, comment };
558583
}
559584
}
560585

561-
return 'any';
586+
return { type: 'any', comment };
562587
}
563588

564589
/**
@@ -694,14 +719,16 @@ function handle_events(node, state) {
694719
}
695720

696721
const needs_curlies = last.expression.body.type !== 'BlockStatement';
697-
state.str.prependRight(
698-
/** @type {number} */ (pos) + (needs_curlies ? 0 : 1),
699-
`${needs_curlies ? '{' : ''}${prepend}${state.indent}`
700-
);
701-
state.str.appendRight(
702-
/** @type {number} */ (last.expression.body.end) - (needs_curlies ? 0 : 1),
703-
`\n${needs_curlies ? '}' : ''}`
704-
);
722+
const end = /** @type {number} */ (last.expression.body.end) - (needs_curlies ? 0 : 1);
723+
pos = /** @type {number} */ (pos) + (needs_curlies ? 0 : 1);
724+
if (needs_curlies && state.str.original[pos - 1] === '(') {
725+
// Prettier does something like on:click={() => (foo = true)}, we need to remove the braces in this case
726+
state.str.update(pos - 1, pos, `{${prepend}${state.indent}`);
727+
state.str.update(end, end + 1, '\n}');
728+
} else {
729+
state.str.prependRight(pos, `${needs_curlies ? '{' : ''}${prepend}${state.indent}`);
730+
state.str.appendRight(end, `\n${needs_curlies ? '}' : ''}`);
731+
}
705732
} else {
706733
state.str.update(
707734
/** @type {number} */ (last.expression.start),
@@ -763,8 +790,12 @@ function generate_event_name(last, state) {
763790
/**
764791
* @param {import('estree').Identifier} node
765792
* @param {State} state
793+
* @param {any[]} path
766794
*/
767-
function handle_identifier(node, state) {
795+
function handle_identifier(node, state, path) {
796+
const parent = path.at(-1);
797+
if (parent?.type === 'MemberExpression' && parent.property === node) return;
798+
768799
if (state.analysis.uses_props) {
769800
if (node.name === '$$props' || node.name === '$$restProps') {
770801
// not 100% correct for $$restProps but it'll do
@@ -785,6 +816,11 @@ function handle_identifier(node, state) {
785816
/** @type {number} */ (node.end),
786817
state.rest_props_name
787818
);
819+
} else if (node.name === '$$slots' && state.analysis.uses_slots) {
820+
if (parent?.type === 'MemberExpression') {
821+
state.str.update(/** @type {number} */ (node.start), parent.property.start, '');
822+
}
823+
// else passed as identifier, we don't know what to do here, so let it error
788824
}
789825
}
790826

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,8 @@ export function analyze_component(root, source, options) {
554554
}
555555

556556
if (analysis.uses_render_tags && (analysis.uses_slots || analysis.slot_names.size > 0)) {
557-
e.slot_snippet_conflict(analysis.slot_names.values().next().value);
557+
const pos = analysis.slot_names.values().next().value ?? analysis.source.indexOf('$$slot');
558+
e.slot_snippet_conflict(pos);
558559
}
559560

560561
if (analysis.css.ast) {

packages/svelte/tests/migrate/samples/event-handlers/input.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<button on:click={() => console.log('hi')} on:click>click me</button>
2-
<button on:click={() => console.log('before')} on:click on:click={() => console.log('after')}>click me</button>
2+
<button on:click={() => console.log('before')} on:click on:click={() => console.log('after')}
3+
>click me</button
4+
>
35
<button on:click on:click={foo}>click me</button>
46
<button on:click>click me</button>
57

@@ -9,6 +11,7 @@
911
<button on:custom-event-bubble>click me</button>
1012

1113
<button on:click|preventDefault={() => ''}>click me</button>
14+
<button on:click|preventDefault={() => (searching = true)}>click me</button>
1215
<button on:click|stopPropagation={() => {}}>click me</button>
1316
<button on:click|stopImmediatePropagation={() => ''}>click me</button>
1417
<button on:click|capture={() => ''}>click me</button>

packages/svelte/tests/migrate/samples/event-handlers/output.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
onclick?.(event);
1515
console.log('after')
16-
}}>click me</button>
16+
}}
17+
>click me</button
18+
>
1719
<button onclick={(event) => {
1820
onclick?.(event);
1921

@@ -30,6 +32,10 @@
3032
event.preventDefault();
3133
''
3234
}}>click me</button>
35+
<button onclick={(event) => {
36+
event.preventDefault();
37+
searching = true
38+
}}>click me</button>
3339
<button onclick={(event) => {
3440
event.stopPropagation();
3541

packages/svelte/tests/migrate/samples/props-export-alias/output.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script lang="ts">
2-
interface Props { class?: string }
2+
interface Props {
3+
class?: string;
4+
}
35
46
let { class: klass = '' }: Props = $props();
57

packages/svelte/tests/migrate/samples/props-ts/input.svelte

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script lang="ts">
2-
export let readonly: number;
3-
export let optional = 'foo';
4-
export let binding: string;
5-
export let bindingOptional: string | undefined = 'bar';
2+
/** some comment */
3+
export let readonly: number;
4+
export let optional = 'foo';
5+
export let binding: string;
6+
export let bindingOptional: string | undefined = 'bar';
67
</script>
78

89
{readonly}

packages/svelte/tests/migrate/samples/props-ts/output.svelte

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
<script lang="ts">
2-
interface Props {
3-
readonly: number,
4-
optional?: string,
5-
binding: string,
6-
bindingOptional?: string | undefined
7-
}
2+
3+
interface Props {
4+
/** some comment */
5+
readonly: number;
6+
optional?: string;
7+
binding: string;
8+
bindingOptional?: string | undefined;
9+
}
810
9-
let {
10-
readonly,
11-
optional = 'foo',
12-
binding = $bindable(),
13-
bindingOptional = $bindable('bar')
14-
}: Props = $props();
11+
let {
12+
readonly,
13+
optional = 'foo',
14+
binding = $bindable(),
15+
bindingOptional = $bindable('bar')
16+
}: Props = $props();
1517
</script>
1618

1719
{readonly}

packages/svelte/tests/migrate/samples/props/input.svelte

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script>
2-
export let readonly;
3-
export let optional = 'foo';
4-
export let binding;
5-
export let bindingOptional = 'bar';
2+
/** @type {Record<string, { href: string; title: string; }[]>} */
3+
export let readonly;
4+
export let optional = 'foo';
5+
export let binding;
6+
export let bindingOptional = 'bar';
67
</script>
78

89
{readonly}

packages/svelte/tests/migrate/samples/props/output.svelte

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
<script>
2-
/** @type {{readonly: any, optional?: string, binding: any, bindingOptional?: string}} */
3-
let {
4-
readonly,
5-
optional = 'foo',
6-
binding = $bindable(),
7-
bindingOptional = $bindable('bar')
8-
} = $props();
2+
3+
/** @type {{readonly: Record<string, { href: string; title: string; }[]>, optional?: string, binding: any, bindingOptional?: string}} */
4+
let {
5+
readonly,
6+
optional = 'foo',
7+
binding = $bindable(),
8+
bindingOptional = $bindable('bar')
9+
} = $props();
910
</script>
1011

1112
{readonly}

0 commit comments

Comments
 (0)