From cd798077b464ab3a28227f1e4a9aace20ba29b28 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Fri, 26 Apr 2024 20:56:40 +0200 Subject: [PATCH] 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 --- .changeset/orange-zoos-heal.md | 5 + packages/svelte/src/compiler/index.js | 2 +- packages/svelte/src/compiler/migrate/index.js | 770 ++++++++++++++++++ packages/svelte/src/compiler/phases/scope.js | 2 +- packages/svelte/src/legacy/legacy-client.js | 12 + packages/svelte/src/legacy/legacy-server.js | 11 + .../migrate/samples/derivations/input.svelte | 7 + .../migrate/samples/derivations/output.svelte | 7 + .../migrate/samples/effects/input.svelte | 13 + .../migrate/samples/effects/output.svelte | 21 + .../samples/event-handlers/input.svelte | 17 + .../samples/event-handlers/output.svelte | 47 ++ .../samples/props-export-alias/input.svelte | 6 + .../samples/props-export-alias/output.svelte | 8 + .../samples/props-rest-props/input.svelte | 5 + .../samples/props-rest-props/output.svelte | 6 + .../migrate/samples/props-ts/input.svelte | 11 + .../migrate/samples/props-ts/output.svelte | 20 + .../tests/migrate/samples/props/input.svelte | 11 + .../tests/migrate/samples/props/output.svelte | 14 + .../tests/migrate/samples/slots/input.svelte | 7 + .../tests/migrate/samples/slots/output.svelte | 12 + packages/svelte/tests/migrate/test.ts | 30 + packages/svelte/types/index.d.ts | 14 + playgrounds/sandbox/run.js | 9 +- .../src/lib/Input/ComponentSelector.svelte | 10 + .../src/lib/Input/Migrate.svelte | 22 + .../src/lib/Output/Compiler.js | 18 + sites/svelte-5-preview/src/lib/Repl.svelte | 22 + sites/svelte-5-preview/src/lib/types.d.ts | 1 + .../src/lib/workers/compiler/index.js | 26 + .../src/lib/workers/workers.d.ts | 6 + 32 files changed, 1169 insertions(+), 3 deletions(-) create mode 100644 .changeset/orange-zoos-heal.md create mode 100644 packages/svelte/src/compiler/migrate/index.js create mode 100644 packages/svelte/tests/migrate/samples/derivations/input.svelte create mode 100644 packages/svelte/tests/migrate/samples/derivations/output.svelte create mode 100644 packages/svelte/tests/migrate/samples/effects/input.svelte create mode 100644 packages/svelte/tests/migrate/samples/effects/output.svelte create mode 100644 packages/svelte/tests/migrate/samples/event-handlers/input.svelte create mode 100644 packages/svelte/tests/migrate/samples/event-handlers/output.svelte create mode 100644 packages/svelte/tests/migrate/samples/props-export-alias/input.svelte create mode 100644 packages/svelte/tests/migrate/samples/props-export-alias/output.svelte create mode 100644 packages/svelte/tests/migrate/samples/props-rest-props/input.svelte create mode 100644 packages/svelte/tests/migrate/samples/props-rest-props/output.svelte create mode 100644 packages/svelte/tests/migrate/samples/props-ts/input.svelte create mode 100644 packages/svelte/tests/migrate/samples/props-ts/output.svelte create mode 100644 packages/svelte/tests/migrate/samples/props/input.svelte create mode 100644 packages/svelte/tests/migrate/samples/props/output.svelte create mode 100644 packages/svelte/tests/migrate/samples/slots/input.svelte create mode 100644 packages/svelte/tests/migrate/samples/slots/output.svelte create mode 100644 packages/svelte/tests/migrate/test.ts create mode 100644 sites/svelte-5-preview/src/lib/Input/Migrate.svelte diff --git a/.changeset/orange-zoos-heal.md b/.changeset/orange-zoos-heal.md new file mode 100644 index 0000000000..f134a4b8a2 --- /dev/null +++ b/.changeset/orange-zoos-heal.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: provide migration helper diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index 97dcc364f0..ba312a1a63 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -190,5 +190,5 @@ export function walk() { } export { CompileError } from './errors.js'; - export { VERSION } from '../version.js'; +export { migrate } from './migrate/index.js'; diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js new file mode 100644 index 0000000000..e6328c70ba --- /dev/null +++ b/packages/svelte/src/compiler/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}`; + } 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(`\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(`\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} */ +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} */ +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} */ + 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); +} diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index 95161fd6e4..7a8036c76b 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -704,7 +704,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) { } else { extract_identifiers(node).forEach((identifier) => { const binding = scope.get(identifier.name); - if (binding) { + if (binding && identifier !== binding.node) { binding.mutated = true; binding.reassigned = true; } diff --git a/packages/svelte/src/legacy/legacy-client.js b/packages/svelte/src/legacy/legacy-client.js index 570a7c8456..8784489c4d 100644 --- a/packages/svelte/src/legacy/legacy-client.js +++ b/packages/svelte/src/legacy/legacy-client.js @@ -1,4 +1,5 @@ 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 { define_property } from '../internal/client/utils.js'; @@ -128,3 +129,14 @@ class Svelte4Component { 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); +} diff --git a/packages/svelte/src/legacy/legacy-server.js b/packages/svelte/src/legacy/legacy-server.js index 6b8d5bfd2c..dad89d0603 100644 --- a/packages/svelte/src/legacy/legacy-server.js +++ b/packages/svelte/src/legacy/legacy-server.js @@ -36,3 +36,14 @@ export function asClassComponent(component) { // @ts-ignore 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(); +} diff --git a/packages/svelte/tests/migrate/samples/derivations/input.svelte b/packages/svelte/tests/migrate/samples/derivations/input.svelte new file mode 100644 index 0000000000..9fde4a4359 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/derivations/input.svelte @@ -0,0 +1,7 @@ + + +{count} / {doubled} / {quadrupled} diff --git a/packages/svelte/tests/migrate/samples/derivations/output.svelte b/packages/svelte/tests/migrate/samples/derivations/output.svelte new file mode 100644 index 0000000000..f6af6dd450 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/derivations/output.svelte @@ -0,0 +1,7 @@ + + +{count} / {doubled} / {quadrupled} \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/effects/input.svelte b/packages/svelte/tests/migrate/samples/effects/input.svelte new file mode 100644 index 0000000000..f7f1edfb97 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/effects/input.svelte @@ -0,0 +1,13 @@ + diff --git a/packages/svelte/tests/migrate/samples/effects/output.svelte b/packages/svelte/tests/migrate/samples/effects/output.svelte new file mode 100644 index 0000000000..006ce4b23d --- /dev/null +++ b/packages/svelte/tests/migrate/samples/effects/output.svelte @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/event-handlers/input.svelte b/packages/svelte/tests/migrate/samples/event-handlers/input.svelte new file mode 100644 index 0000000000..a3cbe72889 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/event-handlers/input.svelte @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/svelte/tests/migrate/samples/event-handlers/output.svelte b/packages/svelte/tests/migrate/samples/event-handlers/output.svelte new file mode 100644 index 0000000000..94103938a2 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/event-handlers/output.svelte @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/props-export-alias/input.svelte b/packages/svelte/tests/migrate/samples/props-export-alias/input.svelte new file mode 100644 index 0000000000..1cb57ef5fd --- /dev/null +++ b/packages/svelte/tests/migrate/samples/props-export-alias/input.svelte @@ -0,0 +1,6 @@ + + +{klass} diff --git a/packages/svelte/tests/migrate/samples/props-export-alias/output.svelte b/packages/svelte/tests/migrate/samples/props-export-alias/output.svelte new file mode 100644 index 0000000000..76d25afb08 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/props-export-alias/output.svelte @@ -0,0 +1,8 @@ + + +{klass} \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/props-rest-props/input.svelte b/packages/svelte/tests/migrate/samples/props-rest-props/input.svelte new file mode 100644 index 0000000000..2086c32c52 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/props-rest-props/input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/svelte/tests/migrate/samples/props-rest-props/output.svelte b/packages/svelte/tests/migrate/samples/props-rest-props/output.svelte new file mode 100644 index 0000000000..7272cd5db6 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/props-rest-props/output.svelte @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/props-ts/input.svelte b/packages/svelte/tests/migrate/samples/props-ts/input.svelte new file mode 100644 index 0000000000..3738630649 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/props-ts/input.svelte @@ -0,0 +1,11 @@ + + +{readonly} +{optional} + + diff --git a/packages/svelte/tests/migrate/samples/props-ts/output.svelte b/packages/svelte/tests/migrate/samples/props-ts/output.svelte new file mode 100644 index 0000000000..441d48b823 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/props-ts/output.svelte @@ -0,0 +1,20 @@ + + +{readonly} +{optional} + + \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/props/input.svelte b/packages/svelte/tests/migrate/samples/props/input.svelte new file mode 100644 index 0000000000..b29e9be480 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/props/input.svelte @@ -0,0 +1,11 @@ + + +{readonly} +{optional} + + diff --git a/packages/svelte/tests/migrate/samples/props/output.svelte b/packages/svelte/tests/migrate/samples/props/output.svelte new file mode 100644 index 0000000000..5dacd500b6 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/props/output.svelte @@ -0,0 +1,14 @@ + + +{readonly} +{optional} + + \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/slots/input.svelte b/packages/svelte/tests/migrate/samples/slots/input.svelte new file mode 100644 index 0000000000..e71004fcfe --- /dev/null +++ b/packages/svelte/tests/migrate/samples/slots/input.svelte @@ -0,0 +1,7 @@ + + +{#if foo} + +{/if} + + \ No newline at end of file diff --git a/packages/svelte/tests/migrate/samples/slots/output.svelte b/packages/svelte/tests/migrate/samples/slots/output.svelte new file mode 100644 index 0000000000..790be7e3a1 --- /dev/null +++ b/packages/svelte/tests/migrate/samples/slots/output.svelte @@ -0,0 +1,12 @@ + + + + +{#if foo} + {@render foo_1?.({ foo, })} +{/if} + +{@render dashed_name?.()} \ No newline at end of file diff --git a/packages/svelte/tests/migrate/test.ts b/packages/svelte/tests/migrate/test.ts new file mode 100644 index 0000000000..a3c247245c --- /dev/null +++ b/packages/svelte/tests/migrate/test.ts @@ -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(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); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 9540b52e57..e3d07e836a 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1086,6 +1086,14 @@ declare module 'svelte/compiler' { * https://svelte.dev/docs/svelte-compiler#svelte-version * */ 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 { constructor(root: ScopeRoot, parent: Scope | null, porous: boolean); @@ -1959,6 +1967,12 @@ declare module 'svelte/legacy' { * * */ export function asClassComponent, Exports extends Record, Events extends Record, Slots extends Record>(component: import("svelte").SvelteComponent): import("svelte").ComponentType & 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' { diff --git a/playgrounds/sandbox/run.js b/playgrounds/sandbox/run.js index d292d640ba..a6589e993d 100644 --- a/playgrounds/sandbox/run.js +++ b/playgrounds/sandbox/run.js @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import glob from 'tiny-glob/sync.js'; 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)); @@ -47,6 +47,13 @@ for (const generate of ['client', 'server']) { }); 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, { diff --git a/sites/svelte-5-preview/src/lib/Input/ComponentSelector.svelte b/sites/svelte-5-preview/src/lib/Input/ComponentSelector.svelte index 71cd9241b8..c63ca416cf 100644 --- a/sites/svelte-5-preview/src/lib/Input/ComponentSelector.svelte +++ b/sites/svelte-5-preview/src/lib/Input/ComponentSelector.svelte @@ -3,6 +3,7 @@ import { get_full_filename } from '$lib/utils.js'; import { createEventDispatcher, tick } from 'svelte'; import RunesInfo from './RunesInfo.svelte'; + import Migrate from './Migrate.svelte'; /** @type {boolean} */ export let show_modified; @@ -296,6 +297,8 @@
+ +
diff --git a/sites/svelte-5-preview/src/lib/Output/Compiler.js b/sites/svelte-5-preview/src/lib/Output/Compiler.js index da11186a2f..ae36368241 100644 --- a/sites/svelte-5-preview/src/lib/Output/Compiler.js +++ b/sites/svelte-5-preview/src/lib/Output/Compiler.js @@ -67,6 +67,24 @@ export default class Compiler { }); } + /** + * @param {import('$lib/types').File} file + * @returns {Promise} + */ + migrate(file) { + return new Promise((fulfil) => { + const id = uid++; + + this.handlers.set(id, fulfil); + + this.worker.postMessage({ + id, + type: 'migrate', + source: file.source + }); + }); + } + destroy() { this.worker.terminate(); } diff --git a/sites/svelte-5-preview/src/lib/Repl.svelte b/sites/svelte-5-preview/src/lib/Repl.svelte index fd30aca10e..80a2b7ff61 100644 --- a/sites/svelte-5-preview/src/lib/Repl.svelte +++ b/sites/svelte-5-preview/src/lib/Repl.svelte @@ -137,6 +137,7 @@ EDITOR_STATE_MAP, rebundle, + migrate, clear_state, go_to_warning_pos, handle_change, @@ -156,6 +157,27 @@ 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; /** diff --git a/sites/svelte-5-preview/src/lib/types.d.ts b/sites/svelte-5-preview/src/lib/types.d.ts index 7f978aec69..a758846d29 100644 --- a/sites/svelte-5-preview/src/lib/types.d.ts +++ b/sites/svelte-5-preview/src/lib/types.d.ts @@ -65,6 +65,7 @@ export type ReplContext = { // Methods rebundle(): Promise; + migrate(): Promise; handle_select(filename: string): Promise; handle_change( event: CustomEvent<{ diff --git a/sites/svelte-5-preview/src/lib/workers/compiler/index.js b/sites/svelte-5-preview/src/lib/workers/compiler/index.js index b5bf92e646..83dffe1856 100644 --- a/sites/svelte-5-preview/src/lib/workers/compiler/index.js +++ b/sites/svelte-5-preview/src/lib/workers/compiler/index.js @@ -41,6 +41,11 @@ self.addEventListener( await ready; postMessage(compile(event.data)); 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 + }; + } +} diff --git a/sites/svelte-5-preview/src/lib/workers/workers.d.ts b/sites/svelte-5-preview/src/lib/workers/workers.d.ts index 47238b4812..2c0015cf32 100644 --- a/sites/svelte-5-preview/src/lib/workers/workers.d.ts +++ b/sites/svelte-5-preview/src/lib/workers/workers.d.ts @@ -26,3 +26,9 @@ export type BundleMessageData = { svelte_url: string; files: File[]; }; + +export type MigrateMessageData = { + id: number; + result: { code: string }; + error?: string; +};