feat: provide migration function (#11334)

Provides a migration function, exported as `migrate` from `svelte/compiler`, which tries its best to automatically migrate towards runes, render tags (instead of slots) and event attributes (instead of event handlers)

The preview REPL was updated with a migrate button so people can try it out in the playground.

closes #9239
pull/11347/head
Simon H 1 year ago committed by GitHub
parent f1986da755
commit cd798077b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: provide migration helper

@ -190,5 +190,5 @@ export function walk() {
} }
export { CompileError } from './errors.js'; export { CompileError } from './errors.js';
export { VERSION } from '../version.js'; export { VERSION } from '../version.js';
export { migrate } from './migrate/index.js';

@ -0,0 +1,770 @@
import MagicString from 'magic-string';
import { walk } from 'zimmerframe';
import { parse } from '../phases/1-parse/index.js';
import { analyze_component } from '../phases/2-analyze/index.js';
import { validate_component_options } from '../validate-options.js';
import { get_rune } from '../phases/scope.js';
import { reset_warnings } from '../warnings.js';
import { extract_identifiers } from '../utils/ast.js';
import { regex_is_valid_identifier } from '../phases/patterns.js';
/**
* Does a best-effort migration of Svelte code towards using runes, event attributes and render tags.
* May throw an error if the code is too complex to migrate automatically.
*
* @param {string} source
* @returns {{ code: string; }}
*/
export function migrate(source) {
try {
reset_warnings({ source, filename: 'migrate.svelte' });
let parsed = parse(source);
const { customElement: customElementOptions, ...parsed_options } = parsed.options || {};
/** @type {import('#compiler').ValidatedCompileOptions} */
const combined_options = {
...validate_component_options({}, ''),
...parsed_options,
customElementOptions
};
const str = new MagicString(source);
const analysis = analyze_component(parsed, source, combined_options);
const indent = guess_indent(source);
/** @type {State} */
let state = {
scope: analysis.instance.scope,
analysis,
str,
indent,
props: [],
props_insertion_point: parsed.instance?.content.start ?? 0,
has_props_rune: false,
props_name: analysis.root.unique('props').name,
rest_props_name: analysis.root.unique('rest').name,
end: source.length,
run_name: analysis.root.unique('run').name,
needs_run: false
};
if (parsed.instance) {
walk(parsed.instance.content, state, instance_script);
}
state = { ...state, scope: analysis.template.scope };
walk(parsed.fragment, state, template);
const run_import = `import { run${state.run_name === 'run' ? '' : `as ${state.run_name}`} } from 'svelte/legacy';`;
let added_legacy_import = false;
if (state.props.length > 0 || analysis.uses_rest_props || analysis.uses_props) {
const has_many_props = state.props.length > 3;
const props_separator = has_many_props ? `\n${indent}${indent}` : ' ';
let props = '';
if (analysis.uses_props) {
props = `...${state.props_name}`;
} else {
props = state.props
.map((prop) => {
let prop_str =
prop.local === prop.exported ? prop.local : `${prop.exported}: ${prop.local}`;
if (prop.bindable) {
prop_str += ` = $bindable(${prop.init})`;
} else if (prop.init) {
prop_str += ` = ${prop.init}`;
}
return prop_str;
})
.join(`,${props_separator}`);
if (analysis.uses_rest_props) {
props += `,${props_separator}...${state.rest_props_name}`;
}
}
if (state.has_props_rune) {
// some render tags or forwarded event attributes to add
str.appendRight(state.props_insertion_point, ` ${props},`);
} else {
const uses_ts = parsed.instance?.attributes.some(
(attr) => attr.name === 'lang' && /** @type {any} */ (attr).value[0].data === 'ts'
);
const type_name = state.scope.root.unique('Props').name;
let type = '';
if (uses_ts) {
if (analysis.uses_props || analysis.uses_rest_props) {
type = `interface ${type_name} { [key: string]: any }`;
} else {
type = `interface ${type_name} {${props_separator}${state.props
.map((prop) => {
return `${prop.exported}${prop.optional ? '?' : ''}: ${prop.type}`;
})
.join(`,${props_separator}`)}${has_many_props ? `\n${indent}` : ' '}}`;
}
} else {
if (analysis.uses_props || analysis.uses_rest_props) {
type = `{Record<string, any>}`;
} else {
type = `{${state.props
.map((prop) => {
return `${prop.exported}${prop.optional ? '?' : ''}: ${prop.type}`;
})
.join(`, `)}}`;
}
}
let props_declaration = `let {${props_separator}${props}${has_many_props ? `\n${indent}` : ' '}}`;
if (uses_ts) {
props_declaration = `${type}\n\n${indent}${props_declaration}`;
props_declaration = `${props_declaration}${type ? `: ${type_name}` : ''} = $props();`;
} else {
props_declaration = `/** @type {${type}} */\n${indent}${props_declaration}`;
props_declaration = `${props_declaration} = $props();`;
}
if (parsed.instance) {
props_declaration = `\n${indent}${props_declaration}`;
str.appendRight(state.props_insertion_point, props_declaration);
} else {
const imports = state.needs_run ? `${indent}${run_import}\n` : '';
str.prepend(`<script>\n${imports}${indent}${props_declaration}\n</script>\n\n`);
added_legacy_import = true;
}
}
}
if (state.needs_run && !added_legacy_import) {
if (parsed.instance) {
str.appendRight(
/** @type {number} */ (parsed.instance.content.start),
`\n${indent}${run_import}\n`
);
} else {
str.prepend(`<script>\n${indent}${run_import}\n</script>\n\n`);
}
}
return { code: str.toString() };
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error while migrating Svelte code');
throw e;
}
}
/**
* @typedef {{
* scope: import('../phases/scope.js').Scope;
* str: MagicString;
* analysis: import('../phases/types.js').ComponentAnalysis;
* indent: string;
* props: Array<{ local: string; exported: string; init: string; bindable: boolean; optional: boolean; type: string }>;
* props_insertion_point: number;
* has_props_rune: boolean;
* props_name: string;
* rest_props_name: string;
* end: number;
* run_name: string;
* needs_run: boolean;
* }} State
*/
/** @type {import('zimmerframe').Visitors<import('../types/template.js').SvelteNode, State>} */
const instance_script = {
Identifier(node, { state }) {
handle_identifier(node, state);
},
ExportNamedDeclaration(node, { state, next }) {
if (node.declaration) {
next();
return;
}
let count_removed = 0;
for (const specifier of node.specifiers) {
const binding = state.scope.get(specifier.local.name);
if (binding?.kind === 'bindable_prop') {
state.str.remove(
/** @type {number} */ (specifier.start),
/** @type {number} */ (specifier.end)
);
count_removed++;
}
}
if (count_removed === node.specifiers.length) {
state.str.remove(/** @type {number} */ (node.start), /** @type {number} */ (node.end));
}
},
VariableDeclaration(node, { state, path }) {
if (state.scope !== state.analysis.instance.scope) {
return;
}
let nr_of_props = 0;
for (const declarator of node.declarations) {
if (state.analysis.runes) {
if (get_rune(declarator.init, state.scope) === '$props') {
state.props_insertion_point = /** @type {number} */ (declarator.id.start) + 1;
state.has_props_rune = true;
}
continue;
}
let bindings;
try {
bindings = state.scope.get_bindings(declarator);
} catch (e) {
// no bindings, so we can skip this
continue;
}
const has_state = bindings.some((binding) => binding.kind === 'state');
const has_props = bindings.some((binding) => binding.kind === 'bindable_prop');
if (!has_state && !has_props) {
continue;
}
if (has_props) {
nr_of_props++;
if (declarator.id.type !== 'Identifier') {
// TODO invest time in this?
throw new Error(
'Encountered an export declaration pattern that is not supported for automigration.'
);
// Turn export let into props. It's really really weird because export let { x: foo, z: [bar]} = ..
// means that foo and bar are the props (i.e. the leafs are the prop names), not x and z.
// const tmp = state.scope.generate('tmp');
// const paths = extract_paths(declarator.id);
// state.props_pre.push(
// b.declaration('const', b.id(tmp), visit(declarator.init!) as Expression)
// );
// for (const path of paths) {
// const name = (path.node as Identifier).name;
// const binding = state.scope.get(name)!;
// const value = path.expression!(b.id(tmp));
// if (binding.kind === 'bindable_prop' || binding.kind === 'rest_prop') {
// state.props.push({
// local: name,
// exported: binding.prop_alias ? binding.prop_alias : name,
// init: value
// });
// state.props_insertion_point = /** @type {number} */(declarator.end);
// } else {
// declarations.push(b.declarator(path.node, value));
// }
// }
}
const binding = /** @type {import('#compiler').Binding} */ (
state.scope.get(declarator.id.name)
);
if (
state.analysis.uses_props &&
(declarator.init || binding.mutated || binding.reassigned)
) {
throw new Error(
'$$props is used together with named props in a way that cannot be automatically migrated.'
);
}
state.props.push({
local: declarator.id.name,
exported: binding.prop_alias ? binding.prop_alias : declarator.id.name,
init: declarator.init
? state.str.original.substring(
/** @type {number} */ (declarator.init.start),
/** @type {number} */ (declarator.init.end)
)
: '',
optional: !!declarator.init,
type: extract_type(declarator, state.str, path),
bindable: binding.mutated || binding.reassigned
});
state.props_insertion_point = /** @type {number} */ (declarator.end);
state.str.update(
/** @type {number} */ (declarator.start),
/** @type {number} */ (declarator.end),
''
);
continue;
}
// state
if (declarator.init) {
state.str.prependLeft(/** @type {number} */ (declarator.init.start), '$state(');
state.str.appendRight(/** @type {number} */ (declarator.init.end), ')');
} else {
state.str.prependLeft(/** @type {number} */ (declarator.id.end), ' = $state()');
}
}
if (nr_of_props === node.declarations.length) {
let start = /** @type {number} */ (node.start);
let end = /** @type {number} */ (node.end);
const parent = path.at(-1);
if (parent?.type === 'ExportNamedDeclaration') {
start = /** @type {number} */ (parent.start);
end = /** @type {number} */ (parent.end);
}
while (state.str.original[start] !== '\n') start--;
while (state.str.original[end] !== '\n') end++;
state.str.update(start, end, '');
}
},
BreakStatement(node, { state, path }) {
if (path[1].type !== 'LabeledStatement') return;
if (node.label?.name !== '$') return;
state.str.update(
/** @type {number} */ (node.start),
/** @type {number} */ (node.end),
'return;'
);
},
LabeledStatement(node, { path, state, next }) {
if (state.analysis.runes) return;
if (path.length > 1) return;
if (node.label.name !== '$') return;
next();
if (
node.body.type === 'ExpressionStatement' &&
node.body.expression.type === 'AssignmentExpression'
) {
const ids = extract_identifiers(node.body.expression.left);
const bindings = ids.map((id) => state.scope.get(id.name));
const reassigned_bindings = bindings.filter((b) => b?.reassigned);
if (reassigned_bindings.length === 0 && !bindings.some((b) => b?.kind === 'store_sub')) {
// $derived
state.str.update(
/** @type {number} */ (node.start),
/** @type {number} */ (node.body.expression.start),
'let '
);
state.str.prependLeft(
/** @type {number} */ (node.body.expression.right.start),
'$derived('
);
state.str.update(
/** @type {number} */ (node.body.expression.right.end),
/** @type {number} */ (node.end),
');'
);
return;
} else {
for (const binding of reassigned_bindings) {
if (binding && ids.includes(binding.node)) {
// implicitly-declared variable which we need to make explicit
state.str.prependLeft(
/** @type {number} */ (node.start),
`let ${binding.node.name}${binding.kind === 'state' ? ' = $state()' : ''};\n${state.indent}`
);
}
}
}
}
state.needs_run = true;
const is_block_stmt = node.body.type === 'BlockStatement';
const start_end = /** @type {number} */ (node.body.start);
// TODO try to find out if we can use $derived.by instead?
if (is_block_stmt) {
state.str.update(
/** @type {number} */ (node.start),
start_end + 1,
`${state.run_name}(() => {`
);
const end = /** @type {number} */ (node.body.end);
state.str.update(end - 1, end, '});');
} else {
state.str.update(
/** @type {number} */ (node.start),
start_end,
`${state.run_name}(() => {\n${state.indent}`
);
state.str.indent(state.indent, {
exclude: [
[0, /** @type {number} */ (node.body.start)],
[/** @type {number} */ (node.body.end), state.end]
]
});
state.str.appendRight(/** @type {number} */ (node.end), `\n${state.indent}});`);
}
}
};
/** @type {import('zimmerframe').Visitors<import('../types/template.js').SvelteNode, State>} */
const template = {
Identifier(node, { state }) {
handle_identifier(node, state);
},
RegularElement(node, { state, next }) {
handle_events(node, state);
next();
},
SvelteElement(node, { state, next }) {
handle_events(node, state);
next();
},
SvelteWindow(node, { state, next }) {
handle_events(node, state);
next();
},
SvelteBody(node, { state, next }) {
handle_events(node, state);
next();
},
SvelteDocument(node, { state, next }) {
handle_events(node, state);
next();
},
SlotElement(node, { state, next }) {
let name = 'children';
let slot_props = '{ ';
for (const attr of node.attributes) {
if (attr.type === 'SpreadAttribute') {
slot_props += `...${state.str.original.substring(/** @type {number} */ (attr.expression.start), attr.expression.end)}, `;
} else if (attr.type === 'Attribute') {
if (attr.name === 'name') {
name = state.scope.generate(/** @type {any} */ (attr.value)[0].data);
} else {
const value =
attr.value !== true
? state.str.original.substring(
attr.value[0].start,
attr.value[attr.value.length - 1].end
)
: 'true';
slot_props += value === attr.name ? `${value}, ` : `${attr.name}: ${value}, `;
}
}
}
slot_props += '}';
if (slot_props === '{ }') {
slot_props = '';
}
state.props.push({
local: name,
exported: name,
init: '',
bindable: false,
optional: true,
type: `import('svelte').${slot_props ? 'Snippet<[any]>' : 'Snippet'}`
});
if (node.fragment.nodes.length > 0) {
next();
state.str.update(
node.start,
node.fragment.nodes[0].start,
`{#if ${name}}{@render ${name}(${slot_props})}{:else}`
);
state.str.update(node.fragment.nodes[node.fragment.nodes.length - 1].end, node.end, '{/if}');
} else {
state.str.update(node.start, node.end, `{@render ${name}?.(${slot_props})}`);
}
}
};
/**
* @param {import('estree').VariableDeclarator} declarator
* @param {MagicString} str
* @param {import('#compiler').SvelteNode[]} path
*/
function extract_type(declarator, str, path) {
if (declarator.id.typeAnnotation) {
let start = declarator.id.typeAnnotation.start + 1; // skip the colon
while (str.original[start] === ' ') {
start++;
}
return str.original.substring(start, declarator.id.typeAnnotation.end);
}
// try to find a comment with a type annotation, hinting at jsdoc
const parent = path.at(-1);
if (parent?.type === 'ExportNamedDeclaration' && parent.leadingComments) {
const last = parent.leadingComments[parent.leadingComments.length - 1];
if (last.type === 'Block') {
const match = /@type {([^}]+)}/.exec(last.value);
if (match) {
str.update(/** @type {any} */ (last).start, /** @type {any} */ (last).end, '');
return match[1];
}
}
}
// try to infer it from the init
if (declarator.init?.type === 'Literal') {
const type = typeof declarator.init.value;
if (type === 'string' || type === 'number' || type === 'boolean') {
return type;
}
}
return 'any';
}
/**
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement | import('#compiler').SvelteWindow | import('#compiler').SvelteDocument | import('#compiler').SvelteBody} node
* @param {State} state
*/
function handle_events(node, state) {
/** @type {Map<string, import('#compiler').OnDirective[]>} */
const handlers = new Map();
for (const attribute of node.attributes) {
if (attribute.type !== 'OnDirective') continue;
let name = `on${attribute.name}`;
if (attribute.modifiers.includes('capture')) {
name += 'capture';
}
const nodes = handlers.get(name) || [];
nodes.push(attribute);
handlers.set(name, nodes);
}
for (const [name, nodes] of handlers) {
// turn on:click into a prop
let exported = name;
if (!regex_is_valid_identifier.test(name)) {
exported = `'${exported}'`;
}
// Check if prop already set, could happen when on:click on different elements
let local = state.props.find((prop) => prop.exported === exported)?.local;
const last = nodes[nodes.length - 1];
const payload_name =
last.expression?.type === 'ArrowFunctionExpression' &&
last.expression.params[0]?.type === 'Identifier'
? last.expression.params[0].name
: generate_event_name(last, state);
let prepend = '';
for (let i = 0; i < nodes.length - 1; i += 1) {
const node = nodes[i];
if (node.expression) {
let body = '';
if (node.expression.type === 'ArrowFunctionExpression') {
body = state.str.original.substring(
/** @type {number} */ (node.expression.body.start),
/** @type {number} */ (node.expression.body.end)
);
} else {
body = `${state.str.original.substring(
/** @type {number} */ (node.expression.start),
/** @type {number} */ (node.expression.end)
)}();`;
}
// TODO check how many indents needed
for (const modifier of node.modifiers) {
if (modifier === 'stopPropagation') {
body = `\n${state.indent}${payload_name}.stopPropagation();\n${body}`;
} else if (modifier === 'preventDefault') {
body = `\n${state.indent}${payload_name}.preventDefault();\n${body}`;
} else if (modifier === 'stopImmediatePropagation') {
body = `\n${state.indent}${payload_name}.stopImmediatePropagation();\n${body}`;
} else {
body = `\n${state.indent}// @migration-task: incorporate ${modifier} modifier\n${body}`;
}
}
prepend += `\n${state.indent}${body}\n`;
} else {
if (!local) {
local = state.scope.generate(`on${node.name}`);
state.props.push({
local,
exported,
init: '',
bindable: false,
optional: true,
type: '(event: any) => void'
});
}
prepend += `\n${state.indent}${local}?.(${payload_name});\n`;
}
state.str.remove(node.start, node.end);
}
if (last.expression) {
// remove : from on:click
state.str.remove(last.start + 2, last.start + 3);
// remove modifiers
if (last.modifiers.length > 0) {
state.str.remove(
last.start + last.name.length + 3,
state.str.original.indexOf('=', last.start)
);
}
if (last.modifiers.includes('capture')) {
state.str.appendRight(last.start + last.name.length + 3, 'capture');
}
for (const modifier of last.modifiers) {
if (modifier === 'stopPropagation') {
prepend += `\n${state.indent}${payload_name}.stopPropagation();\n`;
} else if (modifier === 'preventDefault') {
prepend += `\n${state.indent}${payload_name}.preventDefault();\n`;
} else if (modifier === 'stopImmediatePropagation') {
prepend += `\n${state.indent}${payload_name}.stopImmediatePropagation();\n`;
} else if (modifier !== 'capture') {
prepend += `\n${state.indent}// @migration-task: incorporate ${modifier} modifier\n`;
}
}
if (prepend) {
let pos = last.expression.start;
if (last.expression.type === 'ArrowFunctionExpression') {
pos = last.expression.body.start;
if (
last.expression.params.length > 0 &&
last.expression.params[0].type !== 'Identifier'
) {
const start = /** @type {number} */ (last.expression.params[0].start);
const end = /** @type {number} */ (last.expression.params[0].end);
// replace event payload with generated one that others use,
// then destructure generated payload param into what the user wrote
state.str.overwrite(start, end, payload_name);
prepend = `let ${state.str.original.substring(
start,
end
)} = ${payload_name};\n${prepend}`;
} else if (last.expression.params.length === 0) {
// add generated payload param to arrow function
const pos = state.str.original.lastIndexOf(')', last.expression.body.start);
state.str.prependLeft(pos, payload_name);
}
const needs_curlies = last.expression.body.type !== 'BlockStatement';
state.str.prependRight(
/** @type {number} */ (pos) + (needs_curlies ? 0 : 1),
`${needs_curlies ? '{' : ''}${prepend}${state.indent}`
);
state.str.appendRight(
/** @type {number} */ (last.expression.body.end) - (needs_curlies ? 0 : 1),
`\n${needs_curlies ? '}' : ''}`
);
} else {
state.str.update(
/** @type {number} */ (last.expression.start),
/** @type {number} */ (last.expression.end),
`(${payload_name}) => {${prepend}\n${state.indent}${state.str.original.substring(
/** @type {number} */ (last.expression.start),
/** @type {number} */ (last.expression.end)
)}?.(${payload_name});\n}`
);
}
}
} else {
// turn on:click into a prop
// Check if prop already set, could happen when on:click on different elements
if (!local) {
local = state.scope.generate(`on${last.name}`);
state.props.push({
local,
exported,
init: '',
bindable: false,
optional: true,
type: '(event: any) => void'
});
}
let replacement = '';
if (!prepend) {
if (exported === local) {
replacement = `{${name}}`;
} else {
replacement = `${name}={${local}}`;
}
} else {
replacement = `${name}={(${payload_name}) => {${prepend}\n${state.indent}${local}?.(${payload_name});\n}}`;
}
state.str.update(last.start, last.end, replacement);
}
}
}
/**
* @param {import('#compiler').OnDirective} last
* @param {State} state
*/
function generate_event_name(last, state) {
const scope =
(last.expression && state.analysis.template.scopes.get(last.expression)) || state.scope;
let name = 'event';
if (!scope.get(name)) return name;
let i = 1;
while (scope.get(`${name}${i}`)) i += 1;
return `${name}${i}`;
}
/**
* @param {import('estree').Identifier} node
* @param {State} state
*/
function handle_identifier(node, state) {
if (state.analysis.uses_props) {
if (node.name === '$$props' || node.name === '$$restProps') {
// not 100% correct for $$restProps but it'll do
state.str.update(
/** @type {number} */ (node.start),
/** @type {number} */ (node.end),
state.props_name
);
} else {
const binding = state.scope.get(node.name);
if (binding?.kind === 'bindable_prop') {
state.str.prependLeft(/** @type {number} */ (node.start), `${state.props_name}.`);
}
}
} else if (node.name === '$$restProps' && state.analysis.uses_rest_props) {
state.str.update(
/** @type {number} */ (node.start),
/** @type {number} */ (node.end),
state.rest_props_name
);
}
}
/** @param {string} content */
function guess_indent(content) {
const lines = content.split('\n');
const tabbed = lines.filter((line) => /^\t+/.test(line));
const spaced = lines.filter((line) => /^ {2,}/.test(line));
if (tabbed.length === 0 && spaced.length === 0) {
return '\t';
}
// More lines tabbed than spaced? Assume tabs, and
// default to tabs in the case of a tie (or nothing
// to go on)
if (tabbed.length >= spaced.length) {
return '\t';
}
// Otherwise, we need to guess the multiple
const min = spaced.reduce((previous, current) => {
const count = /^ +/.exec(current)?.[0].length ?? 0;
return Math.min(count, previous);
}, Infinity);
return ' '.repeat(min);
}

