diff --git a/.changeset/slow-bikes-serve.md b/.changeset/slow-bikes-serve.md deleted file mode 100644 index 5e1d654353..0000000000 --- a/.changeset/slow-bikes-serve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: inline primitive constants in attribute values during SSR diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df9f755874..365717755e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,12 +18,12 @@ jobs: strategy: matrix: include: - - node-version: 18 + # Vitest 4 requires Node 20+, so tests run on 20/22/24. The published + # Svelte package still supports Node >=18 (see packages/svelte/package.json). + - node-version: 20 os: windows-latest - - node-version: 18 + - node-version: 20 os: macOS-latest - - node-version: 18 - os: ubuntu-latest - node-version: 20 os: ubuntu-latest - node-version: 22 @@ -80,7 +80,7 @@ jobs: Lint: permissions: {} runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 @@ -98,6 +98,8 @@ jobs: - name: build and check generated types if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); } + - name: check browser-support docs page is up to date + run: '{ [ "`git status --porcelain=v1 documentation/docs/07-misc/.generated/`" == "" ] || (echo "The browser-support docs page is out of date — please regenerate it locally with \`cd packages/svelte && pnpm generate:browser-support\` and commit the changes"; git diff documentation/docs/07-misc/.generated/; exit 1); }' Benchmarks: permissions: {} runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d3c1819bd5..556cae6344 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ coverage .DS_Store tmp +packages/svelte/scripts/_baseline/ benchmarking/.profiles benchmarking/compare/.results diff --git a/.prettierignore b/.prettierignore index 92d9bc797b..28f447f359 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ packages/**/config/*.js # packages/svelte packages/svelte/messages/**/*.md packages/svelte/scripts/_bundle.js +packages/svelte/scripts/_baseline/*.ts packages/svelte/src/compiler/errors.js packages/svelte/src/compiler/warnings.js packages/svelte/src/internal/client/errors.js diff --git a/AGENTS.md b/AGENTS.md index c6cd3ea310..7f143248aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ This guide is for AI coding agents working in the Svelte monorepo. **Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here. +When submitting a PR, you **MUST** read [`PULL_REQUEST_TEMPLATE.md`](./.github/PULL_REQUEST_TEMPLATE.md) and fill it out correctly. **DO NOT** submit a PR without running the full test suite. + ## Quick Reference If asked to do a performance investigation, use the `performance-investigation` skill. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e940252892..586c6fe6ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ The maintainers meet on the final Saturday of each month. While these meetings a ### Prioritization -We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active ones repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. +We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. ## Bugs diff --git a/documentation/docs/03-template-syntax/03-each.md b/documentation/docs/03-template-syntax/03-each.md index 9fa699d493..5f7f494199 100644 --- a/documentation/docs/03-template-syntax/03-each.md +++ b/documentation/docs/03-template-syntax/03-each.md @@ -106,7 +106,7 @@ In case you just want to render something `n` times, you can omit the `as` part: .chess-board { display: grid; grid-template-columns: repeat(8, 1fr); - rows: repeat(8, 1fr); + grid-template-rows: repeat(8, 1fr); border: 1px solid black; aspect-ratio: 1; diff --git a/documentation/docs/03-template-syntax/10-@const.md b/documentation/docs/03-template-syntax/10-@const.md index 2a587b7a3d..6f2edc1a37 100644 --- a/documentation/docs/03-template-syntax/10-@const.md +++ b/documentation/docs/03-template-syntax/10-@const.md @@ -2,6 +2,8 @@ title: {@const ...} --- +> [!NOTE] `{@const x = y}` is legacy syntax — use [`{const x = $derived(y)}`](declaration-tags) instead + The `{@const ...}` tag defines a local constant. ```svelte diff --git a/documentation/docs/03-template-syntax/11-declaration-tags.md b/documentation/docs/03-template-syntax/11-declaration-tags.md new file mode 100644 index 0000000000..e0edaf6a38 --- /dev/null +++ b/documentation/docs/03-template-syntax/11-declaration-tags.md @@ -0,0 +1,72 @@ +--- +title: {let/const ...} +--- + +Declaration tags define local variables inside markup with `const` or `let`: + + +```svelte + + + +{#each boxes as box} + {const area = box.width * box.height} + {const label = `${box.width} ⨉ ${box.height} = ${area}`} + +

{label}

+{/each} +``` + + +> [!NOTE] Declaration tags are available since Svelte 5.56. + +> [!NOTE] The [`{@const ...}`](@const) syntax is considered legacy — use declaration tags instead. + +When values should be reactive, you can use `$state` and `$derived`: + + +```svelte + + + +

Hello {user.name}

