Skip to content

Commit 87e895f

Browse files
dummdidummmanuel3108Rich-Harris
authored
feat: add app-state migration (#354)
* 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]>
1 parent 32deaf0 commit 87e895f

File tree

6 files changed

+325
-0
lines changed

6 files changed

+325
-0
lines changed

.changeset/polite-eyes-invent.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte-migrate': minor
3+
---
4+
5+
feat: add app-state migration

documentation/docs/20-commands/40-sv-migrate.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ npx sv migrate [migration]
1414

1515
## Migrations
1616

17+
### `app-state`
18+
19+
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.
20+
1721
### `svelte-5`
1822

1923
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)).

packages/migrate/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ npx sv migrate [migration]
1818

1919
| Migration | From | To | Guide |
2020
| ------------------- | --------------------- | --------------------- | --------------------------------------------------------------- |
21+
| `app-state` | `$app/stores` | `$app/state` | [#13140](https://github.com/sveltejs/kit/pull/13140) |
2122
| `svelte-5` | Svelte 4 | Svelte 5 | [Website](https://svelte.dev/docs/svelte/v5-migration-guide) |
2223
| `self-closing-tags` | Svelte 4 | Svelte 4 | [#12128](https://github.com/sveltejs/kit/pull/12128) |
2324
| `svelte-4` | Svelte 3 | Svelte 4 | [Website](https://svelte.dev/docs/svelte/v4-migration-guide) |
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import colors from 'kleur';
2+
import fs from 'node:fs';
3+
import process from 'node:process';
4+
import prompts from 'prompts';
5+
import semver from 'semver';
6+
import glob from 'tiny-glob/sync.js';
7+
import { bail, check_git, update_svelte_file } from '../../utils.js';
8+
import { transform_svelte_code, update_pkg_json } from './migrate.js';
9+
10+
export async function migrate() {
11+
if (!fs.existsSync('package.json')) {
12+
bail('Please re-run this script in a directory with a package.json');
13+
}
14+
15+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
16+
17+
const svelte_dep = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
18+
if (svelte_dep && semver.validRange(svelte_dep) && semver.gtr('5.0.0', svelte_dep)) {
19+
console.log(
20+
colors
21+
.bold()
22+
.red('\nYou need to upgrade to Svelte version 5 first (`npx sv migrate svelte-5`).\n')
23+
);
24+
process.exit(1);
25+
}
26+
27+
const kit_dep = pkg.devDependencies?.['@sveltejs/kit'] ?? pkg.dependencies?.['@sveltejs/kit'];
28+
if (kit_dep && semver.validRange(kit_dep) && semver.gtr('2.0.0', kit_dep)) {
29+
console.log(
30+
colors
31+
.bold()
32+
.red('\nYou need to upgrade to SvelteKit version 2 first (`npx sv migrate sveltekit-2`).\n')
33+
);
34+
process.exit(1);
35+
}
36+
37+
console.log(
38+
colors
39+
.bold()
40+
.yellow(
41+
'\nThis will update files in the current directory\n' +
42+
"If you're inside a monorepo, don't run this in the root directory, rather run it in all projects independently.\n"
43+
)
44+
);
45+
46+
const use_git = check_git();
47+
48+
const response = await prompts({
49+
type: 'confirm',
50+
name: 'value',
51+
message: 'Continue?',
52+
initial: false
53+
});
54+
55+
if (!response.value) {
56+
process.exit(1);
57+
}
58+
59+
const folders = await prompts({
60+
type: 'multiselect',
61+
name: 'value',
62+
message: 'Which folders should be migrated?',
63+
choices: fs
64+
.readdirSync('.')
65+
.filter(
66+
(dir) => fs.statSync(dir).isDirectory() && dir !== 'node_modules' && !dir.startsWith('.')
67+
)
68+
.map((dir) => ({ title: dir, value: dir, selected: true }))
69+
});
70+
71+
if (!folders.value?.length) {
72+
process.exit(1);
73+
}
74+
75+
update_pkg_json();
76+
77+
// For some reason {folders.value.join(',')} as part of the glob doesn't work and returns less files
78+
const files = folders.value.flatMap(
79+
/** @param {string} folder */ (folder) =>
80+
glob(`${folder}/**`, { filesOnly: true, dot: true })
81+
.map((file) => file.replace(/\\/g, '/'))
82+
.filter(
83+
(file) =>
84+
!file.includes('/node_modules/') &&
85+
// We're not transforming usage inside .ts/.js files since you can't use the $store syntax there,
86+
// and therefore have to either subscribe or pass it along, which we can't auto-migrate
87+
file.endsWith('.svelte')
88+
)
89+
);
90+
91+
for (const file of files) {
92+
update_svelte_file(
93+
file,
94+
(code) => code,
95+
(code) => transform_svelte_code(code)
96+
);
97+
}
98+
99+
console.log(colors.bold().green('✔ Your project has been migrated'));
100+
101+
console.log('\nRecommended next steps:\n');
102+
103+
const cyan = colors.bold().cyan;
104+
105+
const tasks = [
106+
"install the updated dependencies ('npm i' / 'pnpm i' / etc) " + use_git &&
107+
cyan('git commit -m "migration to $app/state"')
108+
].filter(Boolean);
109+
110+
tasks.forEach((task, i) => {
111+
console.log(` ${i + 1}: ${task}`);
112+
});
113+
114+
console.log('');
115+
116+
if (use_git) {
117+
console.log(`Run ${cyan('git diff')} to review changes.\n`);
118+
}
119+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import fs from 'node:fs';
2+
import { update_pkg } from '../../utils.js';
3+
4+
export function update_pkg_json() {
5+
fs.writeFileSync(
6+
'package.json',
7+
update_pkg_json_content(fs.readFileSync('package.json', 'utf8'))
8+
);
9+
}
10+
11+
/**
12+
* @param {string} content
13+
*/
14+
export function update_pkg_json_content(content) {
15+
return update_pkg(content, [['@sveltejs/kit', '^2.12.0']]);
16+
}
17+
18+
/**
19+
* @param {string} code
20+
*/
21+
export function transform_svelte_code(code) {
22+
// Quick check if nothing to do
23+
if (!code.includes('$app/stores')) return code;
24+
25+
// Check if file is using legacy APIs - if so, we can't migrate since reactive statements would break
26+
const lines = code.split('\n');
27+
if (lines.some((line) => /^\s*(export let|\$:) /.test(line))) {
28+
return code;
29+
}
30+
31+
const import_match = code.match(/import\s*{([^}]+)}\s*from\s*("|')\$app\/stores\2/);
32+
if (!import_match) return code; // nothing to do
33+
34+
const stores = import_match[1].split(',').map((i) => {
35+
const str = i.trim();
36+
const [name, alias] = str.split(' as ').map((s) => s.trim());
37+
return [name, alias || name];
38+
});
39+
let modified = code.replace('$app/stores', '$app/state');
40+
41+
let needs_navigating_migration_task = false;
42+
43+
for (const [store, alias] of stores) {
44+
// if someone uses that they're deep into stores and we better not touch this file
45+
if (store === 'getStores') return code;
46+
47+
const regex = new RegExp(`\\b${alias}\\b`, 'g');
48+
let match;
49+
let count_removed = 0;
50+
51+
while ((match = regex.exec(modified)) !== null) {
52+
const before = modified.slice(0, match.index);
53+
const after = modified.slice(match.index + alias.length);
54+
55+
if (before.slice(-1) !== '$') {
56+
if (/[_'"]/.test(before.slice(-1))) continue; // false positive
57+
58+
if (store === 'updated' && after.startsWith('.check()')) {
59+
continue; // this stays as is
60+
}
61+
62+
if (
63+
match.index - count_removed > /** @type {number} */ (import_match.index) &&
64+
match.index - count_removed <
65+
/** @type {number} */ (import_match.index) + import_match[0].length
66+
) {
67+
continue; // this is the import statement
68+
}
69+
70+
return code;
71+
}
72+
73+
if (store === 'navigating' && after[0] !== '.') {
74+
needs_navigating_migration_task = true;
75+
}
76+
77+
modified = before.slice(0, -1) + alias + (store === 'updated' ? '.current' : '') + after;
78+
count_removed++;
79+
}
80+
}
81+
82+
if (needs_navigating_migration_task) {
83+
modified = `<!-- @migration task: review uses of \`navigating\` -->\n${modified}`;
84+
}
85+
86+
return modified;
87+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { assert, test } from 'vitest';
2+
import { transform_svelte_code } from './migrate.js';
3+
4+
test('Updates $app/store #1', () => {
5+
const result = transform_svelte_code(
6+
`<script>
7+
import { page } from '$app/stores';
8+
</script>
9+
10+
<div>{$page.url}</div>
11+
<button onclick={() => {
12+
console.log($page.state);
13+
}}></button>
14+
`
15+
);
16+
assert.equal(
17+
result,
18+
`<script>
19+
import { page } from '$app/state';
20+
</script>
21+
22+
<div>{page.url}</div>
23+
<button onclick={() => {
24+
console.log(page.state);
25+
}}></button>
26+
`
27+
);
28+
});
29+
30+
test('Updates $app/store #2', () => {
31+
const result = transform_svelte_code(
32+
`<script>
33+
import { navigating, updated } from '$app/stores';
34+
$updated;
35+
updated.check();
36+
</script>
37+
38+
{$navigating?.to?.url.pathname}
39+
`
40+
);
41+
assert.equal(
42+
result,
43+
`<!-- @migration task: review uses of \`navigating\` -->\n<script>
44+
import { navigating, updated } from '$app/state';
45+
updated.current;
46+
updated.check();
47+
</script>
48+
49+
{navigating?.to?.url.pathname}
50+
`
51+
);
52+
});
53+
54+
test('Updates $app/store #3', () => {
55+
const result = transform_svelte_code(
56+
`<script>
57+
import { page as _page } from '$app/stores';
58+
</script>
59+
60+
{$_page.data}
61+
`
62+
);
63+
assert.equal(
64+
result,
65+
`<script>
66+
import { page as _page } from '$app/state';
67+
</script>
68+
69+
{_page.data}
70+
`
71+
);
72+
});
73+
74+
test('Does not update $app/store #1', () => {
75+
const input = `<script>
76+
import { page } from '$app/stores';
77+
$: x = $page.url;
78+
</script>
79+
80+
{x}
81+
`;
82+
const result = transform_svelte_code(input);
83+
assert.equal(result, input);
84+
});
85+
86+
test('Does not update $app/store #2', () => {
87+
const input = `<script>
88+
import { page } from '$app/stores';
89+
import { derived } from 'svelte/store';
90+
const url = derived(page, ($page) => $page.url);
91+
</script>
92+
93+
{$url}
94+
`;
95+
const result = transform_svelte_code(input);
96+
assert.equal(result, input);
97+
});
98+
99+
test('Does not update $app/store #3', () => {
100+
const input = `<script>
101+
import { page, getStores } from '$app/stores';
102+
const x = getStores();
103+
</script>
104+
105+
{$page.url}
106+
`;
107+
const result = transform_svelte_code(input);
108+
assert.equal(result, input);
109+
});

0 commit comments

Comments
 (0)