@ -704,7 +704,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
} else { } else {
extract_identifiers(node).forEach((identifier) => { extract_identifiers(node).forEach((identifier) => {
const binding = scope.get(identifier.name); const binding = scope.get(identifier.name);
if (binding) { if (binding && identifier !== binding.node) {
binding.mutated = true; binding.mutated = true;
binding.reassigned = true; binding.reassigned = true;
} }

@ -1,4 +1,5 @@
import { proxy } from '../internal/client/proxy.js'; import { proxy } from '../internal/client/proxy.js';
import { user_pre_effect } from '../internal/client/reactivity/effects.js';
import { hydrate, mount, unmount } from '../internal/client/render.js'; import { hydrate, mount, unmount } from '../internal/client/render.js';
import { define_property } from '../internal/client/utils.js'; import { define_property } from '../internal/client/utils.js';
@ -128,3 +129,14 @@ class Svelte4Component {
this.#instance.$destroy(); this.#instance.$destroy();
} }
} }
/**
* Runs the given function once immediately on the server, and works like `$effect.pre` on the client.
*
* @deprecated Use this only as a temporary solution to migrate your component code to Svelte 5.
* @param {() => void | (() => void)} fn
* @returns {void}
*/
export function run(fn) {
user_pre_effect(fn);
}

@ -36,3 +36,14 @@ export function asClassComponent(component) {
// @ts-ignore // @ts-ignore
return component_constructor; return component_constructor;
} }
/**
* Runs the given function once immediately on the server, and works like `$effect.pre` on the client.
*
* @deprecated Use this only as a temporary solution to migrate your component code to Svelte 5.
* @param {() => void | (() => void)} fn
* @returns {void}
*/
export function run(fn) {
fn();
}

@ -0,0 +1,7 @@
<script>
let count = 0;
$: doubled = count * 2;
$: ({ quadrupled } = { quadrupled: count * 4 });
</script>
{count} / {doubled} / {quadrupled}

@ -0,0 +1,7 @@
<script>
let count = 0;
let doubled = $derived(count * 2);
let { quadrupled } = $derived({ quadrupled: count * 4 });
</script>
{count} / {doubled} / {quadrupled}

@ -0,0 +1,13 @@
<script>
let count = 0;
$: console.log(count);
$: if (count > 10) {
alert('too high')
}
$: {
console.log('foo');
if (x) break $;
console.log('bar');
}
$: $count = 1;
</script>

@ -0,0 +1,21 @@
<script>
import { run } from 'svelte/legacy';
let count = 0;
run(() => {
console.log(count);
});
run(() => {
if (count > 10) {
alert('too high')
}
});
run(() => {
console.log('foo');
if (x) return;
console.log('bar');
});
run(() => {
$count = 1;
});
</script>

@ -0,0 +1,17 @@
<button on:click={() => console.log('hi')} on:click>click me</button>
<button on:click={() => console.log('before')} on:click on:click={() => console.log('after')}>click me</button>
<button on:click on:click={foo}>click me</button>
<button on:click>click me</button>
<button on:dblclick={() => console.log('hi')}>click me</button>
<button on:toggle>click me</button>
<button on:custom-event={() => 'hi'}>click me</button>
<button on:custom-event-bubble>click me</button>
<button on:click|preventDefault={() => ''}>click me</button>
<button on:click|stopPropagation={() => {}}>click me</button>
<button on:click|stopImmediatePropagation={() => ''}>click me</button>
<button on:click|capture={() => ''}>click me</button>
<button on:click|self={() => ''}>click me</button>
<Button on:click={() => 'leave untouched'} on:click>click me</Button>

@ -0,0 +1,47 @@
<script>
/** @type {{onclick?: (event: any) => void, ontoggle?: (event: any) => void, 'oncustom-event-bubble'?: (event: any) => void}} */
let { onclick, ontoggle, 'oncustom-event-bubble': oncustom_event_bubble } = $props();
</script>
<button onclick={(event) => {
console.log('hi')
onclick?.(event);
}}>click me</button>
<button onclick={(event) => {
console.log('before')
onclick?.(event);
console.log('after')
}}>click me</button>
<button onclick={(event) => {
onclick?.(event);
foo?.(event);
}}>click me</button>
<button {onclick}>click me</button>
<button ondblclick={() => console.log('hi')}>click me</button>
<button {ontoggle}>click me</button>
<button oncustom-event={() => 'hi'}>click me</button>
<button oncustom-event-bubble={oncustom_event_bubble}>click me</button>
<button onclick={(event) => {
event.preventDefault();
''
}}>click me</button>
<button onclick={(event) => {
event.stopPropagation();
}}>click me</button>
<button onclick={(event) => {
event.stopImmediatePropagation();
''
}}>click me</button>
<button onclickcapture={() => ''}>click me</button>
<button onclick={(event) => {
// @migration-task: incorporate self modifier
''
}}>click me</button>
<Button on:click={() => 'leave untouched'} on:click>click me</Button>

@ -0,0 +1,6 @@
<script lang="ts">
let klass = '';
export { klass as class }
</script>
{klass}

@ -0,0 +1,8 @@
<script lang="ts">
interface Props { class?: string }
let { class: klass = '' }: Props = $props();
</script>
{klass}

@ -0,0 +1,5 @@
<script>
export let foo;
</script>
<button {foo} {...$$restProps}>click me</button>

@ -0,0 +1,6 @@
<script>
/** @type {{Record<string, any>}} */
let { foo, ...rest } = $props();
</script>
<button {foo} {...rest}>click me</button>

@ -0,0 +1,11 @@
<script lang="ts">
export let readonly: number;
export let optional = 'foo';
export let binding: string;
export let bindingOptional: string | undefined = 'bar';
</script>
{readonly}
{optional}
<input bind:value={binding} />
<input bind:value={bindingOptional} />

@ -0,0 +1,20 @@
<script lang="ts">
interface Props {
readonly: number,
optional?: string,
binding: string,
bindingOptional?: string | undefined
}
let {
readonly,
optional = 'foo',
binding = $bindable(),
bindingOptional = $bindable('bar')
}: Props = $props();
</script>
{readonly}
{optional}
<input bind:value={binding} />
<input bind:value={bindingOptional} />

@ -0,0 +1,11 @@
<script>
export let readonly;
export let optional = 'foo';
export let binding;
export let bindingOptional = 'bar';
</script>
{readonly}
{optional}
<input bind:value={binding} />
<input bind:value={bindingOptional} />

@ -0,0 +1,14 @@
<script>
/** @type {{readonly: any, optional?: string, binding: any, bindingOptional?: string}} */
let {
readonly,
optional = 'foo',
binding = $bindable(),
bindingOptional = $bindable('bar')
} = $props();
</script>
{readonly}
{optional}
<input bind:value={binding} />
<input bind:value={bindingOptional} />

@ -0,0 +1,7 @@
<button><slot /></button>
{#if foo}
<slot name="foo" {foo} />
{/if}
<slot name="dashed-name" />

@ -0,0 +1,12 @@
<script>
/** @type {{children?: import('svelte').Snippet, foo_1?: import('svelte').Snippet<[any]>, dashed_name?: import('svelte').Snippet}} */
let { children, foo_1, dashed_name } = $props();
</script>
<button>{@render children?.()}</button>
{#if foo}
{@render foo_1?.({ foo, })}
{/if}
{@render dashed_name?.()}

@ -0,0 +1,30 @@
import * as fs from 'node:fs';
import { assert } from 'vitest';
import { migrate } from 'svelte/compiler';
import { try_read_file } from '../helpers.js';
import { suite, type BaseTest } from '../suite.js';
interface ParserTest extends BaseTest {}
const { test, run } = suite<ParserTest>(async (config, cwd) => {
const input = fs
.readFileSync(`${cwd}/input.svelte`, 'utf-8')
.replace(/\s+$/, '')
.replace(/\r/g, '');
const actual = migrate(input).code;
// run `UPDATE_SNAPSHOTS=true pnpm test migrate` to update parser tests
if (process.env.UPDATE_SNAPSHOTS || !fs.existsSync(`${cwd}/output.svelte`)) {
fs.writeFileSync(`${cwd}/output.svelte`, actual);
} else {
fs.writeFileSync(`${cwd}/_actual.svelte`, actual);
const expected = try_read_file(`${cwd}/output.svelte`);
assert.deepEqual(actual, expected);
}
});
export { test };
await run(__dirname);

@ -1086,6 +1086,14 @@ declare module 'svelte/compiler' {
* https://svelte.dev/docs/svelte-compiler#svelte-version * https://svelte.dev/docs/svelte-compiler#svelte-version
* */ * */
export const VERSION: string; export const VERSION: string;
/**
* Does a best-effort migration of Svelte code towards using runes, event attributes and render tags.
* May throw an error if the code is too complex to migrate automatically.
*
* */
export function migrate(source: string): {
code: string;
};
class Scope { class Scope {
constructor(root: ScopeRoot, parent: Scope | null, porous: boolean); constructor(root: ScopeRoot, parent: Scope | null, porous: boolean);
@ -1959,6 +1967,12 @@ declare module 'svelte/legacy' {
* *
* */ * */
export function asClassComponent<Props extends Record<string, any>, Exports extends Record<string, any>, Events extends Record<string, any>, Slots extends Record<string, any>>(component: import("svelte").SvelteComponent<Props, Events, Slots>): import("svelte").ComponentType<import("svelte").SvelteComponent<Props, Events, Slots> & Exports>; export function asClassComponent<Props extends Record<string, any>, Exports extends Record<string, any>, Events extends Record<string, any>, Slots extends Record<string, any>>(component: import("svelte").SvelteComponent<Props, Events, Slots>): import("svelte").ComponentType<import("svelte").SvelteComponent<Props, Events, Slots> & Exports>;
/**
* Runs the given function once immediately on the server, and works like `$effect.pre` on the client.
*
* @deprecated Use this only as a temporary solution to migrate your component code to Svelte 5.
* */
export function run(fn: () => void | (() => void)): void;
} }
declare module 'svelte/motion' { declare module 'svelte/motion' {

@ -3,7 +3,7 @@ import * as path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import glob from 'tiny-glob/sync.js'; import glob from 'tiny-glob/sync.js';
import minimist from 'minimist'; import minimist from 'minimist';
import { compile, compileModule, parse } from 'svelte/compiler'; import { compile, compileModule, parse, migrate } from 'svelte/compiler';
const argv = minimist(process.argv.slice(2)); const argv = minimist(process.argv.slice(2));
@ -47,6 +47,13 @@ for (const generate of ['client', 'server']) {
}); });
fs.writeFileSync(`${cwd}/output/${file}.json`, JSON.stringify(ast, null, '\t')); fs.writeFileSync(`${cwd}/output/${file}.json`, JSON.stringify(ast, null, '\t'));
try {
const migrated = migrate(source);
fs.writeFileSync(`${cwd}/output/${file}.migrated.svelte`, migrated);
} catch (e) {
console.warn(`Error migrating ${file}`, e);
}
} }
const compiled = compile(source, { const compiled = compile(source, {

@ -3,6 +3,7 @@
import { get_full_filename } from '$lib/utils.js'; import { get_full_filename } from '$lib/utils.js';
import { createEventDispatcher, tick } from 'svelte'; import { createEventDispatcher, tick } from 'svelte';
import RunesInfo from './RunesInfo.svelte'; import RunesInfo from './RunesInfo.svelte';
import Migrate from './Migrate.svelte';
/** @type {boolean} */ /** @type {boolean} */
export let show_modified; export let show_modified;
@ -296,6 +297,8 @@
</button> </button>
<div class="runes-info"><RunesInfo {runes} /></div> <div class="runes-info"><RunesInfo {runes} /></div>
<div class="migrate-info"><Migrate /></div>
</div> </div>
<style> <style>
@ -413,6 +416,13 @@
justify-content: flex-end; justify-content: flex-end;
} }
.migrate-info {
flex: 0 1 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.drag-handle { .drag-handle {
cursor: move; cursor: move;
width: 5px; width: 5px;

@ -0,0 +1,22 @@
<script>
import { get_repl_context } from '$lib/context.js';
const { migrate } = get_repl_context();
</script>
<div class="container">
<button on:click={migrate} title="Migrate this component towards the new syntax">migrate</button>
</div>
<style>
button {
position: relative;
display: flex;
text-transform: uppercase;
font-size: 1.4rem;
padding: 0.8rem;
gap: 0.5rem;
margin-right: 0.3rem;
z-index: 9999;
}
</style>

@ -67,6 +67,24 @@ export default class Compiler {
}); });
} }
/**
* @param {import('$lib/types').File} file
* @returns {Promise<import('$lib/workers/workers').MigrateMessageData>}
*/
migrate(file) {
return new Promise((fulfil) => {
const id = uid++;
this.handlers.set(id, fulfil);
this.worker.postMessage({
id,
type: 'migrate',
source: file.source
});
});
}
destroy() { destroy() {
this.worker.terminate(); this.worker.terminate();
} }

@ -137,6 +137,7 @@
EDITOR_STATE_MAP, EDITOR_STATE_MAP,
rebundle, rebundle,
migrate,
clear_state, clear_state,
go_to_warning_pos, go_to_warning_pos,
handle_change, handle_change,
@ -156,6 +157,27 @@
resolver(); resolver();
} }
async function migrate() {
if (!compiler || $selected?.type !== 'svelte') return;
const result = await compiler.migrate($selected);
if (result.error) {
// TODO show somehow
return;
}
const new_files = $files.map((file) => {
if (file.name === $selected?.name) {
return {
...file,
source: result.result.code
};
}
return file;
});
set({ files: new_files });
}
let is_select_changing = false; let is_select_changing = false;
/** /**

@ -65,6 +65,7 @@ export type ReplContext = {
// Methods // Methods
rebundle(): Promise<void>; rebundle(): Promise<void>;
migrate(): Promise<void>;
handle_select(filename: string): Promise<void>; handle_select(filename: string): Promise<void>;
handle_change( handle_change(
event: CustomEvent<{ event: CustomEvent<{

@ -41,6 +41,11 @@ self.addEventListener(
await ready; await ready;
postMessage(compile(event.data)); postMessage(compile(event.data));
break; break;
case 'migrate':
await ready;
postMessage(migrate(event.data));
break;
} }
} }
); );
@ -128,3 +133,24 @@ function compile({ id, source, options, return_ast }) {
}; };
} }
} }
/** @param {import("../workers").MigrateMessageData} param0 */
function migrate({ id, source }) {
try {
const result = svelte.migrate(source);
return {
id,
result
};
} catch (err) {
// @ts-ignore
let message = `/*\nError migrating ${err.filename ?? 'component'}:\n${err.message}\n*/`;
return {
id,
result: { code: source },
error: message
};
}
}

@ -26,3 +26,9 @@ export type BundleMessageData = {
svelte_url: string; svelte_url: string;
files: File[]; files: File[];
}; };
export type MigrateMessageData = {
id: number;
result: { code: string };
error?: string;
};

Loading…
Cancel
Save