+ + +{#if editing} + {let name = $state(user.name)} + {const greeting = $derived(`Hello ${name}`)} + +
+ +

{greeting}

+ + +{/if} +``` + + +Declaration tags can be used anywhere inside the component. They can reference values declared outside themselves (for example in the ``, + '$state.raw': ``, + '$state.eager': ``, + '$state.snapshot': ``, + $derived: ``, + '$derived.by': ``, + $props: ``, + '$props.id': ``, + $bindable: ``, + $effect: ``, + '$effect.pre': ``, + '$effect.tracking': ``, + '$effect.root': ``, + '$effect.pending': ``, + $inspect: ``, + '$inspect().with': ``, + '$inspect.trace': ``, + $host: `\n` +}; + +function rune_fixture(rune: (typeof RUNES)[number]): string { + if (!Object.hasOwn(rune_fixtures, rune)) { + throw new Error(`Fixture missing for ${rune}`); + } + + return rune_fixtures[rune]; +} + +/** + * Compiled-fixture sources for directives. Bindings are covered by the + * `binding_properties` enumeration; transitions, animate, actions, and + * `@attach` need explicit fixtures because they require accompanying + * imports or surrounding markup. + */ +const TESTED_DIRECTIVES = [ + { + name: '`transition:` / `in:` / `out:`', + source: `{#if show}
{/if}` + }, + { + name: '`animate:`', + source: `{#each items as item (item)}
{item}
{/each}` + }, + { + name: '`use:` actions', + source: `
` + }, + { + name: '`@attach`', + source: `
` + }, + { + name: '`{@html ...}`', + source: `{@html html}` + }, + { + name: 'Custom elements (``)', + source: `\n
` + } +]; + +/** + * Filesystem-safe identifier for an importee like `svelte/internal/client`. + */ +function safe_name(importee: string): string { + return importee.replace(/[^a-z0-9]+/gi, '_'); +} + +/** + * Bundle an entry the way users receive it, so we scan the same code the + * browser does. Mirrors `check-treeshakeability.js`. + * + * `entry_code` is virtual module source: typically `export * from + * 'svelte/...'` for a runtime entry, or compiled fixture JS for a per-feature + * scan. `silent` suppresses rollup's circular-dependency warnings — used for + * fixture bundles where they're known and noisy. + */ +async function bundle(entry_code: string, options: { silent?: boolean } = {}): Promise { + const built = await rollup({ + input: '__entry__', + plugins: [ + virtual({ __entry__: entry_code }), + { + name: 'resolve-svelte', + resolveId(id: string) { + if (id.startsWith('svelte')) { + const entry = pkg.exports[id.replace('svelte', '.')]; + if (!entry) return; + if (typeof entry === 'string') return path.resolve(pkg_dir, entry); + const file = entry.browser ?? entry.default; + if (file) return path.resolve(pkg_dir, file); + } + } + }, + nodeResolve({ exportConditions: ['production', 'import', 'browser', 'default'] }) + ], + // Treat optional peers / Node-only branches as external so we only scan + // code that actually runs in the browser. + external: ['esm-env'], + onwarn: options.silent + ? () => {} + : (warning, handler) => { + if (warning.code === 'CIRCULAR_DEPENDENCY') return; + handler(warning); + } + }); + + const { output } = await built.generate({ format: 'esm' }); + await built.close(); + + return output + .filter((chunk): chunk is OutputChunk => chunk.type === 'chunk') + .map((chunk) => chunk.code) + .join('\n'); +} + +/** + * Read every compiler-emitted client file from the snapshot tests. These + * fixtures cover the full range of patterns the compiler emits — bindings, + * transitions, ``, async derived, hydration markers, etc. + */ +function load_compiler_output_fixtures(): CompilerFixture[] { + const fixtures: CompilerFixture[] = []; + + for (const sample of fs.readdirSync(snapshot_dir)) { + const client_dir = path.join(snapshot_dir, sample, '_expected/client'); + if (!fs.existsSync(client_dir)) continue; + + for (const file of fs.readdirSync(client_dir)) { + if (!file.endsWith('.js')) continue; + fixtures.push({ + filename: `${sample}/${file}`, + code: fs.readFileSync(path.join(client_dir, file), 'utf-8') + }); + } + } + + return fixtures; +} + +/** + * Combine per-feature version data into a single Record. Takes the max + * (strictest) version per browser across the input feature IDs. + * + * `null` propagates as "not supported" — if any contributing feature + * marks a browser unsupported, the merged record does too. + * + * Returns `null` if NONE of the IDs have versions in `web-features` or + * supplemental rules, so callers can fall back to year-based mapping. + */ +function versions_from_features(ids: Iterable): BrowserVersions | null { + const merged: BrowserVersions = {}; + let any_found = false; + + for (const id of ids) { + const support = versions_for_feature(id); + if (!support) continue; + any_found = true; + for (const [browser, version] of Object.entries(support)) { + const current = merged[browser]; + // `null` means "not supported"; propagate it directly. + if (version === null) { + merged[browser] = null; + continue; + } + if (current === null) continue; // already known unsupported + if (current === undefined || Number(version) > Number(current)) { + merged[browser] = version; + } + } + } + + return any_found ? merged : null; +} + +/** + * Highest baseline year among `detected`, and the set of feature IDs that + * drove it. Used both for the aggregate runtime floor and for per-fixture + * scans — the two differ only in their ignore set. + */ +function compute_floor( + detected: Iterable, + ignore: Set +): { year: number; drivers: Set } { + let year = 0; + const drivers = new Set(); + for (const id of detected) { + if (ignore.has(id)) continue; + const y = baseline_year_for_feature(id); + if (!y) continue; + if (y > year) { + year = y; + drivers.clear(); + } + if (y === year) drivers.add(id); + } + return { year, drivers }; +} + +/** + * Run the TS-based detector across the runtime bundles and the compiler- + * output fixtures, then compute the highest baseline year among the + * detected features (after subtracting `AGGREGATE_IGNORE`). + * + * `runtime_files` are absolute paths to runtime bundle files. Returns the + * minimum Baseline year the combined code satisfies. + */ +function find_minimum_target( + runtime_files: string[], + compiler_fixtures: CompilerFixture[] +): number { + // Type-aware walk over the runtime bundles. + const detected = detect_features(runtime_files); + + // Syntax-only walk over the compiler-output fixtures (text only, no + // program context; the bare TS source-file parser handles the syntax + // features the compiler emits). + for (const fixture of compiler_fixtures) { + for (const id of detect_features_in_text(fixture.code)) detected.add(id); + } + + const { year, drivers } = compute_floor(detected, AGGREGATE_IGNORE); + // Floor at 2015 so the docs never claim a pre-ES6 target if every + // detected feature happens to lack a Baseline year. + const final_year = Math.max(year, 2015); + + console.log(` → ${final_year} (features that drove the floor:)`); + for (const id of [...drivers].sort()) { + console.log(` - ${id}`); + } + + return final_year; +} + +/** + * Verify every entry in `BEHAVIORAL_IGNORE` is actually used by the + * runtime. Without this, a behavioural suppression can outlive the API + * it suppresses — the comment stays in the config pointing at code that + * no longer exists. + * + * `SAFE_TO_IGNORE` entries are exempt: they're safe to carry regardless + * of whether the runtime currently uses the API. + */ +function validate_ignore_features(runtime_files: string[]): void { + if (BEHAVIORAL_IGNORE.size === 0) return; + const detected = detect_features(runtime_files); + const stale = [...BEHAVIORAL_IGNORE].filter((id) => !detected.has(id)); + if (stale.length > 0) { + throw new Error( + `BEHAVIORAL_IGNORE contains entries that the detector does not flag — ` + + `they can be removed:\n` + + stale.map((id) => ` - ${id}`).join('\n') + + `\n\nEdit \`packages/svelte/scripts/generate-browser-support.js\` ` + + `and delete the stale entries. If the API was removed from the runtime ` + + `as part of this change, that is exactly the intended signal.` + ); + } +} + +/** + * Build the full list of user-facing features to test for conditional + * floor bumps. Each feature gets a self-contained fixture, compiled and + * bundled like real user code, then scanned. If the bundle's floor + * exceeds the runtime floor, a row is auto-emitted in the docs. + * + * `subpackage_exports` maps subpath → list of exported symbols, produced by + * `enumerate_subpackage_exports`. Passed in rather than computed here so the + * dynamic-import discovery can happen once in `main`. + */ +function enumerate_features(subpackage_exports: Record): Feature[] { + const features: Feature[] = []; + + // Every `bind:*` accepted by the compiler. Element selection respects + // the `valid_elements` constraint declared in `binding_properties`. + for (const [name, props] of Object.entries(binding_properties)) { + const fixture = binding_fixture(name, props); + if (fixture) { + features.push({ + name: `\`bind:${name}\``, + kind: 'svelte', + source: fixture + }); + } + } + + for (const [module, exports] of Object.entries(subpackage_exports)) { + for (const exp of exports) { + features.push({ + name: `\`${exp}\` from \`${module}\``, + kind: 'js', + source: `import { ${exp} } from '${module}'; export const _ = ${exp};` + }); + } + } + + for (const rune of RUNES) { + features.push({ + name: `\`${rune}\``, + kind: 'svelte', + source: rune_fixture(rune) + }); + } + + for (const directive of TESTED_DIRECTIVES) { + features.push({ name: directive.name, kind: 'svelte', source: directive.source }); + } + + return features; +} + +/** + * Produce the `.svelte` source for a single binding fixture. Returns + * `null` for bindings the compiler treats as elements rather than + * properties (none currently, but defensive). + */ +function binding_fixture(name: string, props: BindingProperty): string { + // Map declared `valid_elements` to a concrete element + minimal attrs + // so the compiler accepts the binding. + const tag = (props.valid_elements ?? ['div'])[0]; + + const reactive = `let v = $state();`; + + if (tag === 'svelte:window') { + return ``; + } + if (tag === 'svelte:document') { + return ``; + } + if (tag === 'input') { + // `bind:checked` and `bind:group` require type="checkbox" | "radio" + const type = + name === 'checked' || name === 'indeterminate' + ? ' type="checkbox"' + : name === 'group' + ? ' type="radio" value="a"' + : name === 'files' + ? ' type="file"' + : ''; + return ``; + } + if (tag === 'details') { + return `
x
`; + } + + return `<${tag} bind:${name}={v}>`; +} + +/** + * Compile a `.svelte` fixture to JS (no-op for `.js` fixtures), then + * bundle the result through the shared `bundle` helper. Fixtures are tiny + * so circular-dep warnings from the Svelte runtime are silenced. + */ +async function bundle_fixture(feature: Feature): Promise { + const entry_code = + feature.kind === 'svelte' + ? svelte_compile(feature.source, { + generate: 'client', + filename: 'Fixture.svelte', + dev: false + }).js.code + : feature.source; + return bundle(entry_code, { silent: true }); +} + +/** + * Detect features in a single fixture bundle and report the per-fixture + * floor year along with the IDs that drove it. Used for the per-feature + * conditional table. + * + * `fixture_file` is the absolute path to the `.ts` bundle. + */ +function scan_fixture(fixture_file: string): { + year: number; + driving_ids: string[]; +} { + const { year, drivers } = compute_floor(detect_features([fixture_file]), SAFE_TO_IGNORE); + + return { + year, + driving_ids: [...drivers] + }; +} + +/** + * Iterate every feature, bundle its fixture, scan it. Return the rows + * that need to appear in the conditional-features table. + */ +async function find_all_conditional_features( + runtime_floor: RuntimeFloor, + subpackage_exports: Record +): Promise { + const runtime_year = typeof runtime_floor === 'number' ? runtime_floor : Infinity; + const features = enumerate_features(subpackage_exports); + const rows: ConditionalRow[] = []; + + const missing_doc_links: string[] = []; + + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + process.stdout.write(`\r ${i + 1}/${features.length} ${feature.name}`.padEnd(80)); + + let bundle_code; + try { + bundle_code = await bundle_fixture(feature); + } catch { + continue; // some fixtures (rare element combos) may fail to compile + } + + // Write the bundle so the type-aware scanner can resolve its types. + const fixture_file = path.join(tmp_dir, `fixture_${i}.ts`); + fs.writeFileSync(fixture_file, bundle_code); + + const scanned = scan_fixture(fixture_file); + const final_year = scanned.year; + + // Skip features at or below the runtime floor — they don't need a row. + if (final_year <= runtime_year || final_year === 0) continue; + + // Use exact per-feature versions where available (from web-features + // or supplemental rules), falling back to the conservative year + // mapping only if no feature has explicit version data. + let versions = versions_from_features(scanned.driving_ids); + if (!versions) { + try { + versions = browser_versions_for(final_year); + } catch { + continue; + } + } + + let doc_link = doc_links[feature.name]; + if (doc_link === undefined) { + doc_link = null; + missing_doc_links.push(feature.name); + } + + rows.push({ + name: feature.name, + doc_link, + versions, + baseline_year: final_year + }); + } + process.stdout.write('\n'); + + if (missing_doc_links.length) { + throw new Error(`Missing documentation url for some features. +Add them to the \`doc_links\` map in \`scripts/generate-browser-support.ts\`, or add an explicit \`null\` if they don't have a documentation url. +${missing_doc_links.map((name) => ` - "${name}"`).join('\n')}`); + } + + return rows; +} + +function render_conditional_table(features: ConditionalRow[], runtime_floor: RuntimeFloor): string { + if (features.length === 0) { + return '_No features currently require browser versions newer than the runtime floor._'; + } + features.sort((a, b) => a.name.localeCompare(b.name)); + + const browsers = [ + ['chrome', 'Chrome/Edge'], + ['firefox', 'Firefox'], + ['safari', 'Safari'] + ] as const; + + const floor_versions = browser_versions_for(runtime_floor); + + const rows: string[][] = []; + for (const row of features) { + const name_cell = row.doc_link ? `[${row.name}](${row.doc_link})` : row.name; + const versions = browsers.map(([key]) => { + const v = row.versions[key]; + if (v === null) return 'not supported'; + if (v === undefined) return ''; + const floor_v = floor_versions[key]; + if (floor_v && Number(v) <= Number(floor_v)) + return ''; + + return v; + }); + rows.push([name_cell, ...versions]); + } + + return render_markdown_table(['Feature', ...browsers.map(([, label]) => `${label}`)], rows); +} + +function browser_versions_for(target: RuntimeFloor): Record { + // `targetYear` returns the minimum versions in which every feature that + // reached Baseline by the end of that year is supported. If the lint + // search fell through to `'newly'`, we use the current year — that gives + // the most recent Newly-available cutoff, which is the strongest + // statement `baseline-browser-mapping` is able to make. + const target_year = typeof target === 'number' ? target : new Date().getFullYear(); + + const versions = getCompatibleVersions({ + targetYear: target_year, + includeDownstreamBrowsers: true + }); + + // The core Baseline browsers plus the downstream browsers worth listing + // in the docs. Downstream browsers come from `baseline-browser-mapping`'s + // dataset and represent the highest-traffic Chromium derivatives; the + // long tail (UC, QQ, Yandex, in-app Facebook/Instagram browsers, etc.) + // is omitted to keep the table focused. + const visible_browsers = new Set([ + 'chrome', + 'chrome_android', + 'edge', + 'firefox', + 'firefox_android', + 'safari', + 'safari_ios', + 'opera', + 'opera_android', + 'samsunginternet_android', + 'webview_android' + ]); + + const suffixes = ['_android', '_ios']; + + const lookup: Record = {}; + outer: for (const { browser, version } of versions) { + if (visible_browsers.has(browser)) { + for (const suffix of suffixes) { + // skip e.g. 'Chrome (Android)' if it matches Chrome + if (browser.endsWith(suffix) && version === lookup[browser.replace(suffix, '')]) { + continue outer; + } + } + + lookup[browser] = version; + } + } + + return lookup; +} + +const BROWSER = { + chrome: 'Chrome', + edge: 'Edge', + firefox: 'Firefox', + safari: 'Safari', + opera: 'Opera', + samsung_internet: 'Samsung Internet', + webview_android: 'Android WebView', + internet_explorer: 'Internet Explorer' +}; + +function render_browser_table(versions: Record, target: RuntimeFloor): string { + const rows: Array<[string, string]> = [ + [BROWSER.chrome, versions.chrome], + [`${BROWSER.chrome} (Android)`, versions.chrome_android] + ]; + + if (versions.chrome === versions.edge) { + rows[0][0] += `/${BROWSER.edge}`; + } else { + rows.push([BROWSER.edge, versions.edge]); + } + + rows.push( + [BROWSER.firefox, versions.firefox], + [`${BROWSER.firefox} (Android)`, versions.firefox_android], + [BROWSER.safari, versions.safari], + [`${BROWSER.safari} (iOS)`, versions.safari_ios], + [BROWSER.opera, versions.opera], + [`${BROWSER.opera} (Android)`, versions.opera_android], + [BROWSER.samsung_internet, versions.samsunginternet_android], + [BROWSER.webview_android, versions.webview_android], + [BROWSER.internet_explorer, 'not supported'] + ); + + const target_label = target === 'newly' ? '"newly available"' : target; + + return ( + render_markdown_table( + ['Browser', 'Minimum version'], + rows.filter(([, version]) => version !== undefined) + ) + + `\n\n> [!NOTE] This equates to a Baseline target of ${target_label}.` + ); +} + +function render_markdown_table(headers: string[], rows: string[][]): string { + return `| ${headers.join(' | ')} | +| ${headers.map(() => '-').join(' | ')} | +${rows.map((row) => `| ${row.join(' | ')} |`).join('\n')} +`; +} + +async function main() { + console.log('Preparing scratch directory…'); + // Wipe and recreate so stale bundles can't leak into the next scan. + fs.rmSync(tmp_dir, { recursive: true, force: true }); + fs.mkdirSync(tmp_dir, { recursive: true }); + + try { + console.log('Bundling runtime entries…'); + const runtime_files: string[] = []; + for (const importee of browser_subpaths().map(importee_for)) { + // `import * as` + re-export keeps default and named exports + // alive, so flag modules (only a default export) don't produce + // empty chunks but their code still ends up in the scan. + const code = await bundle(`import * as __ns from '${importee}'; export default __ns;`); + const file = path.join(tmp_dir, `${safe_name(importee)}.ts`); + fs.writeFileSync(file, code); + runtime_files.push(file); + } + + console.log('Loading compiler-output fixtures…'); + const compiler_fixtures = load_compiler_output_fixtures(); + console.log(` (${compiler_fixtures.length} fixtures found)`); + + console.log('Searching for the minimum Baseline target (type-aware)…'); + const target = find_minimum_target(runtime_files, compiler_fixtures); + + console.log('Checking BEHAVIORAL_IGNORE for stale entries…'); + validate_ignore_features(runtime_files); + console.log(' no stale entries'); + + console.log('Enumerating subpackage exports…'); + const subpackage_exports = await enumerate_subpackage_exports(); + const total_exports = Object.values(subpackage_exports).reduce((n, list) => n + list.length, 0); + console.log( + ` ${total_exports} export(s) across ${Object.keys(subpackage_exports).length} subpackage(s)` + ); + + console.log('Scanning per-feature fixtures for conditional requirements…'); + const conditional_rows = await find_all_conditional_features(target, subpackage_exports); + console.log( + ` ${conditional_rows.length} feature(s) require browsers newer than the runtime floor` + ); + + console.log('Resolving browser versions…'); + const versions = browser_versions_for(target); + + console.log('Rewriting docs page…'); + generate('browser-support.md', render_browser_table(versions, target)); + generate('browser-support-features.md', render_conditional_table(conditional_rows, target)); + + console.log('Done.'); + } finally { + fs.rmSync(tmp_dir, { recursive: true, force: true }); + } +} + +function generate(file: string, content: string): void { + const filename = path.join(docs_dir, file); + + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } catch {} + + const backlink = path.relative(filename, fileURLToPath(import.meta.url)); + + fs.writeFileSync(filename, `\n\n${content}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/svelte/scripts/process-messages/index.js b/packages/svelte/scripts/process-messages/index.js index 1904f1dfb4..c8d7418db4 100644 --- a/packages/svelte/scripts/process-messages/index.js +++ b/packages/svelte/scripts/process-messages/index.js @@ -378,7 +378,6 @@ function run() { }; const block = esrap.print( - // @ts-expect-error some bullshit /** @type {ESTree.Program} */ ({ ...ast, body: [clone] }), ts({ comments: [jsdoc_clone] }) ).code; diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index bbbc86c997..ed0a004fa1 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -24,7 +24,7 @@ declare function $state(initial: T): T; declare function $state(): T | undefined; declare namespace $state { - type Primitive = string | number | boolean | null | undefined; + type Primitive = string | number | bigint | boolean | null | undefined; type TypedArray = | Int8Array diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 976925d181..ccb7fe833e 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1004,6 +1004,24 @@ export function debug_tag_invalid_arguments(node) { e(node, 'debug_tag_invalid_arguments', `{@debug ...} arguments must be identifiers, not arbitrary expressions\nhttps://svelte.dev/e/debug_tag_invalid_arguments`); } +/** + * Declaration tags must be `let` or `const` declarations + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function declaration_tag_invalid_type(node) { + e(node, 'declaration_tag_invalid_type', `Declaration tags must be \`let\` or \`const\` declarations\nhttps://svelte.dev/e/declaration_tag_invalid_type`); +} + +/** + * Declaration tags cannot be used in legacy mode + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function declaration_tag_no_legacy_mode(node) { + e(node, 'declaration_tag_no_legacy_mode', `Declaration tags cannot be used in legacy mode\nhttps://svelte.dev/e/declaration_tag_no_legacy_mode`); +} + /** * Directive value must be a JavaScript expression enclosed in curly braces * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/legacy.js b/packages/svelte/src/compiler/legacy.js index 459858fe13..c51cd8434b 100644 --- a/packages/svelte/src/compiler/legacy.js +++ b/packages/svelte/src/compiler/legacy.js @@ -262,6 +262,10 @@ export function convert(source, ast) { }; }, // @ts-ignore + DeclarationTag(node) { + return node; + }, + // @ts-ignore KeyBlock(node, { visit }) { remove_surrounding_whitespace_nodes(node.fragment.nodes); return { diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 45a7c2a58c..fb60c228c5 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -1,4 +1,4 @@ -/** @import { Comment, Program } from 'estree' */ +/** @import { Comment, Program, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Parser } from './index.js' */ import * as acorn from 'acorn'; @@ -98,6 +98,36 @@ export function parse_expression_at(parser, source, index) { } } +/** + * @param {Parser} parser + * @param {string} source + * @param {number} index + * @returns {Statement} + */ +export function parse_statement_at(parser, source, index) { + // cast to `any`: acorn's Parser constructor and parseStatement/nextToken aren't in its public types + const acorn = /** @type {any} */ (parser.ts ? TSParser : JSParser); + const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index); + + try { + // This is like parseExpressionAt but for statements + const p = new acorn( + { onComment, sourceType: 'module', ecmaVersion: 16, locations: true }, + source, + index + ); + p.nextToken(); + const statement = /** @type {Statement} */ (p.parseStatement(null, true, Object.create(null))); + add_comments(/** @type {acorn.Node} */ (statement)); + return statement; + } catch (err) { + // A statement that runs to the end of the source (e.g. an unterminated declaration tag) + // is an EOF, not a stray token; preserve the friendlier `unexpected_eof` diagnostic. + if (/** @type {any} */ (err).pos === source.length) e.unexpected_eof(source.length); + handle_parse_error(err); + } +} + const regex_position_indicator = / \(\d+:\d+\)$/; /** diff --git a/packages/svelte/src/compiler/phases/1-parse/index.js b/packages/svelte/src/compiler/phases/1-parse/index.js index 5242cba31f..02d1629c99 100644 --- a/packages/svelte/src/compiler/phases/1-parse/index.js +++ b/packages/svelte/src/compiler/phases/1-parse/index.js @@ -302,7 +302,10 @@ export class Parser { } pop() { - this.fragments.pop(); + const fragment = this.fragments.pop(); + if (fragment?.metadata.transparent && fragment.nodes.some((n) => n.type === 'DeclarationTag')) { + fragment.metadata.transparent = false; + } return this.stack.pop(); } diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js index ff153128a5..15c79e0353 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -1,16 +1,20 @@ -/** @import { ArrowFunctionExpression, Expression, Identifier, Pattern } from 'estree' */ +/** @import { ArrowFunctionExpression, Expression, Identifier, Pattern, VariableDeclaration } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Parser } from '../index.js' */ import { walk } from 'zimmerframe'; import * as e from '../../../errors.js'; import { ExpressionMetadata } from '../../nodes.js'; -import { parse_expression_at } from '../acorn.js'; +import { parse_expression_at, parse_statement_at } from '../acorn.js'; import read_pattern from '../read/context.js'; import read_expression, { get_loose_identifier } from '../read/expression.js'; import { create_fragment } from '../utils/create.js'; -import { match_bracket } from '../utils/bracket.js'; +import { find_matching_bracket, match_bracket } from '../utils/bracket.js'; const regex_whitespace_with_closing_curly_brace = /\s*}/y; +const regex_supported_declaration = /(?:let|const)\b/y; +const regex_unsupported_declaration = /(?:var|interface|enum)\b/y; +// `type` is a contextual keyword; this is just a shape hint, confirmed by parsing. +const regex_maybe_type_declaration = /type\b/y; const pointy_bois = { '<': '>' }; @@ -31,6 +35,20 @@ export default function tag(parser) { } } + const declaration = read_declaration(parser); + if (declaration) { + parser.append({ + type: 'DeclarationTag', + start, + end: parser.index, + declaration: /** @type {VariableDeclaration} */ (declaration), + metadata: { + expression: new ExpressionMetadata() + } + }); + return; + } + const expression = read_expression(parser); parser.allow_whitespace(); @@ -47,6 +65,89 @@ export default function tag(parser) { }); } +/** + * @param {Parser} parser + * @returns {null | import('estree').VariableDeclaration} + */ +function read_declaration(parser) { + const start = parser.index; + + const unsupported = parser.match_regex(regex_unsupported_declaration); + if (unsupported) { + e.declaration_tag_invalid_type({ start, end: start + unsupported.length }); + } + + if ( + !parser.match_regex(regex_supported_declaration) && + // `type` is special, since it is not a reserved keyword and can be used + // as part of a valid expression. We gotta parse first and then see what it is. + !parser.match_regex(regex_maybe_type_declaration) + ) { + return null; + } + + const initial_comment_count = parser.root.comments.length; + + /** @type {import('estree').Statement | import('estree').VariableDeclaration} */ + let declaration; + try { + declaration = parse_statement_at(parser, parser.template, start); + } catch (error) { + if (!parser.loose) throw error; + + const end = find_matching_bracket(parser.template, start, '{'); + if (end === undefined) throw error; + + parser.index = end; + const kind = parser.template.startsWith('const', start) ? 'const' : 'let'; + + declaration = { + type: 'VariableDeclaration', + kind, + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: '', + start: parser.index, + end: parser.index + }, + init: null, + start: parser.index, + end: parser.index + } + ], + start, + end + }; + } + + if (declaration.type !== 'VariableDeclaration') { + if (declaration.type === 'ExpressionStatement') { + parser.root.comments.length = initial_comment_count; // Else they show up duplicated + return null; + } else { + // This is a TSTypeAliasDeclaration + e.declaration_tag_invalid_type({ + start: declaration.start ?? start, + end: declaration.end ?? parser.index + }); + } + } + + // TODO support using + if (declaration.kind !== 'let' && declaration.kind !== 'const') { + e.declaration_tag_invalid_type(declaration); + } + + parser.index = /** @type {number} */ (declaration.end); + parser.allow_whitespace(); + parser.eat('}', true); + + return declaration; +} + /** @param {Parser} parser */ function open(parser) { let start = parser.index - 2; diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js b/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js index 63fbc68fb3..47299c9de5 100644 --- a/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js +++ b/packages/svelte/src/compiler/phases/1-parse/utils/bracket.js @@ -39,7 +39,9 @@ function find_string_end(string, search_start_index, string_start_char) { * @returns {number} The index of the end of this regex expression, or `Infinity` if not found. */ function find_regex_end(string, search_start_index) { - return find_unescaped_char(string, search_start_index, '/'); + const slash = find_unescaped_char(string, search_start_index, '/'); + const eol = find_unescaped_char(string, search_start_index, '\n'); + return slash < eol ? slash : Infinity; } /** @@ -105,7 +107,11 @@ export function find_matching_bracket(template, index, open) { continue; case '/': { const next_char = template[i + 1]; - if (!next_char) continue; + if (!next_char) { + // `/` is the last character; advance past it so we don't loop forever + i++; + continue; + } if (next_char === '/') { i = infinity_if_negative(template.indexOf('\n', i + 1)) + '\n'.length; continue; @@ -114,7 +120,12 @@ export function find_matching_bracket(template, index, open) { i = infinity_if_negative(template.indexOf('*/', i + 1)) + '*/'.length; continue; } - i = find_regex_end(template, i + 1) + '/'.length; + const end = find_regex_end(template, i + 1) + '/'.length; + if (end === Infinity) { + i++; + } else { + i = end; + } continue; } default: { diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index dec0081aa9..ef20049697 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -36,6 +36,7 @@ import { ClassDeclaration } from './visitors/ClassDeclaration.js'; import { ClassDirective } from './visitors/ClassDirective.js'; import { Component } from './visitors/Component.js'; import { ConstTag } from './visitors/ConstTag.js'; +import { DeclarationTag } from './visitors/DeclarationTag.js'; import { DebugTag } from './visitors/DebugTag.js'; import { EachBlock } from './visitors/EachBlock.js'; import { ExportDefaultDeclaration } from './visitors/ExportDefaultDeclaration.js'; @@ -157,6 +158,7 @@ const visitors = { ClassDirective, Component, ConstTag, + DeclarationTag, DebugTag, EachBlock, ExportDefaultDeclaration, @@ -312,6 +314,7 @@ export function analyze_module(source, options) { options: /** @type {ValidatedCompileOptions} */ (options), fragment: null, parent_element: null, + in_declaration_tag: false, reactive_statement: null, derived_function_depth: -1 }, @@ -718,6 +721,7 @@ export function analyze_component(root, source, options) { ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module', fragment: ast === template.ast ? ast : null, parent_element: null, + in_declaration_tag: false, has_props_rune: false, component_slots: new Set(), expression: null, @@ -785,6 +789,7 @@ export function analyze_component(root, source, options) { options, fragment: ast === template.ast ? ast : null, parent_element: null, + in_declaration_tag: false, has_props_rune: false, ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module', reactive_statement: null, diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index 354f1c0856..bd486a12ca 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -16,6 +16,8 @@ export interface AnalysisState { * Parent doesn't necessarily mean direct path predecessor because there could be `#each`, `#if` etc in-between. */ parent_element: string | null; + /** True if inside DeclarationTag */ + in_declaration_tag: boolean; has_props_rune: boolean; /** Which slots the current parent component has */ component_slots: Set; @@ -35,7 +37,7 @@ export interface AnalysisState { */ derived_function_depth: number; - /** Collected info about async `{@const }` declarations */ + /** Collected info about async `{@const }`/`{let/const ...}` declarations */ async_consts?: { id: Identifier; /** How many `$.run(...)` entries are already allocated in this scope */ diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js index 545bc3be27..5a54d62471 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -115,8 +115,7 @@ function is_last_evaluated_expression(path, node) { break; case 'MemberExpression': - if (parent.computed && node === parent.object) return false; - break; + return false; case 'ObjectExpression': if (node !== parent.properties.at(-1)) return false; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 52eba8c735..d3524af4db 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -255,6 +255,9 @@ export function CallExpression(node, context) { if (expression.has_await) { context.state.analysis.async_deriveds.add(node); } + + // Tell surrounding declaration tag about metadata for correct calculation of blockers etc + if (context.state.in_declaration_tag) context.state.expression?.merge(expression); } else if (rune === '$inspect') { context.next({ ...context.state, function_depth: context.state.function_depth + 1 }); } else { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js index 4f07249a39..64e93d3efe 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js @@ -1,8 +1,8 @@ /** @import { AST } from '#compiler' */ /** @import { Context } from '../types' */ import * as e from '../../../errors.js'; -import * as b from '#compiler/builders'; import { validate_opening_tag } from './shared/utils.js'; +import { mark_async_declaration } from './DeclarationTag.js'; /** * @param {AST.ConstTag} node @@ -44,28 +44,5 @@ export function ConstTag(node, context) { derived_function_depth: context.state.function_depth + 1 }); - const has_await = node.metadata.expression.has_await; - const blockers = [...node.metadata.expression.dependencies] - .map((dep) => dep.blocker) - .filter((b) => b !== null && b.object !== context.state.async_consts?.id); - - if (has_await || context.state.async_consts || blockers.length > 0) { - const run = (context.state.async_consts ??= { - id: context.state.analysis.root.unique('promises'), - declaration_count: 0 - }); - node.metadata.promises_id = run.id; - - const bindings = context.state.scope.get_bindings(declaration); - - // keep the counter in sync with the number of thunks pushed in ConstTag in transform - // TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust - // via something like the approach in https://github.com/sveltejs/svelte/pull/18032 - const length = run.declaration_count + (blockers.length > 0 ? 1 : 0); - run.declaration_count += blockers.length > 0 ? 2 : 1; - const blocker = b.member(run.id, b.literal(length), true); - for (const binding of bindings) { - binding.blocker = blocker; - } - } + mark_async_declaration(context, node.metadata, [declaration]); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js new file mode 100644 index 0000000000..52e9480838 --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/DeclarationTag.js @@ -0,0 +1,71 @@ +/** @import { AST } from '#compiler' */ +/** @import { Context } from '../types' */ +import * as b from '#compiler/builders'; +import * as e from '../../../errors.js'; +import { extract_identifiers } from '../../../utils/ast.js'; + +/** + * @param {AST.DeclarationTag} node + * @param {Context} context + */ +export function DeclarationTag(node, context) { + if (!context.state.analysis.runes && !context.state.analysis.maybe_runes) { + e.declaration_tag_no_legacy_mode(node); + } + + const is_top_level = context.path.length === 1 && context.path[0].type === 'Fragment'; + if (is_top_level) { + const duplicate = node.declaration.declarations + .flatMap((declaration) => extract_identifiers(declaration.id)) + .find((id) => context.state.analysis.instance.scope.declarations.has(id.name)); + if (duplicate) { + e.declaration_duplicate(duplicate, duplicate.name); + } + } + + context.visit(node.declaration, { + ...context.state, + in_declaration_tag: true, + // the declaration lives in the fragment scope, which is one level deeper than the + // `function_depth` we're tracking here (`set_scope` doesn't update `function_depth`). + // align them so that `state_referenced_locally` warnings are calculated correctly + function_depth: context.state.scope.function_depth, + expression: node.metadata.expression + }); + + mark_async_declaration(context, node.metadata, node.declaration.declarations); +} + +/** + * @param {Context} context + * @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata + * @param {import('estree').VariableDeclarator[]} declarations + */ +export function mark_async_declaration(context, metadata, declarations) { + const has_await = metadata.expression.has_await; + const blockers = [...metadata.expression.dependencies] + .map((dep) => dep.blocker) + .filter((b) => b !== null && b.object !== context.state.async_consts?.id); + + if (has_await || context.state.async_consts || blockers.length > 0) { + const run = (context.state.async_consts ??= { + id: context.state.analysis.root.unique('promises'), + declaration_count: 0 + }); + metadata.promises_id = run.id; + + const bindings = declarations.flatMap((declaration) => + context.state.scope.get_bindings(declaration) + ); + + // keep the counter in sync with the number of thunks pushed in transform + // TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust + // via something like the approach in https://github.com/sveltejs/svelte/pull/18032 + const length = run.declaration_count + (blockers.length > 0 ? 1 : 0); + run.declaration_count += blockers.length > 0 ? 2 : 1; + const blocker = b.member(run.id, b.literal(length), true); + for (const binding of bindings) { + binding.blocker = blocker; + } + } +} diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js index 5c1e8031b8..ebb2fc2b67 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js @@ -162,7 +162,7 @@ export function Identifier(node, context) { if (binding.metadata?.is_template_declaration && context.state.options.experimental.async) { let snippet_name; - // Find out if this references a {@const ...} declaration of an implicit children snippet + // Find out if this references a {@const ...}/{let/const ...} declaration of an implicit children snippet // when it is itself inside a snippet block at the same level. If so, error. for (let i = context.path.length - 1; i >= 0; i--) { const parent = context.path[i]; diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js index c0350f8e6f..7618716ae5 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js @@ -25,19 +25,24 @@ export function SnippetBlock(node, context) { context.next({ ...context.state, parent_element: null }); - const can_hoist = - context.path.length === 1 && - context.path[0].type === 'Fragment' && - can_hoist_snippet(context.state.scope, context.state.scopes); + const is_top_level = context.path.length === 1 && context.path[0].type === 'Fragment'; - const name = node.expression.name; + if (is_top_level) { + const name = node.expression.name; - if (can_hoist) { - const binding = /** @type {Binding} */ (context.state.scope.get(name)); - context.state.analysis.module.scope.declarations.set(name, binding); - } + if (context.state.analysis.instance.scope.declarations.has(name)) { + e.declaration_duplicate(node.expression, name); + } + + node.metadata.can_hoist = + is_top_level && can_hoist_snippet(context.state.scope, context.state.scopes); - node.metadata.can_hoist = can_hoist; + if (node.metadata.can_hoist) { + const name = node.expression.name; + const binding = /** @type {Binding} */ (context.state.scope.get(name)); + context.state.analysis.module.scope.declarations.set(name, binding); + } + } const { path } = context; const parent = path.at(-2); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js index be3af1e59f..a6e801cf5a 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js @@ -302,7 +302,7 @@ export function check_element(node, context) { const has_key_event = handlers.has('keydown') || handlers.has('keyup') || handlers.has('keypress'); if (!has_key_event) { - w.a11y_click_events_have_key_events(node); + w.a11y_click_events_have_key_events(node, node.name); } } } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/element.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/element.js index 725a4aded8..ff02dee02a 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/element.js @@ -100,6 +100,7 @@ export function validate_element(node, context) { (n) => n.type !== 'Comment' && n.type !== 'ConstTag' && + n.type !== 'DeclarationTag' && (n.type !== 'Text' || n.data.trim() !== '') ).length > 1 ) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 5cd837d08e..552fe89960 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -4,7 +4,7 @@ /** @import { Visitors, ComponentClientTransformState, ClientTransformState } from './types' */ import { walk } from 'zimmerframe'; import * as b from '#compiler/builders'; -import { build_getter, is_state_source } from './utils.js'; +import { build_getter, get_transform } from './utils.js'; import { render_stylesheet } from '../css/index.js'; import { dev, filename } from '../../../state.js'; import { AnimateDirective } from './visitors/AnimateDirective.js'; @@ -22,6 +22,7 @@ import { ClassBody } from './visitors/ClassBody.js'; import { Comment } from './visitors/Comment.js'; import { Component } from './visitors/Component.js'; import { ConstTag } from './visitors/ConstTag.js'; +import { DeclarationTag } from './visitors/DeclarationTag.js'; import { DebugTag } from './visitors/DebugTag.js'; import { EachBlock } from './visitors/EachBlock.js'; import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js'; @@ -66,20 +67,7 @@ const visitors = { const scope = state.scopes.get(node); if (scope && scope !== state.scope) { - const transform = { ...state.transform }; - - for (const [name, binding] of scope.declarations) { - if ( - binding.kind === 'normal' || - // Reads of `$state(...)` declarations are not - // transformed if they are never reassigned - (binding.kind === 'state' && !is_state_source(binding, state.analysis)) - ) { - delete transform[name]; - } - } - - next({ ...state, transform, scope }); + next({ ...state, transform: get_transform(scope, state), scope }); } else { next(); } @@ -99,6 +87,7 @@ const visitors = { Comment, Component, ConstTag, + DeclarationTag, DebugTag, EachBlock, ExportNamedDeclaration, @@ -152,6 +141,7 @@ export function client_component(analysis, options) { scopes: analysis.module.scopes, is_instance: false, hoisted: [b.import_all('$', 'svelte/internal/client'), ...analysis.instance_body.hoisted], + templates: new Map(), node: /** @type {any} */ (null), // populated by the root node legacy_reactive_imports: [], legacy_reactive_statements: new Map(), diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js index 40c0907e38..5fdc88844b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js @@ -1,3 +1,4 @@ +/** @import { TemplateLiteral } from 'estree' */ /** @import { Namespace } from '#compiler' */ /** @import { ComponentClientTransformState } from '../types.js' */ /** @import { Node } from './types.js' */ @@ -31,14 +32,29 @@ function build_locations(nodes) { /** * @param {ComponentClientTransformState} state - * @param {Namespace} namespace + * @param {string} name * @param {number} [flags] */ -export function transform_template(state, namespace, flags = 0) { +export function transform_template(state, name, flags = 0) { + const namespace = state.metadata.namespace; const tree = state.options.fragments === 'tree'; const expression = tree ? state.template.as_tree() : state.template.as_html(); + const key = + tree || dev + ? null + : get_template_key( + /** @type {TemplateLiteral} */ (expression), + state.metadata.namespace, + flags + ); + + if (key !== null) { + const existing = state.templates.get(key); + if (existing !== undefined) return existing; + } + if (tree) { if (namespace === 'svg') flags |= TEMPLATE_USE_SVG; if (namespace === 'mathml') flags |= TEMPLATE_USE_MATHML; @@ -63,5 +79,26 @@ export function transform_template(state, namespace, flags = 0) { ); } - return call; + const id = state.scope.root.unique(name); + state.hoisted.push(b.var(id, call)); + + if (key !== null) { + state.templates.set(key, id); + } + + return id; +} + +/** + * Returns a stable key for templates that are safe to deduplicate - plain + * `$.from_html`/`from_svg`/`from_mathml` factories with literal arguments - or `null` + * for anything else. Dev-mode templates are wrapped in `$.add_locations(...)`, which + * embeds per-call-site locations, so they never produce a key and are never shared. + * @param {TemplateLiteral} template + * @param {Namespace} namespace + * @param {number} flags + * @returns {string | null} + */ +function get_template_key(template, namespace, flags) { + return `${namespace} ${flags} ${template.quasis[0].value.raw}`; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 287bf24ac6..7a95a2d43c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -40,6 +40,8 @@ export interface ComponentClientTransformState extends ClientTransformState { readonly analysis: ComponentAnalysis; readonly options: ValidatedCompileOptions; readonly hoisted: Array; + /** Deduplicates hoisted templates by content, mapping a template key to its hoisted identifier */ + readonly templates: Map; readonly events: Set; readonly store_to_invalidate?: string; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index f21fb43fc1..d52b438d92 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -179,3 +179,24 @@ export function create_derived(state, expression, async = false) { return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk); } } + +/** + * @param {Scope} scope + * @param {ClientTransformState} state + */ +export function get_transform(scope, state) { + const transform = { ...state.transform }; + + for (const [name, binding] of scope.declarations) { + if ( + binding.kind === 'normal' || + // Reads of `$state(...)` declarations are not + // transformed if they are never reassigned + (binding.kind === 'state' && !is_state_source(binding, state.analysis)) + ) { + delete transform[name]; + } + } + + return transform; +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js index 832c56818b..b8def178f2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js @@ -71,14 +71,14 @@ export function AwaitBlock(node, context) { 'await' ); - if (node.metadata.expression.has_blockers()) { + if (node.metadata.expression.has_blockers() || node.metadata.expression.has_await) { context.state.init.push( b.stmt( b.call( '$.async', context.state.node, node.metadata.expression.blockers(), - b.array([]), + b.array([]), // {#await await ...} is special insofar that the await should not be waited on b.arrow([context.state.node], b.block([stmt])) ) ) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js index bf559cd24d..d05d7a8ed9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js @@ -7,6 +7,7 @@ import * as b from '#compiler/builders'; import { create_derived } from '../utils.js'; import { get_value } from './shared/declarations.js'; import { build_expression } from './shared/utils.js'; +import { add_async_declaration } from './DeclarationTag.js'; /** * @param {AST.ConstTag} node @@ -26,7 +27,7 @@ export function ConstTag(node, context) { context.state.transform[declaration.id.name] = { read: get_value }; - add_const_declaration(context.state, declaration.id, expression, node.metadata); + add_const_declaration(context, declaration.id, expression, node.metadata); } else { const identifiers = extract_identifiers(declaration.id); const tmp = b.id(context.state.scope.generate('computed_const')); @@ -63,7 +64,7 @@ export function ConstTag(node, context) { expression = b.call('$.tag', expression, b.literal('[@const]')); } - add_const_declaration(context.state, tmp, expression, node.metadata); + add_const_declaration(context, tmp, expression, node.metadata); for (const node of identifiers) { context.state.transform[node.name] = { @@ -74,38 +75,26 @@ export function ConstTag(node, context) { } /** - * @param {ComponentContext['state']} state + * @param {ComponentContext} context * @param {Identifier} id * @param {Expression} expression * @param {AST.ConstTag['metadata']} metadata */ -function add_const_declaration(state, id, expression, metadata) { +function add_const_declaration(context, id, expression, metadata) { // we need to eagerly evaluate the expression in order to hit any // 'Cannot access x before initialization' errors const after = dev ? [b.stmt(b.call('$.get', id))] : []; - const blockers = [...metadata.expression.dependencies] - .map((dep) => dep.blocker) - .filter((b) => b !== null && b.object !== state.async_consts?.id); - if (metadata.promises_id) { - const run = (state.async_consts ??= { - id: metadata.promises_id, - thunks: [] - }); - - state.consts.push(b.let(id)); - - if (blockers.length === 1) { - run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise'))); - } else if (blockers.length > 0) { - run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers)))); - } - - // keep the number of thunks pushed in sync with ConstTag in analysis phase - const assignment = b.assignment('=', id, expression); - run.thunks.push(b.thunk(assignment, metadata.expression.has_await)); + add_async_declaration( + context, + metadata, + [id], + [b.stmt(b.assignment('=', id, expression))], + 'let' + ); } else { + const { state } = context; state.consts.push(b.const(id, expression)); state.consts.push(...after); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js new file mode 100644 index 0000000000..abec5e828f --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DeclarationTag.js @@ -0,0 +1,89 @@ +/** @import { Expression, Identifier, Pattern, Statement, ExpressionStatement, VariableDeclaration } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { ComponentContext } from '../types' */ +import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js'; +import * as b from '#compiler/builders'; +import { add_state_transformers } from './shared/declarations.js'; + +/** + * @param {AST.DeclarationTag} node + * @param {ComponentContext} context + */ +export function DeclarationTag(node, context) { + // register the transformers _before_ visiting the declaration, so that + // later declarators can reference earlier ones (e.g. `{let a = $state(0), b = $derived(a * 2)}`) + add_state_transformers(context); + const declaration = /** @type {Statement | undefined} */ (context.visit(node.declaration)); + + if ( + node.metadata.promises_id && + node.declaration.type === 'VariableDeclaration' && + declaration?.type === 'VariableDeclaration' + ) { + const { ids, assignments } = build_async_declaration_parts(declaration); + add_async_declaration(context, node.metadata, ids, assignments, declaration.kind); + } else { + context.state.consts.push(declaration ?? node.declaration); + } +} + +/** + * @param {VariableDeclaration} declaration + */ +export function build_async_declaration_parts(declaration) { + const ids = new Map(); + for (const declarator of declaration.declarations) { + for (const id of extract_identifiers(declarator.id)) { + ids.set(id.name, id); + } + } + + const assignments = declaration.declarations + .filter((declarator) => declarator.init !== null) + .map((declarator) => + b.stmt( + b.assignment( + '=', + /** @type {Pattern} */ (declarator.id), + /** @type {Expression} */ (declarator.init) + ) + ) + ); + + return { ids: [...ids.values()], assignments }; +} + +/** + * @param {ComponentContext} context + * @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata + * @param {Identifier[]} ids + * @param {ExpressionStatement[]} assignments + * @param {VariableDeclaration['kind']} [kind] + */ +export function add_async_declaration(context, metadata, ids, assignments, kind = 'let') { + const run = (context.state.async_consts ??= { + id: /** @type {Identifier} */ (metadata.promises_id), + thunks: [] + }); + + for (const id of ids) { + context.state.consts.push(kind === 'var' ? b.var(id.name) : b.let(id.name)); + } + + const blockers = [...metadata.expression.dependencies] + .map((dep) => dep.blocker) + .filter((b) => b !== null && b.object !== context.state.async_consts?.id); + + if (blockers.length === 1) { + run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise'))); + } else if (blockers.length > 0) { + run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers)))); + } + + // keep the number of thunks pushed in sync with analysis phase + const has_await = + metadata.expression.has_await || + assignments.some((assignment) => has_await_expression(assignment)); + const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments); + run.thunks.push(b.thunk(body, has_await)); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index ad0c487fb4..893b1db568 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -52,7 +52,6 @@ export function Fragment(node, context) { (trimmed[0].type === 'IfBlock' && trimmed[0].elseif && /** @type {AST.IfBlock} */ (parent).metadata.flattened?.includes(trimmed[0]))); - const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent /** @type {Statement[]} */ const body = []; @@ -96,8 +95,7 @@ export function Fragment(node, context) { let flags = state.template.needs_import_node ? TEMPLATE_USE_IMPORT_NODE : undefined; - const template = transform_template(state, namespace, flags); - state.hoisted.push(b.var(template_name, template)); + const template_name = transform_template(state, 'root', flags); state.init.unshift(b.var(id, b.call(template_name))); close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); @@ -147,8 +145,7 @@ export function Fragment(node, context) { // special case — we can use `$.comment` instead of creating a unique template state.init.unshift(b.var(id, b.call('$.comment'))); } else { - const template = transform_template(state, namespace, flags); - state.hoisted.push(b.var(template_name, template)); + const template_name = transform_template(state, 'root', flags); state.init.unshift(b.var(id, b.call(template_name))); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 0579d80b74..8dcd3c7d6f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -18,7 +18,7 @@ import { is_customizable_select_element } from '../../../nodes.js'; import { clean_nodes, determine_namespace_for_children } from '../../utils.js'; -import { build_getter } from '../utils.js'; +import { build_getter, get_transform } from '../utils.js'; import { get_attribute_name, build_attribute_value, @@ -201,8 +201,8 @@ export function RegularElement(node, context) { } } - // Let bindings first, they can be used on attributes - context.state.init.push(...lets); + // Let bindings first, they can be used on attributes and `{@const}` declarations + context.state.let_directives.push(...lets); const node_id = context.state.node; @@ -300,11 +300,14 @@ export function RegularElement(node, context) { } } + const scope = /** @type {Scope} */ (context.state.scopes.get(node.fragment)); + /** @type {ComponentClientTransformState} */ const state = { ...context.state, metadata, - scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)), + scope, + transform: get_transform(scope, context.state), preserve_whitespace: context.state.preserve_whitespace || name === 'pre' || name === 'textarea' }; @@ -318,8 +321,19 @@ export function RegularElement(node, context) { state.options.preserveComments ); + const has_declarations = !node.fragment.metadata.transparent; + /** @type {typeof state} */ - const child_state = { ...state, init: [], update: [], after_update: [], snippets: [] }; + const child_state = { + ...state, + init: [], + update: [], + after_update: [], + snippets: [], + consts: has_declarations ? [] : state.consts, + async_consts: has_declarations ? undefined : state.async_consts, + memoizer: has_declarations ? new Memoizer() : state.memoizer + }; for (const node of hoisted) { context.visit(node, child_state); @@ -360,7 +374,6 @@ export function RegularElement(node, context) { context.state.template.push_comment(); // Create a separate template for the rich content - const template_name = context.state.scope.root.unique(`${name}_content`); const fragment_id = b.id(context.state.scope.generate('fragment')); const anchor_id = b.id(context.state.scope.generate('anchor')); @@ -384,9 +397,8 @@ export function RegularElement(node, context) { } ); - // Transform the template to $.from_html(...) and hoist it - const template = transform_template(select_state, metadata.namespace, TEMPLATE_FRAGMENT); - context.state.hoisted.push(b.var(template_name, template)); + // Transform the template to $.from_html(...) and hoist it (deduplicating identical templates) + const template_name = transform_template(select_state, `${name}_content`, TEMPLATE_FRAGMENT); // Build the rich content function body // The anchor is the child of the element (a hydration marker during hydration) @@ -427,11 +439,21 @@ export function RegularElement(node, context) { } } - if (node.fragment.nodes.some((node) => node.type === 'SnippetBlock')) { + if (node.fragment.nodes.some((node) => node.type === 'SnippetBlock') || has_declarations) { + if (child_state.async_consts && child_state.async_consts.thunks.length > 0) { + child_state.consts.push( + b.var( + child_state.async_consts.id, + b.call('$.run', b.array(child_state.async_consts.thunks)) + ) + ); + } + // Wrap children in `{...}` to avoid declaration conflicts context.state.init.push( b.block([ ...child_state.snippets, + ...child_state.consts, ...child_state.init, ...element_state.init, child_state.update.length > 0 ? build_render_statement(child_state) : b.empty, diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js index f929a3bc47..eaffd15084 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js @@ -40,6 +40,7 @@ export function SvelteBoundary(node, context) { const hoisted = []; let has_const = false; + let has_declaration = false; // const tags need to live inside the boundary, but might also be referenced in hoisted snippets. // to resolve this we cheat: we duplicate const tags inside snippets @@ -55,6 +56,10 @@ export function SvelteBoundary(node, context) { }); } } + + if (child.type === 'DeclarationTag') { + has_declaration = true; + } } for (const child of node.fragment.nodes) { @@ -68,10 +73,10 @@ export function SvelteBoundary(node, context) { if (child.type === 'SnippetBlock') { if ( context.state.options.experimental.async && - has_const && + (has_const || has_declaration) && !['failed', 'pending'].includes(child.expression.name) ) { - // we can't hoist snippets as they may reference const tags, so we just keep them in the fragment + // we can't hoist snippets as they may reference const/declaration tags, so we just keep them in the fragment nodes.push(child); } else { /** @type {Statement[]} */ diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index 16aa164d84..b9f4690179 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -49,8 +49,13 @@ export function VariableDeclaration(node, context) { } if (declarator.id.type === 'Identifier') { + const exclude_id = context.state.scope.root.unique('rest_excludes'); + context.state.hoisted.push( + b.var(exclude_id, b.new('Set', b.array(seen.map((name) => b.literal(name))))) + ); + /** @type {Expression[]} */ - const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))]; + const args = [b.id('$$props'), exclude_id]; if (dev) { // include rest name, so we can provide informative error messages @@ -95,8 +100,13 @@ export function VariableDeclaration(node, context) { } } else { // RestElement + const exclude_id = context.state.scope.root.unique('rest_excludes'); + context.state.hoisted.push( + b.var(exclude_id, b.new('Set', b.array(seen.map((name) => b.literal(name))))) + ); + /** @type {Expression[]} */ - const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))]; + const args = [b.id('$$props'), exclude_id]; if (dev) { // include rest name, so we can provide informative error messages @@ -194,11 +204,6 @@ export function VariableDeclaration(node, context) { /** @type {CallExpression} */ (init) ); - // for now, only wrap async derived in $.save if it's not - // a top-level instance derived. TODO in future maybe we - // can dewaterfall all of them? - const should_save = context.state.is_instance && context.state.scope.function_depth > 1; - if (declarator.id.type === 'Identifier') { let expression = /** @type {Expression} */ (context.visit(value)); @@ -213,9 +218,7 @@ export function VariableDeclaration(node, context) { location ? b.literal(location) : undefined ); - call = should_save ? save(call) : b.await(call); - - declarations.push(b.declarator(declarator.id, call)); + declarations.push(b.declarator(declarator.id, b.await(call))); } else { if (rune === '$derived') expression = b.thunk(expression); @@ -251,7 +254,7 @@ export function VariableDeclaration(node, context) { location ? b.literal(location) : undefined ); - call = should_save ? save(call) : b.await(call); + call = b.await(call); } declarations.push(b.declarator(id, call)); @@ -386,13 +389,17 @@ export function VariableDeclaration(node, context) { * @param {Expression} value */ function create_state_declarators(declarator, context, value) { + /** + * @param {Expression} value + * @param {string} name + */ + const mutable_source = (value, name) => { + const call = b.call('$.mutable_source', value, context.state.analysis.immutable && b.true); + return dev ? b.call('$.tag', call, b.literal(name)) : call; + }; + if (declarator.id.type === 'Identifier') { - return [ - b.declarator( - declarator.id, - b.call('$.mutable_source', value, context.state.analysis.immutable ? b.true : undefined) - ) - ]; + return [b.declarator(declarator.id, mutable_source(value, declarator.id.name))]; } const tmp = b.id(context.state.scope.generate('tmp')); @@ -414,7 +421,7 @@ function create_state_declarators(declarator, context, value) { return b.declarator( path.node, binding?.kind === 'state' - ? b.call('$.mutable_source', value, context.state.analysis.immutable ? b.true : undefined) + ? mutable_source(value, /** @type {Identifier} */ (path.node).name) : value ); }) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index 1f3c7b2256..51c84dd01c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -52,7 +52,7 @@ export class Memoizer { * @param {ExpressionMetadata} metadata */ check_blockers(metadata) { - for (const binding of metadata.dependencies) { + for (const binding of metadata.references) { if (binding.blocker) { this.#blockers.add(binding.blocker); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 90a693b99a..24ca58bb41 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -15,6 +15,7 @@ import { CallExpression } from './visitors/CallExpression.js'; import { ClassBody } from './visitors/ClassBody.js'; import { Component } from './visitors/Component.js'; import { ConstTag } from './visitors/ConstTag.js'; +import { DeclarationTag } from './visitors/DeclarationTag.js'; import { DebugTag } from './visitors/DebugTag.js'; import { EachBlock } from './visitors/EachBlock.js'; import { ExpressionStatement } from './visitors/ExpressionStatement.js'; @@ -64,6 +65,7 @@ const template_visitors = { AwaitBlock, Component, ConstTag, + DeclarationTag, DebugTag, EachBlock, Fragment, diff --git a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts index 4912728a1e..2863955786 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts @@ -28,7 +28,7 @@ export interface ComponentServerTransformState extends ServerTransformState { readonly preserve_whitespace: boolean; /** True if the current node is a) a component or render tag and b) the sole child of a block */ readonly is_standalone: boolean; - /** Transformed async `{@const }` declarations (if any) and those coming after them */ + /** Transformed async `{@const }`/`{let/const ...}` declarations (if any) and those coming after them */ async_consts?: { id: Identifier; thunks: Expression[]; diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js index 84c2a81612..ba05ca1357 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js @@ -9,12 +9,19 @@ import { block_close, create_child_block } from './shared/utils.js'; * @param {ComponentContext} context */ export function AwaitBlock(node, context) { + let expression = /** @type {Expression} */ (context.visit(node.expression)); + if (node.metadata.expression.has_await) { + // If this is an await expression, turn it into a IIFE so that the result is a promise. + // {#await await ...} is special insofar that the await should not be waited on. + expression = b.call(b.arrow([], expression, true)); + } + /** @type {Statement} */ let statement = b.stmt( b.call( '$.await', b.id('$$renderer'), - /** @type {Expression} */ (context.visit(node.expression)), + expression, b.thunk( node.pending ? /** @type {BlockStatement} */ (context.visit(node.pending)) : b.block([]) ), diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js index 9420bdd6d2..cf56ef6910 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ConstTag.js @@ -1,8 +1,9 @@ -/** @import { Expression, Pattern, Statement } from 'estree' */ +/** @import { Expression, Pattern } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; import { extract_identifiers } from '../../../../utils/ast.js'; +import { add_async_declaration } from './DeclarationTag.js'; /** * @param {AST.ConstTag} node @@ -12,31 +13,15 @@ export function ConstTag(node, context) { const declaration = node.declaration.declarations[0]; const id = /** @type {Pattern} */ (context.visit(declaration.id)); const init = /** @type {Expression} */ (context.visit(declaration.init)); - const blockers = [...node.metadata.expression.dependencies] - .map((dep) => dep.blocker) - .filter((b) => b !== null && b.object !== context.state.async_consts?.id); if (node.metadata.promises_id) { - const run = (context.state.async_consts ??= { - id: node.metadata.promises_id, - thunks: [] - }); - - const identifiers = extract_identifiers(declaration.id); - - for (const identifier of identifiers) { - context.state.init.push(b.let(identifier.name)); - } - - if (blockers.length === 1) { - run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0]))); - } else if (blockers.length > 0) { - run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers)))); - } - - // keep the number of thunks pushed in sync with ConstTag in analysis phase - const assignment = b.assignment('=', id, init); - run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await)); + add_async_declaration( + context, + node.metadata, + extract_identifiers(id), + [b.stmt(b.assignment('=', id, init))], + 'let' + ); } else { context.state.init.push(b.const(id, init)); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/DeclarationTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/DeclarationTag.js new file mode 100644 index 0000000000..8338700c0b --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/DeclarationTag.js @@ -0,0 +1,85 @@ +/** @import { Expression, Identifier, Pattern, Statement, ExpressionStatement, VariableDeclaration } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { ComponentContext } from '../types.js' */ +import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js'; +import * as b from '#compiler/builders'; + +/** + * @param {AST.DeclarationTag} node + * @param {ComponentContext} context + */ +export function DeclarationTag(node, context) { + const declaration = /** @type {Statement} */ (context.visit(node.declaration)); + + if ( + node.metadata.promises_id && + node.declaration.type === 'VariableDeclaration' && + declaration.type === 'VariableDeclaration' + ) { + const { ids, assignments } = build_async_declaration_parts(declaration); + add_async_declaration(context, node.metadata, ids, assignments, declaration.kind); + } else { + context.state.init.push(declaration); + } +} + +/** + * @param {VariableDeclaration} declaration + */ +export function build_async_declaration_parts(declaration) { + const ids = new Map(); + for (const declarator of declaration.declarations) { + for (const id of extract_identifiers(declarator.id)) { + ids.set(id.name, id); + } + } + + const assignments = declaration.declarations + .filter((declarator) => declarator.init !== null) + .map((declarator) => + b.stmt( + b.assignment( + '=', + /** @type {Pattern} */ (declarator.id), + /** @type {Expression} */ (declarator.init) + ) + ) + ); + + return { ids: [...ids.values()], assignments }; +} + +/** + * @param {ComponentContext} context + * @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata + * @param {Identifier[]} ids + * @param {ExpressionStatement[]} assignments + * @param {VariableDeclaration['kind']} [kind] + */ +export function add_async_declaration(context, metadata, ids, assignments, kind = 'let') { + const run = (context.state.async_consts ??= { + id: /** @type {Identifier} */ (metadata.promises_id), + thunks: [] + }); + + for (const id of ids) { + context.state.init.push(kind === 'var' ? b.var(id.name) : b.let(id.name)); + } + + const blockers = [...metadata.expression.dependencies] + .map((dep) => dep.blocker) + .filter((b) => b !== null && b.object !== context.state.async_consts?.id); + + if (blockers.length === 1) { + run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0]))); + } else if (blockers.length > 0) { + run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers)))); + } + + // keep the number of thunks pushed in sync with analysis phase + const has_await = + metadata.expression.has_await || + assignments.some((assignment) => has_await_expression(assignment)); + const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments); + run.thunks.push(b.thunk(body, has_await)); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js index 42d9bcb667..90c34d7035 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js @@ -17,17 +17,23 @@ import { is_customizable_select_element } from '../../../nodes.js'; export function RegularElement(node, context) { const name = context.state.namespace === 'html' ? node.name.toLowerCase() : node.name; const namespace = determine_namespace_for_children(node, context.state.namespace); + const has_child_declarations = !node.fragment.metadata.transparent; /** @type {ComponentServerTransformState} */ const state = { ...context.state, namespace, + scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)), preserve_whitespace: context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea', init: [], - template: [] + template: [], + async_consts: undefined }; + /** @type {ComponentServerTransformState} */ + const attribute_state = { ...state, scope: context.state.scope }; + const node_is_void = is_void(name); const optimiser = new PromiseOptimiser(); @@ -50,7 +56,11 @@ export function RegularElement(node, context) { if (!is_special) { // only open the tag in the non-special path state.template.push(b.literal(`<${name}`)); - body = build_element_attributes(node, { ...context, state }, optimiser.transform); + body = build_element_attributes( + node, + { ...context, state: attribute_state }, + optimiser.transform + ); state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance } @@ -72,10 +82,7 @@ export function RegularElement(node, context) { node.fragment.nodes, context.path, namespace, - { - ...state, - scope: /** @type {Scope} */ (state.scopes.get(node.fragment)) - }, + state, state.preserve_whitespace, state.options.preserveComments ); @@ -205,7 +212,17 @@ export function RegularElement(node, context) { state.template.push(b.stmt(b.call('$.pop_element'))); } - if (optimiser.is_async()) { + if (has_child_declarations && state.async_consts && state.async_consts.thunks.length > 0) { + state.init.push( + b.var(state.async_consts.id, b.call('$$renderer.run', b.array(state.async_consts.thunks))) + ); + } + + if (has_child_declarations) { + context.state.template.push( + ...optimiser.render([b.block([...state.init, ...build_template(state.template)])]) + ); + } else if (optimiser.is_async()) { context.state.template.push( ...optimiser.render([...state.init, ...build_template(state.template)]) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index 62e1c44094..3da6fcf321 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -343,7 +343,7 @@ export class PromiseOptimiser { * @param {ExpressionMetadata} metadata */ check_blockers(metadata) { - for (const binding of metadata.dependencies) { + for (const binding of metadata.references) { if (binding.blocker) { this.#blockers.add(binding.blocker); } diff --git a/packages/svelte/src/compiler/phases/3-transform/utils.js b/packages/svelte/src/compiler/phases/3-transform/utils.js index f61b59f3bd..c7fce61ff9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/utils.js @@ -152,6 +152,7 @@ export function clean_nodes( if ( node.type === 'ConstTag' || + node.type === 'DeclarationTag' || node.type === 'DebugTag' || node.type === 'SvelteBody' || node.type === 'SvelteWindow' || diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 2e54f333a8..da7e73015b 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -102,8 +102,8 @@ export class ExpressionMetadata { if (!this.#blockers) { this.#blockers = new Set(); - for (const d of this.dependencies) { - if (d.blocker) this.#blockers.add(d.blocker); + for (const r of this.references) { + if (r.blocker) this.#blockers.add(r.blocker); } } @@ -217,6 +217,7 @@ function* find_descendants(fragment) { case 'SnippetBlock': case 'DebugTag': case 'ConstTag': + case 'DeclarationTag': case 'Comment': case 'ExpressionTag': break; diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js index c511ee6da0..fbd7e86f12 100644 --- a/packages/svelte/src/compiler/print/index.js +++ b/packages/svelte/src/compiler/print/index.js @@ -603,6 +603,48 @@ const svelte_visitors = (comments) => ({ context.write('}'); }, + DeclarationTag(node, context) { + context.write('{'); + + // This is duplicated from esrap's handling of VariableDeclaration, + // which we need to do in order to omit the trailing semicolon that esrap would add. + const open = context.new(); + const join = context.new(); + const child_context = context.new(); + + context.append(child_context); + + child_context.write(`${node.declaration.kind} `); + child_context.append(open); + + const declarations = node.declaration.declarations; + let first = true; + + for (const d of declarations) { + if (!first) child_context.append(join); + first = false; + + child_context.visit(d); + } + + const length = child_context.measure() + 2 * (declarations.length - 1); + + const multiline = child_context.multiline || (declarations.length > 1 && length > 50); + + if (multiline) { + context.multiline = true; + + if (declarations.length > 1) open.indent(); + join.write(','); + join.newline(); + if (declarations.length > 1) context.dedent(); + } else { + join.write(', '); + } + + context.write('}'); + }, + DebugTag(node, context) { context.write('{@debug '); let started = false; diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 2964e46761..4d4b6fc21f 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -160,6 +160,18 @@ export namespace AST { }; } + /** A `{let ...}` or `{const ...}` tag */ + export interface DeclarationTag extends BaseNode { + type: 'DeclarationTag'; + declaration: VariableDeclaration; + /** @internal */ + metadata: { + expression: ExpressionMetadata; + /** If this declaration tag contains an await expression, or needs to wait on other async, this is set */ + promises_id?: Identifier; + }; + } + /** A `{@debug ...}` tag */ export interface DebugTag extends BaseNode { type: 'DebugTag'; @@ -622,6 +634,7 @@ export namespace AST { export type Tag = | AST.AttachTag | AST.ConstTag + | AST.DeclarationTag | AST.DebugTag | AST.ExpressionTag | AST.HtmlTag diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 7508caf3e7..1f48f7fd8b 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -686,7 +686,8 @@ export { if_builder as if, this_instance as this, null_instance as null, - debugger_builder as debugger + debugger_builder as debugger, + new_builder as new }; /** diff --git a/packages/svelte/src/compiler/warnings.js b/packages/svelte/src/compiler/warnings.js index 089cb1e118..98f407671d 100644 --- a/packages/svelte/src/compiler/warnings.js +++ b/packages/svelte/src/compiler/warnings.js @@ -166,11 +166,12 @@ export function a11y_autofocus(node) { } /** - * Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as ` + +{#await request(count) then result}{result}{/await} +{#await await push(count) + count then result}{result}{/await} +{#await await 1 then result}{result}{/await} diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js new file mode 100644 index 0000000000..c2be623de2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [x, x_y, pop] = target.querySelectorAll('button'); + + x.click(); + await tick(); + x_y.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ' 2 1 1' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte new file mode 100644 index 0000000000..61efd4fca0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte @@ -0,0 +1,25 @@ + + + + + + +{await push(x)} {await push(y)} + +{#if true} + {y} +{/if} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js new file mode 100644 index 0000000000..faf1ff7f6b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js @@ -0,0 +1,17 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' done'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte new file mode 100644 index 0000000000..4780442293 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte @@ -0,0 +1,21 @@ + + + + +{#if count < 3} + {await push(count)} +{:else} + done +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js new file mode 100644 index 0000000000..928db008e6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js @@ -0,0 +1,58 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [inc_count, inc_both, shift] = target.querySelectorAll('button'); + + inc_both.click(); + await tick(); + inc_count.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 0 + 0 + + ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 1 + 2 + + ` + ); + + const button = /** @type {HTMLButtonElement} */ (target.querySelector('button:last-child')); + button.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 2 + 2 + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte new file mode 100644 index 0000000000..92b7669fa9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte @@ -0,0 +1,23 @@ + + + + + + +{await push(other)} +{#if count % 2 === 0} + {await push(count)} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js new file mode 100644 index 0000000000..0dd4b870d5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js @@ -0,0 +1,13 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['async-server', 'client', 'hydrate'], + ssrHtml: `

Hello, world!

5 01234 5 sync 6 5 0 10`, + + async test({ assert, target }) { + await tick(); + + assert.htmlEqual(target.innerHTML, `

Hello, world!

5 01234 5 sync 6 5 0 10`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/main.svelte new file mode 100644 index 0000000000..f15065e4db --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/main.svelte @@ -0,0 +1,31 @@ + + + + {const sync = 'sync'} + {const number = await Promise.resolve(5)} + {const after_async =number + 1} + {const { length, 0: first } = await '01234'} + + {#snippet greet()} + {const greeting = $derived(await `Hello, ${name}!`)} +

{greeting}

+ {number} + {#if number > 4 && after_async && greeting} + {const length = $derived(await number)} + {#each { length }, index} + {const i = $derived(await index)} + {i} + {/each} + {/if} + {/snippet} + + {@render greet()} + {number} {sync} {after_async} {length} {first} + + {#if sync} + {const double = $derived(number * 2)} + {double} + {/if} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/_config.js b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/_config.js new file mode 100644 index 0000000000..8fd2fc0976 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/_config.js @@ -0,0 +1,43 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [top, change] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + +

Hello name

+
nested Hi name
+ ` + ); + + top.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

