Skip to content

Commit

Permalink
feat: add app-state migration (#354)
Browse files Browse the repository at this point in the history
* feat: add page-state migration

A simple, string-based migration for sveltejs/kit#13140 (tested on that repo, zero false-positives, less than five false negatives)

* handle aliases

* add migration to docs

* update link

* lint

* rename to app-state

* rename

* remove .current from navigating references

* hopefully this will get it to shut up

* add migration task

---------

Co-authored-by: Manuel Serret <[email protected]>
Co-authored-by: Rich Harris <[email protected]>
  • Loading branch information
3 people authored Dec 16, 2024
1 parent 32deaf0 commit 87e895f
Show file tree
Hide file tree
Showing 6 changed files with 325 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/polite-eyes-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-migrate': minor
---

feat: add app-state migration
4 changes: 4 additions & 0 deletions documentation/docs/20-commands/40-sv-migrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ npx sv migrate [migration]

## Migrations

### `app-state`

Migrates `$app/store` usage to `$app/state` in `.svelte` files. See the [migration guide](/docs/kit/migrating-to-sveltekit-2#SvelteKit-2.12:-$app-stores-deprecated) for more details.

### `svelte-5`

Upgrades a Svelte 4 app to use Svelte 5, and updates individual components to use [runes](../svelte/what-are-runes) and other Svelte 5 syntax ([see migration guide](../svelte/v5-migration-guide)).
Expand Down
1 change: 1 addition & 0 deletions packages/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ npx sv migrate [migration]

| Migration | From | To | Guide |
| ------------------- | --------------------- | --------------------- | --------------------------------------------------------------- |
| `app-state` | `$app/stores` | `$app/state` | [#13140](https://github.com/sveltejs/kit/pull/13140) |
| `svelte-5` | Svelte 4 | Svelte 5 | [Website](https://svelte.dev/docs/svelte/v5-migration-guide) |
| `self-closing-tags` | Svelte 4 | Svelte 4 | [#12128](https://github.com/sveltejs/kit/pull/12128) |
| `svelte-4` | Svelte 3 | Svelte 4 | [Website](https://svelte.dev/docs/svelte/v4-migration-guide) |
Expand Down
119 changes: 119 additions & 0 deletions packages/migrate/migrations/app-state/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import colors from 'kleur';
import fs from 'node:fs';
import process from 'node:process';
import prompts from 'prompts';
import semver from 'semver';
import glob from 'tiny-glob/sync.js';
import { bail, check_git, update_svelte_file } from '../../utils.js';
import { transform_svelte_code, update_pkg_json } from './migrate.js';

export async function migrate() {
if (!fs.existsSync('package.json')) {
bail('Please re-run this script in a directory with a package.json');
}

const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));

const svelte_dep = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
if (svelte_dep && semver.validRange(svelte_dep) && semver.gtr('5.0.0', svelte_dep)) {
console.log(
colors
.bold()
.red('\nYou need to upgrade to Svelte version 5 first (`npx sv migrate svelte-5`).\n')
);
process.exit(1);
}

const kit_dep = pkg.devDependencies?.['@sveltejs/kit'] ?? pkg.dependencies?.['@sveltejs/kit'];
if (kit_dep && semver.validRange(kit_dep) && semver.gtr('2.0.0', kit_dep)) {
console.log(
colors
.bold()
.red('\nYou need to upgrade to SvelteKit version 2 first (`npx sv migrate sveltekit-2`).\n')
);
process.exit(1);
}

console.log(
colors
.bold()
.yellow(
'\nThis will update files in the current directory\n' +
"If you're inside a monorepo, don't run this in the root directory, rather run it in all projects independently.\n"
)
);

const use_git = check_git();

const response = await prompts({
type: 'confirm',
name: 'value',
message: 'Continue?',
initial: false
});

if (!response.value) {
process.exit(1);
}

const folders = await prompts({
type: 'multiselect',
name: 'value',
message: 'Which folders should be migrated?',
choices: fs
.readdirSync('.')
.filter(
(dir) => fs.statSync(dir).isDirectory() && dir !== 'node_modules' && !dir.startsWith('.')
)
.map((dir) => ({ title: dir, value: dir, selected: true }))
});

if (!folders.value?.length) {
process.exit(1);
}

update_pkg_json();

// For some reason {folders.value.join(',')} as part of the glob doesn't work and returns less files
const files = folders.value.flatMap(
/** @param {string} folder */ (folder) =>
glob(`${folder}/**`, { filesOnly: true, dot: true })
.map((file) => file.replace(/\\/g, '/'))
.filter(
(file) =>
!file.includes('/node_modules/') &&
// We're not transforming usage inside .ts/.js files since you can't use the $store syntax there,
// and therefore have to either subscribe or pass it along, which we can't auto-migrate
file.endsWith('.svelte')
)
);

for (const file of files) {
update_svelte_file(
file,
(code) => code,
(code) => transform_svelte_code(code)
);
}

console.log(colors.bold().green('✔ Your project has been migrated'));

console.log('\nRecommended next steps:\n');

const cyan = colors.bold().cyan;

const tasks = [
"install the updated dependencies ('npm i' / 'pnpm i' / etc) " + use_git &&
cyan('git commit -m "migration to $app/state"')
].filter(Boolean);

tasks.forEach((task, i) => {
console.log(` ${i + 1}: ${task}`);
});

console.log('');

if (use_git) {
console.log(`Run ${cyan('git diff')} to review changes.\n`);
}
}
87 changes: 87 additions & 0 deletions packages/migrate/migrations/app-state/migrate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import { update_pkg } from '../../utils.js';

export function update_pkg_json() {
fs.writeFileSync(
'package.json',
update_pkg_json_content(fs.readFileSync('package.json', 'utf8'))
);
}

/**
* @param {string} content
*/
export function update_pkg_json_content(content) {
return update_pkg(content, [['@sveltejs/kit', '^2.12.0']]);
}

/**
* @param {string} code
*/
export function transform_svelte_code(code) {
// Quick check if nothing to do
if (!code.includes('$app/stores')) return code;

// Check if file is using legacy APIs - if so, we can't migrate since reactive statements would break
const lines = code.split('\n');
if (lines.some((line) => /^\s*(export let|\$:) /.test(line))) {
return code;
}

const import_match = code.match(/import\s*{([^}]+)}\s*from\s*("|')\$app\/stores\2/);
if (!import_match) return code; // nothing to do

const stores = import_match[1].split(',').map((i) => {
const str = i.trim();
const [name, alias] = str.split(' as ').map((s) => s.trim());
return [name, alias || name];
});
let modified = code.replace('$app/stores', '$app/state');

let needs_navigating_migration_task = false;

for (const [store, alias] of stores) {
// if someone uses that they're deep into stores and we better not touch this file
if (store === 'getStores') return code;

const regex = new RegExp(`\\b${alias}\\b`, 'g');
let match;
let count_removed = 0;

while ((match = regex.exec(modified)) !== null) {
const before = modified.slice(0, match.index);
const after = modified.slice(match.index + alias.length);

if (before.slice(-1) !== '$') {
if (/[_'"]/.test(before.slice(-1))) continue; // false positive

if (store === 'updated' && after.startsWith('.check()')) {
continue; // this stays as is
}

if (
match.index - count_removed > /** @type {number} */ (import_match.index) &&
match.index - count_removed <
/** @type {number} */ (import_match.index) + import_match[0].length
) {
continue; // this is the import statement
}

return code;
}

if (store === 'navigating' && after[0] !== '.') {
needs_navigating_migration_task = true;
}

modified = before.slice(0, -1) + alias + (store === 'updated' ? '.current' : '') + after;
count_removed++;
}
}

if (needs_navigating_migration_task) {
modified = `<!-- @migration task: review uses of \`navigating\` -->\n${modified}`;
}

return modified;
}
109 changes: 109 additions & 0 deletions packages/migrate/migrations/app-state/migrate.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { assert, test } from 'vitest';
import { transform_svelte_code } from './migrate.js';

test('Updates $app/store #1', () => {
const result = transform_svelte_code(
`<script>
import { page } from '$app/stores';
</script>
<div>{$page.url}</div>
<button onclick={() => {
console.log($page.state);
}}></button>
`
);
assert.equal(
result,
`<script>
import { page } from '$app/state';
</script>
<div>{page.url}</div>
<button onclick={() => {
console.log(page.state);
}}></button>
`
);
});

test('Updates $app/store #2', () => {
const result = transform_svelte_code(
`<script>
import { navigating, updated } from '$app/stores';
$updated;
updated.check();
</script>
{$navigating?.to?.url.pathname}
`
);
assert.equal(
result,
`<!-- @migration task: review uses of \`navigating\` -->\n<script>
import { navigating, updated } from '$app/state';
updated.current;
updated.check();
</script>
{navigating?.to?.url.pathname}
`
);
});

test('Updates $app/store #3', () => {
const result = transform_svelte_code(
`<script>
import { page as _page } from '$app/stores';
</script>
{$_page.data}
`
);
assert.equal(
result,
`<script>
import { page as _page } from '$app/state';
</script>
{_page.data}
`
);
});

test('Does not update $app/store #1', () => {
const input = `<script>
import { page } from '$app/stores';
$: x = $page.url;
</script>
{x}
`;
const result = transform_svelte_code(input);
assert.equal(result, input);
});

test('Does not update $app/store #2', () => {
const input = `<script>
import { page } from '$app/stores';
import { derived } from 'svelte/store';
const url = derived(page, ($page) => $page.url);
</script>
{$url}
`;
const result = transform_svelte_code(input);
assert.equal(result, input);
});

test('Does not update $app/store #3', () => {
const input = `<script>
import { page, getStores } from '$app/stores';
const x = getStores();
</script>
{$page.url}
`;
const result = transform_svelte_code(input);
assert.equal(result, input);
});

0 comments on commit 87e895f

Please sign in to comment.