Hello name

+
nested Hi name
+ ` + ); + + change.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

Hello other

+
nested Hi other
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/main.svelte new file mode 100644 index 0000000000..4521ea2e41 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag/main.svelte @@ -0,0 +1,20 @@ + + +{let name = $state(top_id)} + + +{#if id} + {let name = $state(await id)} + {let greeting = $derived(await `Hello ${name}`)} + + +

{greeting}

+
+ {const nested = 'nested'} + {const greeting2 = $derived(await `Hi ${name}`)} + {nested} {greeting2} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/Child.svelte new file mode 100644 index 0000000000..6e5197b2fe --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/Child.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/_config.js new file mode 100644 index 0000000000..37a3f0dae1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/_config.js @@ -0,0 +1,11 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['hydrate'], + + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, 'foo '); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/main.svelte new file mode 100644 index 0000000000..c87d3f8a08 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-prop-closure-hydration/main.svelte @@ -0,0 +1,12 @@ + + +{foo} +'); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ''); + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/main.svelte new file mode 100644 index 0000000000..78475047cd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/main.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/_config.js b/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/_config.js new file mode 100644 index 0000000000..fdc773751b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/_config.js @@ -0,0 +1,15 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // Test that an async derived inside an $effect.root not connected to the component tree still works + async test({ assert, logs }) { + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.deepEqual(logs, [1, 1]); + const [button] = document.querySelectorAll('button'); + + button.click(); + await tick(); + assert.deepEqual(logs, [1, 1, 2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/main.svelte new file mode 100644 index 0000000000..0ddb7d01d7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/main.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/_config.js b/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/_config.js new file mode 100644 index 0000000000..b7451282ac --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/_config.js @@ -0,0 +1,43 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 4 + ` + ); + + increment.click(); + await tick(); + increment.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + 8 + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/main.svelte new file mode 100644 index 0000000000..58a5848e3b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-duplicate-dependencies/main.svelte @@ -0,0 +1,20 @@ + + + + +{await request(count)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-const-await-iife/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-const-await-iife/_config.js new file mode 100644 index 0000000000..958c3023f1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-const-await-iife/_config.js @@ -0,0 +1,14 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that an IIFE referencing an `await` @const inside an {#each} block +// correctly registers the async dependency so the template waits for the +// resolved value instead of rendering with `undefined`. +export default test({ + mode: ['client', 'hydrate', 'async-server'], + ssrHtml: '

example.com

', + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, '

example.com

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-const-await-iife/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-const-await-iife/main.svelte new file mode 100644 index 0000000000..e0ccbb7892 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-const-await-iife/main.svelte @@ -0,0 +1,10 @@ + + +{#each { length: 1 } as nothing} + {@const host = await get_host()} +

{(() => host)()}

+{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte new file mode 100644 index 0000000000..18856d71e1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte @@ -0,0 +1,6 @@ + + +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js new file mode 100644 index 0000000000..7a6e436825 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js @@ -0,0 +1,10 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // Test that $effect/onMount etc at the top level of components are correctly deferred/coordinated if inside an async block + async test({ assert, logs }) { + await tick(); + assert.deepEqual(logs, [true]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte new file mode 100644 index 0000000000..934d9d7f5b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/_config.js new file mode 100644 index 0000000000..b76ea85bfd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/_config.js @@ -0,0 +1,12 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, logs }) { + await tick(); + + assert.deepEqual(logs, [ + `effect_orphan\n\`$effect\` can only be used inside an effect (e.g. during component initialisation)\nhttps://svelte.dev/e/effect_orphan` + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/main.svelte new file mode 100644 index 0000000000..086a048025 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/main.svelte @@ -0,0 +1,14 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/Child.svelte new file mode 100644 index 0000000000..8bfc51bb7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/Child.svelte @@ -0,0 +1,6 @@ + + +

{model.title}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/_config.js new file mode 100644 index 0000000000..a3cda2f865 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/_config.js @@ -0,0 +1,13 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [button] = target.querySelectorAll('button'); + + button.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, '

error was contained

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/main.svelte new file mode 100644 index 0000000000..3b5f51742a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/main.svelte @@ -0,0 +1,27 @@ + + + + + + {#if open} + + + + {#snippet pending()} +

loading…

+ {/snippet} + + {#snippet failed()} +

error was contained

+ {/snippet} +
+ {/if} + + {#snippet failed()} +

error escaped containment

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js new file mode 100644 index 0000000000..e127fd03ec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js @@ -0,0 +1,39 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [fork, real, resolve] = target.querySelectorAll('button'); + + fork.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + 0 + + + + ` + ); + assert.deepEqual(logs, [0]); + + real.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + 1 + + + + ` + ); + assert.deepEqual(logs, [0, 1]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte new file mode 100644 index 0000000000..85056e02b8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte @@ -0,0 +1,23 @@ + + +{await push(count)} + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js new file mode 100644 index 0000000000..5e18c953c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' failed'); + + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' failed'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte new file mode 100644 index 0000000000..3293e4ab88 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte @@ -0,0 +1,22 @@ + + + + + + + {await push(count)} + + {#snippet failed()}failed{/snippet} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js new file mode 100644 index 0000000000..e7359efa2d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // This test mainly checks that we don't run into the 'Batch has scheduled roots' invariant wrongly. + // It is crafted such that two batches are scheduled to run in the same microtask, and the first + // tries to rebase the second. + async test({ assert, target }) { + await tick(); + const [run] = target.querySelectorAll('button'); + + run.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' none none 0'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte new file mode 100644 index 0000000000..19305cfb76 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte @@ -0,0 +1,8 @@ + + + + +{selectedId ?? "none"} {selectedOption ?? "none"} {$effect.pending()} diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/_config.js new file mode 100644 index 0000000000..4bd8adc9c5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/_config.js @@ -0,0 +1,15 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { dev: true }, + async test({ assert, target, warnings }) { + await tick(); + const [increment] = target.querySelectorAll('button'); + + increment.click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.htmlEqual(target.innerHTML, ' 1 1'); + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/main.svelte new file mode 100644 index 0000000000..d47d99e652 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/main.svelte @@ -0,0 +1,16 @@ + + + + +{await x} +{y} diff --git a/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js new file mode 100644 index 0000000000..29ed72d90a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.deepEqual(logs, ['settled 2', 'settled 2']); + assert.htmlEqual( + target.innerHTML, + ` + 2 + + + ` + ); + + pop.click(); + await tick(); + assert.deepEqual(logs, ['settled 2', 'settled 2']); + assert.htmlEqual( + target.innerHTML, + ` + 2 + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte new file mode 100644 index 0000000000..c3675f8add --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte @@ -0,0 +1,22 @@ + + +{await push(count)} + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-sole-if-child/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-sole-if-child/Child.svelte new file mode 100644 index 0000000000..304b7bd8a9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-sole-if-child/Child.svelte @@ -0,0 +1,7 @@ + + +
+