diff --git a/.changeset/big-months-shout.md b/.changeset/big-months-shout.md deleted file mode 100644 index 3d8ca0045e..0000000000 --- a/.changeset/big-months-shout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: drop dead code that make TSGO fail diff --git a/.changeset/calm-events-cleanup.md b/.changeset/calm-events-cleanup.md new file mode 100644 index 0000000000..4f5c2357f1 --- /dev/null +++ b/.changeset/calm-events-cleanup.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: cancel deferred event listeners during cleanup diff --git a/.changeset/config.json b/.changeset/config.json index b56077a922..785aa01e70 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", - "changelog": ["@svitejs/changesets-changelog-github-compact", { "repo": "sveltejs/svelte" }], + "changelog": ["@changesets/changelog-github", { "repo": "sveltejs/svelte", "template": "\n- {summary} {ref}" }], "commit": false, "fixed": [], "linked": [], diff --git a/.changeset/easy-points-tan.md b/.changeset/easy-points-tan.md new file mode 100644 index 0000000000..60edbf94eb --- /dev/null +++ b/.changeset/easy-points-tan.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: speed up parser interactions with Acorn or avoid them where possible diff --git a/.changeset/green-inputs-ssr.md b/.changeset/green-inputs-ssr.md new file mode 100644 index 0000000000..e034d0e79a --- /dev/null +++ b/.changeset/green-inputs-ssr.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: serialize input default values during server rendering diff --git a/.changeset/grumpy-pens-press.md b/.changeset/grumpy-pens-press.md new file mode 100644 index 0000000000..0ba87ab0c5 --- /dev/null +++ b/.changeset/grumpy-pens-press.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: remove `WAS_MARKED` flag in favor of `Set` diff --git a/.changeset/olive-mice-argue.md b/.changeset/olive-mice-argue.md new file mode 100644 index 0000000000..6d749bcc3b --- /dev/null +++ b/.changeset/olive-mice-argue.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: throw `set_context_after_init` when `setContext` is called after an `await` during SSR diff --git a/.changeset/rich-jokes-attack.md b/.changeset/rich-jokes-attack.md deleted file mode 100644 index 46e7d2d8c8..0000000000 --- a/.changeset/rich-jokes-attack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: correctly transform declaration tags during SSR diff --git a/.changeset/tall-kids-juggle.md b/.changeset/tall-kids-juggle.md new file mode 100644 index 0000000000..f116590a28 --- /dev/null +++ b/.changeset/tall-kids-juggle.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: avoid regex matching in parser where possible diff --git a/.changeset/tricky-cooks-help.md b/.changeset/tricky-cooks-help.md new file mode 100644 index 0000000000..f61c7944a1 --- /dev/null +++ b/.changeset/tricky-cooks-help.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: in non-async mode, only push variable to current_sources when active_reaction is updating diff --git a/.changeset/wild-cases-smile.md b/.changeset/wild-cases-smile.md new file mode 100644 index 0000000000..ac5edacfd1 --- /dev/null +++ b/.changeset/wild-cases-smile.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: recognise `aria-braillelabel` and `aria-brailleroledescription` as known ARIA attributes diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index b6c26e0792..5cdbd34e5b 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -28,7 +28,7 @@ jobs: - name: Get PR ref if: github.event_name != 'workflow_dispatch' id: pr - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const { data: pull } = await github.rest.pulls.get({ @@ -46,12 +46,12 @@ jobs: core.setFailed('PR is from a fork'); } core.setOutput('ref', pull.head.ref); - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 if: github.event_name == 'workflow_dispatch' || steps.pr.outcome == 'success' with: ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || steps.pr.outputs.ref }} - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 365717755e..3846690135 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,9 @@ jobs: os: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ matrix.node-version }} cache: pnpm @@ -48,9 +48,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 22 cache: pnpm @@ -65,9 +65,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm @@ -82,9 +82,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm @@ -105,9 +105,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm diff --git a/.github/workflows/ecosystem-ci-trigger.yml b/.github/workflows/ecosystem-ci-trigger.yml index 8a6d1bf345..8691a64ca4 100644 --- a/.github/workflows/ecosystem-ci-trigger.yml +++ b/.github/workflows/ecosystem-ci-trigger.yml @@ -17,7 +17,7 @@ jobs: contents: read # to clone the repo steps: - name: Check User Permissions - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: check-permissions with: script: | @@ -56,7 +56,7 @@ jobs: } - name: Get PR Data - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: get-pr-data with: script: | @@ -106,7 +106,7 @@ jobs: - name: Generate Token id: generate-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }} private-key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }} @@ -115,7 +115,7 @@ jobs: svelte-ecosystem-ci - name: Trigger Downstream Workflow - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: trigger env: COMMENT: ${{ github.event.comment.body }} diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml deleted file mode 100644 index 0fcda5a778..0000000000 --- a/.github/workflows/pkg.pr.new.yml +++ /dev/null @@ -1,229 +0,0 @@ -name: pkg.pr.new -on: - pull_request_target: - types: [opened, synchronize] - push: - branches: [main] - workflow_dispatch: - inputs: - sha: - description: 'Commit SHA to build' - required: true - type: string - pr: - description: 'PR number to comment on' - required: true - type: number - -permissions: {} - -jobs: - build: - # Skip pull_request_target events from forks — maintainers can use workflow_dispatch instead - if: > - github.event_name != 'pull_request_target' || - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - # No permissions — this job runs user-controlled code - permissions: {} - - steps: - - uses: actions/checkout@v6 - with: - # For pull_request_target, check out the PR head. - # For workflow_dispatch, check out the manually specified SHA. - # For push, fall back to the push SHA. - ref: ${{ github.event.pull_request.head.sha || inputs.sha || github.sha }} - - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - run: pnpx pkg-pr-new publish --comment=off --json output.json --compact --no-template './packages/svelte' - - - name: Upload output - uses: actions/upload-artifact@v4 - with: - name: output - path: ./output.json - - # Sanitizes the untrusted output from the build job before it's consumed by - # jobs with elevated permissions. This ensures that only known package names - # and valid SHA prefixes make it through. - sanitize: - needs: build - runs-on: ubuntu-latest - - permissions: {} - - steps: - - name: Download artifact - uses: actions/download-artifact@v7 - with: - name: output - - - name: Sanitize output - uses: actions/github-script@v8 - with: - script: | - const fs = require('fs'); - const raw = JSON.parse(fs.readFileSync('output.json', 'utf8')); - - const ALLOWED_PACKAGES = new Set(['svelte']); - const SHA_PATTERN = /^[0-9a-f]{7}$/; - - const packages = (raw.packages || []) - .filter(p => { - if (!ALLOWED_PACKAGES.has(p.name)) { - console.log(`Skipping unexpected package: ${JSON.stringify(p.name)}`); - return false; - } - const sha = p.url?.replace(/^.+@([^@]+)$/, '$1'); - if (!sha || !SHA_PATTERN.test(sha)) { - console.log(`Skipping package with invalid SHA: ${JSON.stringify(p.url)}`); - return false; - } - return true; - }) - .map(p => ({ - name: p.name, - sha: p.url.replace(/^.+@([^@]+)$/, '$1'), - })); - - fs.writeFileSync('sanitized-output.json', JSON.stringify({ packages }), 'utf8'); - - - name: Upload sanitized output - uses: actions/upload-artifact@v4 - with: - name: sanitized-output - path: ./sanitized-output.json - - comment: - needs: sanitize - if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - - permissions: - contents: read - pull-requests: write - - steps: - - name: Download sanitized artifact - uses: actions/download-artifact@v7 - with: - name: sanitized-output - - - name: Resolve PR number - id: pr - uses: actions/github-script@v8 - with: - script: | - if (context.eventName === 'pull_request_target') { - core.setOutput('number', context.issue.number); - return; - } - - // For workflow_dispatch, use the explicitly provided PR number. - // We can't use listPullRequestsAssociatedWithCommit because fork - // commits don't exist in the base repo, so the API returns nothing. - const pr = Number('${{ inputs.pr }}'); - if (!pr || isNaN(pr)) { - core.setFailed('workflow_dispatch requires a valid pr input'); - return; - } - - core.setOutput('number', pr); - - - name: Post or update comment - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8')); - - if (packages.length === 0) { - console.log('No valid packages found. Skipping comment.'); - return; - } - - const issue_number = parseInt('${{ steps.pr.outputs.number }}', 10); - - const bot_comment_identifier = ``; - - const body = `${bot_comment_identifier} - - [Playground](https://svelte.dev/playground?version=pr-${issue_number}) - - \`\`\` - ${packages.map(p => `pnpm add https://pkg.pr.new/${p.name}@${issue_number}`).join('\n')} - \`\`\` - `; - - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number, - }); - const existing = comments.data.find(c => c.body.includes(bot_comment_identifier)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number, - body, - }); - } - - log: - needs: sanitize - if: github.event_name == 'push' - runs-on: ubuntu-latest - - permissions: {} - - steps: - - name: Download sanitized artifact - uses: actions/download-artifact@v7 - with: - name: sanitized-output - - - name: Log publish info - uses: actions/github-script@v8 - with: - script: | - const fs = require('fs'); - const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8')); - - if (packages.length === 0) { - console.log('No valid packages found.'); - return; - } - - console.log('\n' + '='.repeat(50)); - console.log('Publish Information'); - console.log('='.repeat(50)); - for (const p of packages) { - console.log(`${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.sha}`); - } - const svelte = packages.find(p => p.name === 'svelte'); - if (svelte) { - console.log(`\nPlayground: https://svelte.dev/playground?version=commit-${svelte.sha}`); - } - console.log('='.repeat(50)); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 359fcb7eea..12fe17582e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,13 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repo - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits fetch-depth: 0 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24.x cache: pnpm diff --git a/.gitignore b/.gitignore index 556cae6344..e4f9f9cf87 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ packages/svelte/scripts/_baseline/ benchmarking/.profiles benchmarking/compare/.results benchmarking/compare/.profiles +benchmarking/compare/results.html diff --git a/benchmarking/analyze-compiler-profile.js b/benchmarking/analyze-compiler-profile.js new file mode 100644 index 0000000000..41729b7d93 --- /dev/null +++ b/benchmarking/analyze-compiler-profile.js @@ -0,0 +1,346 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(fileURLToPath(import.meta.url), '../..'); +const compiler_path = 'packages/svelte/src/compiler/'; + +/** + * Merge V8 profiles from a Vitest run into a compiler-only flame graph and hotspot summary. + * Samples retain callees outside the compiler once execution has entered compiler code. + * @param {string} run_dir + */ +export function analyze_compiler_profiles(run_dir) { + const raw_dir = path.join(run_dir, 'raw'); + const profile_files = fs + .readdirSync(raw_dir) + .filter((file) => file.endsWith('.cpuprofile')) + .sort(); + + if (profile_files.length === 0) { + throw new Error(`No CPU profiles found in ${raw_dir}`); + } + + /** @type {Array<{ name: string, file?: string, line?: number, col?: number }>} */ + const frames = []; + /** @type {Map} */ + const frame_indices = new Map(); + /** @type {Map} */ + const stacks = new Map(); + /** @type {Map} */ + const hotspots = new Map(); + /** @type {Map} */ + const files = new Map(); + let total_time = 0; + let compiler_time = 0; + let garbage_collection_time = 0; + let total_samples = 0; + let compiler_samples = 0; + + for (const profile_file of profile_files) { + let profile; + try { + profile = JSON.parse(fs.readFileSync(path.join(raw_dir, profile_file), 'utf8')); + } catch (error) { + throw new Error(`Could not parse CPU profile ${profile_file}`, { cause: error }); + } + const nodes = Array.isArray(profile.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile.samples) ? profile.samples : []; + const time_deltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : []; + const nodes_by_id = new Map(nodes.map((node) => [node.id, node])); + const parents = new Map(); + + for (const node of nodes) { + for (const child of node.children || []) { + parents.set(child, node.id); + } + } + + for (let i = 0; i < samples.length; i += 1) { + const weight = typeof time_deltas[i] === 'number' ? time_deltas[i] : 1; + const stack = get_stack(samples[i], nodes_by_id, parents); + total_time += weight; + total_samples += 1; + + if (stack.at(-1)?.function_name === '(garbage collector)') { + garbage_collection_time += weight; + } + + const compiler_index = stack.findIndex((frame) => frame.url.includes(compiler_path)); + if (compiler_index === -1) continue; + + const compiler_stack = stack.slice(compiler_index); + const stack_indices = compiler_stack.map(get_frame_index); + const stack_key = stack_indices.join(','); + const existing_stack = stacks.get(stack_key); + + if (existing_stack) { + existing_stack.weight += weight; + } else { + stacks.set(stack_key, { stack: stack_indices, weight }); + } + + compiler_time += weight; + compiler_samples += 1; + + const leaf = compiler_stack.at(-1); + if (leaf) { + get_hotspot(hotspots, frame_key(leaf), leaf).self += weight; + get_hotspot(files, leaf.url || '(native)', { + ...leaf, + function_name: leaf.url || '(native)' + }).self += weight; + } + + const seen_frames = new Set(); + const seen_files = new Set(); + + for (const frame of compiler_stack) { + const key = frame_key(frame); + if (!seen_frames.has(key)) { + get_hotspot(hotspots, key, frame).inclusive += weight; + seen_frames.add(key); + } + + const file = frame.url || '(native)'; + if (!seen_files.has(file)) { + get_hotspot(files, file, { ...frame, function_name: file }).inclusive += weight; + seen_files.add(file); + } + } + } + } + + if (compiler_time === 0) { + throw new Error('The CPU profiles contain no samples from packages/svelte/src/compiler'); + } + + const hotspot_rows = rank(hotspots, compiler_time); + const file_rows = rank(files, compiler_time); + const compact_stacks = [...stacks.values()]; + const summary = { + profile_files: profile_files.length, + total_samples, + compiler_samples, + total_time_microseconds: total_time, + compiler_time_microseconds: compiler_time, + garbage_collection_time_microseconds: garbage_collection_time, + compiler_share_percent: (compiler_time * 100) / total_time, + hotspots: hotspot_rows, + files: file_rows + }; + const speedscope = { + $schema: 'https://www.speedscope.app/file-format-schema.json', + name: `Svelte compiler: ${path.basename(run_dir)}`, + exporter: 'Svelte compiler test profiler', + activeProfileIndex: 0, + shared: { frames }, + profiles: [ + { + type: 'sampled', + name: 'Compiler-active samples from pnpm test', + unit: 'microseconds', + startValue: 0, + endValue: compiler_time, + samples: compact_stacks.map((entry) => entry.stack), + weights: compact_stacks.map((entry) => entry.weight) + } + ] + }; + + fs.writeFileSync( + path.join(run_dir, 'flamegraph.speedscope.json'), + `${JSON.stringify(speedscope)}\n` + ); + fs.writeFileSync(path.join(run_dir, 'summary.json'), `${JSON.stringify(summary, null, '\t')}\n`); + fs.writeFileSync(path.join(run_dir, 'summary.md'), render_markdown(summary)); + + return summary; + + /** @param {Frame} frame */ + function get_frame_index(frame) { + const key = frame_key(frame); + const existing = frame_indices.get(key); + if (existing !== undefined) return existing; + + const index = frames.length; + const location = frame.url ? ` (${frame.url}:${frame.line})` : ''; + frames.push({ + name: `${frame.function_name}${location}`, + ...(frame.url ? { file: frame.url, line: frame.line, col: frame.column } : {}) + }); + frame_indices.set(key, index); + return index; + } +} + +/** + * @param {number} leaf_id + * @param {Map} nodes_by_id + * @param {Map} parents + * @returns {Frame[]} + */ +function get_stack(leaf_id, nodes_by_id, parents) { + /** @type {Frame[]} */ + const stack = []; + const seen = new Set(); + let id = leaf_id; + + while (typeof id === 'number' && !seen.has(id)) { + seen.add(id); + const node = nodes_by_id.get(id); + if (!node) break; + + const call_frame = node.callFrame || {}; + stack.push({ + function_name: call_frame.functionName || '(anonymous)', + url: normalize_url(call_frame.url || ''), + line: typeof call_frame.lineNumber === 'number' ? call_frame.lineNumber + 1 : 0, + column: typeof call_frame.columnNumber === 'number' ? call_frame.columnNumber + 1 : 0 + }); + id = parents.get(id); + } + + return stack.reverse(); +} + +/** @param {string} url */ +function normalize_url(url) { + if (!url) return ''; + + let pathname = url.replace(/^\/\@fs\//, '/').replace(/[?#].*$/, ''); + if (pathname.startsWith('file://')) { + try { + pathname = fileURLToPath(pathname); + } catch { + return url; + } + } + + if (path.isAbsolute(pathname)) { + const relative = path.relative(root, pathname); + if (!relative.startsWith('..') && !path.isAbsolute(relative)) { + pathname = relative; + } + } + + return pathname.replaceAll(path.sep, '/'); +} + +/** @param {Frame} frame */ +function frame_key(frame) { + return `${frame.function_name}\0${frame.url}\0${frame.line}\0${frame.column}`; +} + +/** + * @param {Map} collection + * @param {string} key + * @param {Frame} frame + */ +function get_hotspot(collection, key, frame) { + let hotspot = collection.get(key); + if (!hotspot) { + hotspot = { ...frame, self: 0, inclusive: 0 }; + collection.set(key, hotspot); + } + return hotspot; +} + +/** + * @param {Map} collection + * @param {number} total + */ +function rank(collection, total) { + return [...collection.values()] + .map((hotspot) => ({ + function: hotspot.function_name, + url: hotspot.url, + line: hotspot.line, + column: hotspot.column, + self_microseconds: hotspot.self, + self_percent: (hotspot.self * 100) / total, + inclusive_microseconds: hotspot.inclusive, + inclusive_percent: (hotspot.inclusive * 100) / total + })) + .sort( + (a, b) => + b.self_microseconds - a.self_microseconds || + b.inclusive_microseconds - a.inclusive_microseconds + ); +} + +/** @param {ReturnType} summary */ +function render_markdown(summary) { + const lines = [ + '# Svelte compiler CPU profile', + '', + 'Open `flamegraph.speedscope.json` in [Speedscope](https://www.speedscope.app/) for the interactive flame graph.', + '', + '## Coverage', + '', + '| Metric | Value |', + '| --- | ---: |', + `| Raw profiles | ${summary.profile_files} |`, + `| All profiled CPU time | ${format_time(summary.total_time_microseconds)} |`, + `| Compiler-active CPU time | ${format_time(summary.compiler_time_microseconds)} |`, + `| Compiler share | ${summary.compiler_share_percent.toFixed(2)}% |`, + `| All-process garbage collection | ${format_time(summary.garbage_collection_time_microseconds)} |`, + '', + 'Compiler-active time includes external callees while a compiler frame is on the stack. Garbage collection is reported for context but cannot be attributed to compiler stacks by V8.', + '', + '## Top Self Hotspots', + '', + '| Rank | Function | Location | Self | Self % | Inclusive | Inclusive % |', + '| ---: | --- | --- | ---: | ---: | ---: | ---: |' + ]; + + for (const [index, row] of summary.hotspots.slice(0, 50).entries()) { + lines.push(render_row(index, row)); + } + + lines.push( + '', + '## Top Files', + '', + '| Rank | File | Self | Self % | Inclusive | Inclusive % |', + '| ---: | --- | ---: | ---: | ---: | ---: |' + ); + + for (const [index, row] of summary.files.slice(0, 50).entries()) { + lines.push( + `| ${index + 1} | ${escape_cell(row.function)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |` + ); + } + + return `${lines.join('\n')}\n`; +} + +/** @param {number} index @param {ReturnType[number]} row */ +function render_row(index, row) { + const location = row.url ? `${row.url}:${row.line}:${row.column}` : '(native)'; + return `| ${index + 1} | ${escape_cell(row.function)} | ${escape_cell(location)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |`; +} + +/** @param {number} microseconds */ +function format_time(microseconds) { + return `${(microseconds / 1000).toFixed(1)} ms`; +} + +/** @param {string} value */ +function escape_cell(value) { + return value.replaceAll('|', '\\|'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const run_dir = process.argv[2]; + if (!run_dir) { + console.error('Usage: node benchmarking/analyze-compiler-profile.js '); + process.exit(1); + } + analyze_compiler_profiles(path.resolve(run_dir)); +} + +/** + * @typedef {{ function_name: string, url: string, line: number, column: number }} Frame + * @typedef {Frame & { self: number, inclusive: number }} Hotspot + */ diff --git a/benchmarking/benchmarks/compiler/index.js b/benchmarking/benchmarks/compiler/index.js new file mode 100644 index 0000000000..6db5ca139d --- /dev/null +++ b/benchmarking/benchmarks/compiler/index.js @@ -0,0 +1,3 @@ +import { parser_benchmarks } from './parser.bench.js'; + +export const compiler_benchmarks = parser_benchmarks; diff --git a/benchmarking/benchmarks/compiler/parser.bench.js b/benchmarking/benchmarks/compiler/parser.bench.js new file mode 100644 index 0000000000..3ff0fefbe7 --- /dev/null +++ b/benchmarking/benchmarks/compiler/parser.bench.js @@ -0,0 +1,58 @@ +import { parse } from '../../../packages/svelte/src/compiler/phases/1-parse/index.js'; +import { fastest_test } from '../../utils.js'; + +const elements_source = Array.from( + { length: 1000 }, + (_, i) => + `

Item ${i} has a reasonably long entity-free text value

` +).join('\n'); + +const script_style_source = ` + +`; + +const typescript_source = `${Array.from( + { length: 1000 }, + (_, i) => `
Item ${i}
` +).join('\n')} +{value as number} +${' \n'.repeat(1000)}`; + +/** + * @param {string} label + * @param {string} source + * @param {number} iterations + */ +function create_parser_benchmark(label, source, iterations) { + return { + label, + fn: async () => { + for (let i = 0; i < iterations; i++) { + parse(source); + } + + return await fastest_test(10, () => { + for (let i = 0; i < iterations; i++) { + parse(source); + } + }); + } + }; +} + +export const parser_benchmarks = [ + create_parser_benchmark('parser_elements', elements_source, 5), + create_parser_benchmark('parser_script_style', script_style_source, 50), + create_parser_benchmark('parser_typescript', typescript_source, 25) +]; diff --git a/benchmarking/benchmarks/reactivity/index.js b/benchmarking/benchmarks/reactivity/index.js index 3fe9639376..d971b25e9b 100644 --- a/benchmarking/benchmarks/reactivity/index.js +++ b/benchmarking/benchmarks/reactivity/index.js @@ -13,7 +13,7 @@ import { sbench_create_4to1, sbench_create_signals } from './sbench.js'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { create_test } from './util.js'; // This benchmark has been adapted from the js-reactivity-benchmark (https://github.com/milomg/js-reactivity-benchmark) @@ -39,7 +39,8 @@ for (const file of fs.readdirSync(`${dirname}/tests`)) { const name = file.replace('.bench.js', ''); - const module = await import(`${dirname}/tests/${file}`); + const module_url = pathToFileURL(path.join(dirname, 'tests', file)); + const module = await import(module_url.href); const { owned, unowned } = create_test(name, module.default); reactivity_benchmarks.push(owned, unowned); diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_broad_block.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_broad_block.bench.js new file mode 100644 index 0000000000..e8451ec3fa --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_broad_block.bench.js @@ -0,0 +1,47 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; +import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js'; + +// Like `kairo_broad`, but each derived is also read by a block effect, as +// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better. +export default () => { + let head = $.state(0); + let last = head; + let counter = 0; + + const destroy = $.effect_root(() => { + for (let i = 0; i < 50; i++) { + let current = $.derived(() => { + return $.get(head) + i; + }); + let current2 = $.derived(() => { + return $.get(current) + 1; + }); + block(() => { + $.get(current2); + }); + $.render_effect(() => { + $.get(current2); + counter++; + }); + last = current2; + } + }); + + return { + destroy, + run() { + $.flush(() => { + $.set(head, 1); + }); + counter = 0; + for (let i = 0; i < 50; i++) { + $.flush(() => { + $.set(head, i); + }); + assert.equal($.get(last), i + 50); + } + assert.equal(counter, 50 * 50); + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_deep_block.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_deep_block.bench.js new file mode 100644 index 0000000000..a8d9fa8c35 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_deep_block.bench.js @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; +import { block } from '../../../../packages/svelte/src/internal/client/reactivity/effects.js'; + +let len = 50; +const iter = 50; + +// Like `kairo_deep`, but the derived chain is also read by a block effect, as +// happens with e.g. `{#if derived}` in a component. Measures our #traverse perf better. +export default () => { + let head = $.state(0); + let current = head; + for (let i = 0; i < len; i++) { + let c = current; + current = $.derived(() => { + return $.get(c) + 1; + }); + } + let counter = 0; + + const destroy = $.effect_root(() => { + block(() => { + $.get(current); + }); + + $.render_effect(() => { + $.get(current); + counter++; + }); + }); + + return { + destroy, + run() { + $.flush(() => { + $.set(head, 1); + }); + counter = 0; + for (let i = 0; i < iter; i++) { + $.flush(() => { + $.set(head, i); + }); + assert.equal($.get(current), len + i); + } + assert.equal(counter, iter); + } + }; +}; diff --git a/benchmarking/compare/generate-report.js b/benchmarking/compare/generate-report.js index a61f58909b..70f7b30fbb 100644 --- a/benchmarking/compare/generate-report.js +++ b/benchmarking/compare/generate-report.js @@ -2,15 +2,27 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -export function generate_report(outdir) { +const REPORT_DATA_PLACEHOLDER = '%%REPORT_DATA%%'; +const report_template = fs.readFileSync( + new URL('./results.template.html', import.meta.url), + 'utf-8' +); + +if (!report_template.includes(REPORT_DATA_PLACEHOLDER)) { + throw new Error(`Missing ${REPORT_DATA_PLACEHOLDER} in results.template.html`); +} + +export function generate_report(outdir, branches) { const result_files = fs .readdirSync(outdir) - .filter((file) => file.endsWith('.json')) + .filter((file) => file.endsWith('.json') && (!branches || branches.includes(file.slice(0, -5)))) .sort((a, b) => a.localeCompare(b)); - const branches = result_files.map((file) => file.slice(0, -5)); + // always do this so that ordering lines up (branches argument might be passed in a different order than the result files are sorted + branches = result_files.map((file) => file.slice(0, -5)); + const results = result_files.map((file) => - JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8')) + JSON.parse(fs.readFileSync(path.join(outdir, file), 'utf-8')) ); if (results.length === 0) { @@ -33,11 +45,38 @@ export function generate_report(outdir) { write(''); - for (let i = 0; i < results[0].length; i += 1) { - write(`${results[0][i].benchmark}`); + // match results by benchmark name — branches may have different benchmark + // lists (e.g. a benchmark that only exists on one of the branches), so + // pairing by array index would misattribute results + const by_name = results.map((result) => new Map(result.map((r) => [r.benchmark, r]))); + + /** @type {string[]} */ + const names = []; + + for (const result of results) { + for (const { benchmark } of result) { + if (!names.includes(benchmark)) { + names.push(benchmark); + } + } + } + + for (const name of names) { + const entries = by_name.map((map) => map.get(name)); + const missing = entries + .map((entry, b) => (entry === undefined ? branches[b] : null)) + .filter((branch) => branch !== null); + + write(`${name}`); + + if (missing.length > 0) { + write(` skipped (missing on ${missing.join(', ')})`); + write(''); + continue; + } for (const metric of ['time', 'gc_time']) { - const times = results.map((result) => +result[i][metric]); + const times = entries.map((entry) => +entry[metric]); let min = Infinity; let max = -Infinity; let min_index = -1; @@ -68,6 +107,32 @@ export function generate_report(outdir) { write(''); } + + const benchmarks = names.map((name) => ({ + name, + values: by_name.map((map) => { + const entry = map.get(name); + + if (entry === undefined) return null; + + return { + time: Number(entry.time), + gc_time: Number(entry.gc_time) + }; + }) + })); + const data = JSON.stringify({ + generated_at: new Date().toISOString(), + branches, + benchmarks + }) + .replaceAll('<', '\\u003c') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029'); + const html_file = path.resolve(outdir, '../results.html'); + + fs.writeFileSync(html_file, report_template.replace(REPORT_DATA_PLACEHOLDER, data)); + console.log(`\nHTML report written to ${html_file}`); } function char(i) { diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js index 9064ee7da9..2e76f46e1b 100644 --- a/benchmarking/compare/index.js +++ b/benchmarking/compare/index.js @@ -85,4 +85,4 @@ if (PROFILE_DIR !== null) { console.log(`\nCPU profiles written to ${PROFILE_DIR}`); } -generate_report(outdir); +generate_report(outdir, requested_branches); diff --git a/benchmarking/compare/results.template.html b/benchmarking/compare/results.template.html new file mode 100644 index 0000000000..02f208ac4f --- /dev/null +++ b/benchmarking/compare/results.template.html @@ -0,0 +1,741 @@ + + + + + + Benchmark comparison + + + +
+

Benchmark comparison

+

+ Runtime results across branches. Green cells are fastest for an entry and red cells expose + the largest regressions. Overall runtime normalizes every benchmark to its fastest result + before averaging, so long-running entries do not outweigh short ones. +

+ +
+ +
+
+
+

Branch standings

+

+ Wins count the fastest branch for each comparable entry. Normalized runtime is the + average slowdown against each entry's fastest result; lower is better. Click a heading + to sort. +

+
+
+
+ + + + + + + + + +
+ +
+
+
+ +
+
+
+

Results by benchmark

+

+ Each cell shows runtime, difference from the winner, and GC time. Missing entries are + excluded from both standings. +

+
+
+
+ + +
+
+
+
+
+ +
+
+ + + + + diff --git a/benchmarking/compare/runner.js b/benchmarking/compare/runner.js index 31a8e6b44b..fa746f9869 100644 --- a/benchmarking/compare/runner.js +++ b/benchmarking/compare/runner.js @@ -1,18 +1,67 @@ +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js'; import { with_cpu_profile } from '../utils.js'; -const results = []; -const PROFILE_DIR = process.env.BENCH_PROFILE_DIR; +const PROFILE_DIR = process.env.BENCH_PROFILE_DIR ?? null; +const single = process.env.BENCH_SINGLE; -for (let i = 0; i < reactivity_benchmarks.length; i += 1) { - const benchmark = reactivity_benchmarks[i]; +if (single) { + // child mode — run a single benchmark and report the result to the parent + const benchmark = reactivity_benchmarks.find((b) => b.label === single); - process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `); - results.push({ - benchmark: benchmark.label, - ...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn())) - }); - process.stderr.write('\x1b[2K\r'); -} + if (!benchmark) { + throw new Error(`Unknown benchmark ${single}`); + } + + const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); + + // exit via the callback so the message is guaranteed to be delivered + /** @type {NodeJS.Process} */ (process).send(result, () => process.exit(0)); +} else { + // parent mode — run every benchmark in its own child process, so that + // heap/GC/JIT state from one benchmark cannot contaminate the others + const filename = fileURLToPath(import.meta.url); + const results = []; + + for (let i = 0; i < reactivity_benchmarks.length; i += 1) { + const benchmark = reactivity_benchmarks[i]; + + process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `); + + const result = await new Promise((fulfil, reject) => { + const child = fork(filename, [], { + env: { + ...process.env, + BENCH_SINGLE: benchmark.label + } + }); + + /** @type {object | null} */ + let message_received = null; -process.send(results); + child.on('message', (message) => { + message_received = /** @type {object} */ (message); + }); + + child.on('error', reject); + + child.on('exit', (code) => { + if (message_received === null) { + reject(new Error(`benchmark ${benchmark.label} exited with code ${code}`)); + } else { + fulfil(message_received); + } + }); + }); + + results.push({ + benchmark: benchmark.label, + .../** @type {object} */ (result) + }); + + process.stderr.write('\x1b[2K\r'); + } + + /** @type {NodeJS.Process} */ (process).send(results); +} diff --git a/benchmarking/compiler-profiling.md b/benchmarking/compiler-profiling.md new file mode 100644 index 0000000000..cc2196958e --- /dev/null +++ b/benchmarking/compiler-profiling.md @@ -0,0 +1,43 @@ +# Compiler CPU profiling + +Run the full test suite with V8 CPU profiling enabled in every Vitest worker: + +```sh +pnpm profile:compiler +``` + +Arguments are passed to `pnpm test`, so a smaller smoke run can target a suite: + +```sh +pnpm profile:compiler snapshot +``` + +Each run is stored under `benchmarking/.profiles/compiler-tests/`. Set +`SVELTE_PROFILE_NAME` to give a run a stable name; existing runs are never overwritten. +`benchmarking/.profiles/compiler-tests/latest.txt` contains the latest successful run name. + +Each run contains: + +- `raw/*.cpuprofile`: the original V8 profile from every Vitest worker +- `flamegraph.speedscope.json`: compiler-active stacks merged for use in + [Speedscope](https://www.speedscope.app/) +- `summary.md`: ranked self/inclusive hotspots and files +- `summary.json`: the same measurements for further analysis +- `manifest.json`: the command, revision, timestamps, and test result + +The merged profile includes downstream callees while a compiler source frame is on the stack. +This captures parser, walker, and printer dependencies without including unrelated test runtime. +V8 reports garbage collection outside the originating stack, so the summary reports process-wide +GC separately rather than attributing it to the compiler. + +The default sampling interval is 5 ms. It can be changed for shorter, focused runs: + +```sh +SVELTE_CPU_PROF_INTERVAL=1000 pnpm profile:compiler snapshot +``` + +Existing raw profiles can be analyzed again without rerunning tests: + +```sh +node benchmarking/analyze-compiler-profile.js benchmarking/.profiles/compiler-tests/ +``` diff --git a/benchmarking/profile-compiler.js b/benchmarking/profile-compiler.js new file mode 100644 index 0000000000..323c2d27ea --- /dev/null +++ b/benchmarking/profile-compiler.js @@ -0,0 +1,94 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { analyze_compiler_profiles } from './analyze-compiler-profile.js'; + +const root = path.resolve(fileURLToPath(import.meta.url), '../..'); +const profiles_root = path.join(root, 'benchmarking/.profiles/compiler-tests'); +const requested_name = process.env.SVELTE_PROFILE_NAME; +const run_name = safe(requested_name || new Date().toISOString().replaceAll(':', '-')); +const run_dir = path.join(profiles_root, run_name); +const raw_dir = path.join(run_dir, 'raw'); +const test_args = process.argv.slice(2); +const sampling_interval = process.env.SVELTE_CPU_PROF_INTERVAL || '5000'; + +if (fs.existsSync(run_dir)) { + console.error(`Profile run already exists: ${path.relative(root, run_dir)}`); + process.exit(1); +} + +fs.mkdirSync(raw_dir, { recursive: true }); + +const revision = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8' +}); +const started_at = new Date().toISOString(); +const manifest_file = path.join(run_dir, 'manifest.json'); +const command = [ + 'pnpm', + 'test', + '--execArgv=--cpu-prof', + `--execArgv=--cpu-prof-dir=${raw_dir}`, + `--execArgv=--cpu-prof-interval=${sampling_interval}`, + ...test_args +]; + +write_manifest({ + command, + revision: revision.status === 0 ? revision.stdout.trim() : null, + started_at, + status: 'running' +}); + +console.log(`Compiler profiles will be written to ${path.relative(root, run_dir)}`); + +const result = spawnSync(command[0], command.slice(1), { + cwd: root, + stdio: 'inherit' +}); + +let analysis_error = null; + +try { + analyze_compiler_profiles(run_dir); + fs.writeFileSync(path.join(profiles_root, 'latest.txt'), `${run_name}\n`); +} catch (error) { + analysis_error = error instanceof Error ? error.stack || error.message : String(error); + console.error(analysis_error); +} + +write_manifest({ + command, + revision: revision.status === 0 ? revision.stdout.trim() : null, + started_at, + finished_at: new Date().toISOString(), + status: result.status === 0 && analysis_error === null ? 'completed' : 'failed', + test_exit_code: result.status, + test_signal: result.signal, + analysis_error +}); + +if (result.error) { + console.error(result.error); +} + +if (result.status === 0 && analysis_error === null) { + console.log( + `Compiler flame graph: ${path.relative(root, path.join(run_dir, 'flamegraph.speedscope.json'))}` + ); + console.log(`Compiler hotspot summary: ${path.relative(root, path.join(run_dir, 'summary.md'))}`); +} else { + process.exitCode = result.status || 1; +} + +/** @param {string} value */ +function safe(value) { + return value.replace(/[^a-z0-9._-]+/gi, '_'); +} + +/** @param {Record} manifest */ +function write_manifest(manifest) { + fs.writeFileSync(manifest_file, `${JSON.stringify(manifest, null, '\t')}\n`); +} diff --git a/benchmarking/run.js b/benchmarking/run.js index 80e40a5ff1..322960e866 100644 --- a/benchmarking/run.js +++ b/benchmarking/run.js @@ -1,94 +1,159 @@ +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import * as $ from '../packages/svelte/src/internal/client/index.js'; +import { compiler_benchmarks } from './benchmarks/compiler/index.js'; import { reactivity_benchmarks } from './benchmarks/reactivity/index.js'; import { ssr_benchmarks } from './benchmarks/ssr/index.js'; import { with_cpu_profile } from './utils.js'; -// e.g. `pnpm bench kairo` to only run the kairo benchmarks -const filters = process.argv.slice(2); - const PROFILE_DIR = './benchmarking/.profiles'; -const suites = [ - { - benchmarks: reactivity_benchmarks.filter( - (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) - ), - name: 'reactivity benchmarks' - }, - { - benchmarks: ssr_benchmarks.filter( - (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) - ), - name: 'server-side rendering benchmarks' - } -].filter((suite) => suite.benchmarks.length > 0); - -if (suites.length === 0) { - console.log('No benchmarks matched provided filters'); - process.exit(1); -} - -const COLUMN_WIDTHS = [25, 9, 9]; -const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b); - -const pad_right = (str, n) => str + ' '.repeat(n - str.length); -const pad_left = (str, n) => ' '.repeat(n - str.length) + str; +const single = process.env.BENCH_SINGLE; -let total_time = 0; -let total_gc_time = 0; +if (single) { + // child mode — run a single benchmark and report the result to the parent + const benchmark = [...compiler_benchmarks, ...reactivity_benchmarks, ...ssr_benchmarks].find( + (b) => b.label === single + ); -$.push({}, true); + if (!benchmark) { + throw new Error(`Unknown benchmark ${single}`); + } -try { - for (const { benchmarks, name } of suites) { - let suite_time = 0; - let suite_gc_time = 0; + $.push({}, true); + + const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); + + $.pop(); + + // exit via the callback so the message is guaranteed to be delivered + /** @type {NodeJS.Process} */ (process).send(result, () => process.exit(0)); +} else { + // parent mode — run every benchmark in its own child process, so that + // heap/GC/JIT state from one benchmark cannot contaminate the others + + // e.g. `pnpm bench kairo` to only run the kairo benchmarks + const filters = process.argv.slice(2); + + const suites = [ + // Commenting out because we rarely need to run it + // { + // benchmarks: compiler_benchmarks.filter( + // (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) + // ), + // name: 'compiler benchmarks' + // }, + { + benchmarks: reactivity_benchmarks.filter( + (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) + ), + name: 'reactivity benchmarks' + }, + { + benchmarks: ssr_benchmarks.filter( + (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) + ), + name: 'server-side rendering benchmarks' + } + ].filter((suite) => suite.benchmarks.length > 0); - console.log(`\nRunning ${name}...\n`); - console.log( - pad_right('Benchmark', COLUMN_WIDTHS[0]) + - pad_left('Time', COLUMN_WIDTHS[1]) + - pad_left('GC time', COLUMN_WIDTHS[2]) - ); - console.log('='.repeat(TOTAL_WIDTH)); + if (suites.length === 0) { + console.log('No benchmarks matched provided filters'); + process.exit(1); + } - for (const benchmark of benchmarks) { - const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); + const filename = fileURLToPath(import.meta.url); + + /** + * @param {string} label + * @returns {Promise<{ time: number, gc_time: number }>} + */ + const run_benchmark = (label) => { + return new Promise((fulfil, reject) => { + const child = fork(filename, [], { + env: { + ...process.env, + BENCH_SINGLE: label + } + }); + + /** @type {{ time: number, gc_time: number } | null} */ + let result = null; + + child.on('message', (message) => { + result = /** @type {{ time: number, gc_time: number }} */ (message); + }); + + child.on('error', reject); + + child.on('exit', (code) => { + if (result === null) { + reject(new Error(`benchmark ${label} exited with code ${code}`)); + } else { + fulfil(result); + } + }); + }); + }; + + const COLUMN_WIDTHS = [25, 9, 9]; + const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b); + + /** @type {(str: string, n: number) => string} */ + const pad_right = (str, n) => str + ' '.repeat(n - str.length); + /** @type {(str: string, n: number) => string} */ + const pad_left = (str, n) => ' '.repeat(n - str.length) + str; + + let total_time = 0; + let total_gc_time = 0; + + try { + for (const { benchmarks, name } of suites) { + let suite_time = 0; + let suite_gc_time = 0; + + console.log(`\nRunning ${name}...\n`); + console.log( + pad_right('Benchmark', COLUMN_WIDTHS[0]) + + pad_left('Time', COLUMN_WIDTHS[1]) + + pad_left('GC time', COLUMN_WIDTHS[2]) + ); + console.log('='.repeat(TOTAL_WIDTH)); + + for (const benchmark of benchmarks) { + const results = await run_benchmark(benchmark.label); + console.log( + pad_right(benchmark.label, COLUMN_WIDTHS[0]) + + pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) + + pad_left(results.gc_time.toFixed(2), COLUMN_WIDTHS[2]) + ); + total_time += results.time; + total_gc_time += results.gc_time; + suite_time += results.time; + suite_gc_time += results.gc_time; + } + + console.log('='.repeat(TOTAL_WIDTH)); console.log( - pad_right(benchmark.label, COLUMN_WIDTHS[0]) + - pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) + - pad_left(results.gc_time.toFixed(2), COLUMN_WIDTHS[2]) + pad_right('suite', COLUMN_WIDTHS[0]) + + pad_left(suite_time.toFixed(2), COLUMN_WIDTHS[1]) + + pad_left(suite_gc_time.toFixed(2), COLUMN_WIDTHS[2]) ); - total_time += results.time; - total_gc_time += results.gc_time; - suite_time += results.time; - suite_gc_time += results.gc_time; + console.log('='.repeat(TOTAL_WIDTH)); } - console.log('='.repeat(TOTAL_WIDTH)); - console.log( - pad_right('suite', COLUMN_WIDTHS[0]) + - pad_left(suite_time.toFixed(2), COLUMN_WIDTHS[1]) + - pad_left(suite_gc_time.toFixed(2), COLUMN_WIDTHS[2]) - ); - console.log('='.repeat(TOTAL_WIDTH)); - } - - if (PROFILE_DIR !== null) { console.log(`\nCPU profiles written to ${PROFILE_DIR}`); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + process.exit(1); } -} catch (e) { - // eslint-disable-next-line no-console - console.error(e); - process.exit(1); -} -$.pop(); + console.log(''); -console.log(''); - -console.log( - pad_right('total', COLUMN_WIDTHS[0]) + - pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) + - pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2]) -); + console.log( + pad_right('total', COLUMN_WIDTHS[0]) + + pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) + + pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2]) + ); +} diff --git a/documentation/docs/02-runes/04-$effect.md b/documentation/docs/02-runes/04-$effect.md index a13fc7bc46..de18add08f 100644 --- a/documentation/docs/02-runes/04-$effect.md +++ b/documentation/docs/02-runes/04-$effect.md @@ -205,6 +205,8 @@ In rare cases, you may need to run code _before_ the DOM updates. For this we ca ``` +`$effect.pre` runs before DOM updates that are scheduled after it, not before every DOM mutation in the flush - DOM of parent components may already be updated. When using [await expressions](await-expressions), block updates like `{#if ...}` and `{#each ...}` in the same component also run before `$effect.pre`. + Apart from the timing, `$effect.pre` works exactly like `$effect`. ## `$effect.tracking` diff --git a/documentation/docs/02-runes/06-$bindable.md b/documentation/docs/02-runes/06-$bindable.md index 3675a56b16..a3be662785 100644 --- a/documentation/docs/02-runes/06-$bindable.md +++ b/documentation/docs/02-runes/06-$bindable.md @@ -52,3 +52,5 @@ In this case, you can specify a fallback value for when no prop is passed at all /// file: FancyInput.svelte let { value = $bindable('fallback'), ...props } = $props(); ``` + +When a bindable prop has a fallback value, the parent must pass a value other than `undefined` if it uses `bind:`. This avoids ambiguity about which value should apply, since the parent and child should share the same value for a binding. diff --git a/documentation/docs/03-template-syntax/01-basic-markup.md b/documentation/docs/03-template-syntax/01-basic-markup.md index feecfe033e..89e01cf1ed 100644 --- a/documentation/docs/03-template-syntax/01-basic-markup.md +++ b/documentation/docs/03-template-syntax/01-basic-markup.md @@ -202,7 +202,7 @@ You can add a special comment starting with `@component` that will show up when - You can also use code blocks here. - Usage: ```html -
+
``` --> '); }, Fragment(node, context) { - /** @type {AST.SvelteNode[][]} */ + if (state.preserve_whitespace > 0) { + for (const child of node.nodes) { + context.visit(child); + context.multiline ||= child.type === 'Text' && /[\r\n]/.test(child.data); + } + return; + } + + const first = node.nodes[0]; + const last = node.nodes.at(-1); + const has_surrounding_whitespace = + first?.type === 'Text' && + /^\s/.test(first.data) && + last?.type === 'Text' && + /\s$/.test(last.data); + + /** @type {{ nodes: AST.SvelteNode[]; leading_whitespace: boolean }[]} */ const items = []; /** @type {AST.SvelteNode[]} */ let sequence = []; + let leading_whitespace = false; const flush = () => { - items.push(sequence); - sequence = []; + if (sequence.length > 0) { + items.push({ nodes: sequence, leading_whitespace }); + sequence = []; + leading_whitespace = false; + } }; for (let i = 0; i < node.nodes.length; i += 1) { @@ -402,6 +628,7 @@ const svelte_visitors = (comments) => ({ if (child_node.data.startsWith(' ') && prev && prev.type !== 'ExpressionTag') { flush(); + leading_whitespace = true; child_node.data = child_node.data.trimStart(); } @@ -440,35 +667,39 @@ const svelte_visitors = (comments) => ({ let multiline = false; let width = 0; - const child_contexts = items - .filter((x) => x.length > 0) - .map((sequence) => { - const child_context = context.new(); + const child_contexts = items.map(({ nodes, leading_whitespace }) => { + const child_context = context.new(); - for (const node of sequence) { - child_context.visit(node); - multiline ||= child_context.multiline; - } + for (const node of nodes) { + child_context.visit(node); + multiline ||= child_context.multiline; + } - width += child_context.measure(); + width += child_context.measure() + (leading_whitespace ? 1 : 0); - return child_context; - }); + return { context: child_context, leading_whitespace }; + }); multiline ||= width > LINE_BREAK_THRESHOLD; + // Normally context.newline() also makes context.multiline true, but the below loop only + // does that if we have more than one child context. If there's one long text block inside + // with whitespace at the edges we wanna split that up, too. + context.multiline ||= has_surrounding_whitespace && width > LINE_BREAK_THRESHOLD * 2; for (let i = 0; i < child_contexts.length; i += 1) { const prev = child_contexts[i]; const next = child_contexts[i + 1]; - context.append(prev); + context.append(prev.context); if (next) { - if (prev.multiline || next.multiline) { + if (prev.context.multiline || next.context.multiline) { context.margin(); context.newline(); } else if (multiline) { context.newline(); + } else if (next.leading_whitespace) { + context.write(' '); } } } @@ -522,7 +753,7 @@ const svelte_visitors = (comments) => ({ if (node.pending) { context.write('}'); - block(context, node.pending); + block(context, node.pending, state.preserve_whitespace > 0); context.write('{:'); } else { context.write(' '); @@ -533,7 +764,7 @@ const svelte_visitors = (comments) => ({ if (node.value) context.visit(node.value); context.write('}'); - block(context, node.then); + block(context, node.then, state.preserve_whitespace > 0); if (node.catch) { context.write('{:'); @@ -541,11 +772,11 @@ const svelte_visitors = (comments) => ({ } if (node.catch) { - context.write(node.value ? 'catch ' : 'catch'); + context.write(node.error ? 'catch ' : 'catch'); if (node.error) context.visit(node.error); context.write('}'); - block(context, node.catch); + block(context, node.catch, state.preserve_whitespace > 0); } context.write('{/await}'); @@ -589,7 +820,7 @@ const svelte_visitors = (comments) => ({ }, Component(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, ConstTag(node, context) { @@ -679,11 +910,11 @@ const svelte_visitors = (comments) => ({ context.write('}'); - block(context, node.body); + block(context, node.body, state.preserve_whitespace > 0); if (node.fallback) { context.write('{:else}'); - block(context, node.fallback); + block(context, node.fallback, state.preserve_whitespace > 0); } context.write('{/each}'); @@ -707,13 +938,13 @@ const svelte_visitors = (comments) => ({ context.visit(node.test); context.write('}'); - block(context, node.consequent); + block(context, node.consequent, state.preserve_whitespace > 0); } else { context.write('{#if '); context.visit(node.test); context.write('}'); - block(context, node.consequent); + block(context, node.consequent, state.preserve_whitespace > 0); } if (node.alternate !== null) { @@ -725,7 +956,7 @@ const svelte_visitors = (comments) => ({ ) ) { context.write('{:else}'); - block(context, node.alternate); + block(context, node.alternate, state.preserve_whitespace > 0); } else { context.visit(node.alternate); } @@ -740,7 +971,7 @@ const svelte_visitors = (comments) => ({ context.write('{#key '); context.visit(node.expression); context.write('}'); - block(context, node.fragment); + block(context, node.fragment, state.preserve_whitespace > 0); context.write('{/key}'); }, @@ -772,7 +1003,7 @@ const svelte_visitors = (comments) => ({ }, RegularElement(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, RenderTag(node, context) { @@ -782,7 +1013,7 @@ const svelte_visitors = (comments) => ({ }, SlotElement(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SnippetBlock(node, context) { @@ -801,7 +1032,7 @@ const svelte_visitors = (comments) => ({ } context.write(')}'); - block(context, node.body); + block(context, node.body, state.preserve_whitespace > 0); context.write('{/snippet}'); }, @@ -836,40 +1067,12 @@ const svelte_visitors = (comments) => ({ } }, - StyleSheet(node, context) { - context.write(''); - - if (node.children.length > 0) { - context.indent(); - context.newline(); - - let started = false; - - for (const child of node.children) { - if (started) { - context.margin(); - context.newline(); - } - - context.visit(child); - started = true; - } - - context.dedent(); - context.newline(); - } - - context.write(''); - }, - SvelteBody(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteBoundary(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteComponent(node, context) { @@ -881,7 +1084,7 @@ const svelte_visitors = (comments) => ({ attributes(node, node.attributes, context, comments); if (node.fragment && node.fragment.nodes.length > 0) { context.write('>'); - block(context, node.fragment, true); + block(context, node.fragment, state.preserve_whitespace > 0, true); context.write(``); } else { context.write(' />'); @@ -889,7 +1092,7 @@ const svelte_visitors = (comments) => ({ }, SvelteDocument(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteElement(node, context) { @@ -902,7 +1105,7 @@ const svelte_visitors = (comments) => ({ if (node.fragment && node.fragment.nodes.length > 0) { context.write('>'); - block(context, node.fragment); + block(context, node.fragment, state.preserve_whitespace > 0); context.write(``); } else { context.write(' />'); @@ -910,19 +1113,19 @@ const svelte_visitors = (comments) => ({ }, SvelteFragment(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteHead(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteSelf(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteWindow(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, Text(node, context) { @@ -930,7 +1133,7 @@ const svelte_visitors = (comments) => ({ }, TitleElement(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, TransitionDirective(node, context) { diff --git a/packages/svelte/src/compiler/print/types.d.ts b/packages/svelte/src/compiler/print/types.d.ts index d0ff909525..9eccebb6f3 100644 --- a/packages/svelte/src/compiler/print/types.d.ts +++ b/packages/svelte/src/compiler/print/types.d.ts @@ -4,4 +4,5 @@ import type ts from 'esrap/languages/ts'; export type Options = { getLeadingComments?: NonNullable[0]>['getLeadingComments'] | undefined; getTrailingComments?: NonNullable[0]>['getTrailingComments'] | undefined; + indent?: string; // default tab }; diff --git a/packages/svelte/src/compiler/state.js b/packages/svelte/src/compiler/state.js index 5ae001ec50..51a02a61e1 100644 --- a/packages/svelte/src/compiler/state.js +++ b/packages/svelte/src/compiler/state.js @@ -33,10 +33,14 @@ export let component_name = ''; export let source; /** - * The source code split into lines (set by `set_source`) - * @type {string[]} + * The source code split into lines, initialized when a diagnostic needs a code frame + * @type {string[] | undefined} */ -export let source_lines = []; +let source_lines; + +export function get_source_lines() { + return (source_lines ??= source.split('\n')); +} /** * True if compiling with `dev: true` @@ -52,7 +56,7 @@ export let locator; /** @param {string} value */ export function set_source(value) { source = value; - source_lines = source.split('\n'); + source_lines = undefined; const l = getLocator(source, { offsetLine: 1 }); @@ -141,7 +145,7 @@ export function reset(state) { runes = false; component_name = UNKNOWN_FILENAME; source = ''; - source_lines = []; + source_lines = undefined; filename = (state.filename ?? UNKNOWN_FILENAME).replace(/\\/g, '/'); warning_filter = state.warning ?? (() => true); warnings = []; diff --git a/packages/svelte/src/compiler/types/css.d.ts b/packages/svelte/src/compiler/types/css.d.ts index 13eb880d83..96b2bc3a7d 100644 --- a/packages/svelte/src/compiler/types/css.d.ts +++ b/packages/svelte/src/compiler/types/css.d.ts @@ -8,6 +8,15 @@ export namespace _CSS { export interface StyleSheetBase extends BaseNode { children: Array; + /** CSS comments in source order */ + comments: CSSComment[]; + } + + export interface CSSComment extends BaseNode { + type: 'CSSComment'; + value: string; + /** Character offset in a containing declaration value or at-rule prelude */ + position?: number; } export interface StyleSheetFile extends StyleSheetBase { @@ -112,6 +121,7 @@ export namespace _CSS { export interface TypeSelector extends BaseNode { type: 'TypeSelector'; name: string; + namespace?: string; } export interface IdSelector extends BaseNode { @@ -135,6 +145,7 @@ export namespace _CSS { export interface PseudoElementSelector extends BaseNode { type: 'PseudoElementSelector'; name: string; + args?: SelectorList; } export interface PseudoClassSelector extends BaseNode { diff --git a/packages/svelte/src/compiler/utils/ast.js b/packages/svelte/src/compiler/utils/ast.js index 75aadd905b..927674bd18 100644 --- a/packages/svelte/src/compiler/utils/ast.js +++ b/packages/svelte/src/compiler/utils/ast.js @@ -286,7 +286,7 @@ function _extract_paths(paths, inserts, param, expression, update_expression, ha const props = []; for (const p of param.properties) { - if (p.type === 'Property' && p.key.type !== 'PrivateIdentifier') { + if (p.type === 'Property') { if (p.key.type === 'Identifier' && !p.computed) { props.push(b.literal(p.key.name)); } else if (p.key.type === 'Literal') { @@ -548,10 +548,7 @@ export function is_expression_async(expression) { if (property.type === 'SpreadElement') { return is_expression_async(property.argument); } else if (property.type === 'Property') { - return ( - (property.key.type !== 'PrivateIdentifier' && is_expression_async(property.key)) || - is_expression_async(property.value) - ); + return is_expression_async(property.key) || is_expression_async(property.value); } }); } diff --git a/packages/svelte/src/compiler/utils/compile_diagnostic.js b/packages/svelte/src/compiler/utils/compile_diagnostic.js index 95d028ee35..ab0732c958 100644 --- a/packages/svelte/src/compiler/utils/compile_diagnostic.js +++ b/packages/svelte/src/compiler/utils/compile_diagnostic.js @@ -15,7 +15,7 @@ function tabs_to_spaces(str) { * @param {number} column */ function get_code_frame(line, column) { - const lines = state.source_lines; + const lines = state.get_source_lines(); const frame_start = Math.max(0, line - 2); const frame_end = Math.min(line + 3, lines.length); const digits = String(frame_end + 1).length; diff --git a/packages/svelte/src/compiler/utils/mapped_code.js b/packages/svelte/src/compiler/utils/mapped_code.js index 7686ba59c6..635743c0c4 100644 --- a/packages/svelte/src/compiler/utils/mapped_code.js +++ b/packages/svelte/src/compiler/utils/mapped_code.js @@ -311,6 +311,13 @@ function apply_preprocessor_sourcemap(filename, svelte_map, preprocessor_map_inp typeof preprocessor_map_input === 'string' ? JSON.parse(preprocessor_map_input) : preprocessor_map_input; + // A preprocessor map with a missing/empty `sources[0]` (e.g. from a MagicString transform + // created without a `source` option) can't be matched against `filename` during combination, + // which silently drops the chain instead of erroring. Normalize it to `filename` first, the + // same way Vite treats an empty `sources[0]` as referring to the file being transformed. + if (preprocessor_map.sources?.length === 1 && !preprocessor_map.sources[0]) { + preprocessor_map.sources = [filename]; + } const result_map = combine_sourcemaps(filename, [svelte_map, preprocessor_map]); // Svelte expects a SourceMap which includes toUrl and toString. Instead of wrapping our output in a class, // we just tack on the extra properties. diff --git a/packages/svelte/src/easing/index.js b/packages/svelte/src/easing/index.js index 08c86933ab..c2f7e63bc0 100644 --- a/packages/svelte/src/easing/index.js +++ b/packages/svelte/src/easing/index.js @@ -4,6 +4,8 @@ Distributed under MIT License https://github.com/mattdesl/eases/blob/master/LICE */ /** + * Returns value as is. + * * @param {number} t * @returns {number} */ @@ -12,6 +14,8 @@ export function linear(t) { } /** + * Rebound effect on start and end of value range. + * * @param {number} t * @returns {number} */ @@ -22,6 +26,8 @@ export function backInOut(t) { } /** + * Rebound effect on start. + * * @param {number} t * @returns {number} */ @@ -31,6 +37,8 @@ export function backIn(t) { } /** + * Rebound effect on end. + * * @param {number} t * @returns {number} */ @@ -40,6 +48,8 @@ export function backOut(t) { } /** + * Bounce effect on end. + * * @param {number} t * @returns {number} */ @@ -61,6 +71,8 @@ export function bounceOut(t) { } /** + * Bounce effect on start and end. + * * @param {number} t * @returns {number} */ @@ -69,6 +81,8 @@ export function bounceInOut(t) { } /** + * Bounce effect on start. + * * @param {number} t * @returns {number} */ @@ -77,6 +91,8 @@ export function bounceIn(t) { } /** + * Circular effect, accelerate on start, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -86,6 +102,8 @@ export function circInOut(t) { } /** + * Circular effect, accelerate on start. + * * @param {number} t * @returns {number} */ @@ -94,6 +112,8 @@ export function circIn(t) { } /** + * Circular effect, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -102,6 +122,8 @@ export function circOut(t) { } /** + * Cubic scaling, accelerate on start, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -110,6 +132,8 @@ export function cubicInOut(t) { } /** + * Cubic scaling, accelerate on start + * * @param {number} t * @returns {number} */ @@ -118,6 +142,8 @@ export function cubicIn(t) { } /** + * Cubic scaling, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -127,6 +153,8 @@ export function cubicOut(t) { } /** + * Elastic effect on start and end. + * * @param {number} t * @returns {number} */ @@ -140,6 +168,8 @@ export function elasticInOut(t) { } /** + * Elastic effect on start. + * * @param {number} t * @returns {number} */ @@ -148,6 +178,8 @@ export function elasticIn(t) { } /** + * Elastic effect on end. + * * @param {number} t * @returns {number} */ @@ -156,6 +188,8 @@ export function elasticOut(t) { } /** + * Exponential effect on start and end. + * * @param {number} t * @returns {number} */ @@ -168,6 +202,8 @@ export function expoInOut(t) { } /** + * Exponential effect on start. + * * @param {number} t * @returns {number} */ @@ -176,6 +212,8 @@ export function expoIn(t) { } /** + * Exponential effect on end. + * * @param {number} t * @returns {number} */ @@ -184,6 +222,8 @@ export function expoOut(t) { } /** + * Quadratic scaling, accelerate on start, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -195,6 +235,8 @@ export function quadInOut(t) { } /** + * Quadratic scaling, accelerate on start. + * * @param {number} t * @returns {number} */ @@ -203,6 +245,8 @@ export function quadIn(t) { } /** + * Quadratic scaling, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -211,6 +255,8 @@ export function quadOut(t) { } /** + * Quartic scaling, accelerate on start, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -219,6 +265,8 @@ export function quartInOut(t) { } /** + * Quartic scaling, accelerate on start. + * * @param {number} t * @returns {number} */ @@ -227,6 +275,8 @@ export function quartIn(t) { } /** + * Quartic scaling, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -235,6 +285,8 @@ export function quartOut(t) { } /** + * Quintic scaling, accelerate on start, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -244,6 +296,8 @@ export function quintInOut(t) { } /** + * Quintic scaling, accelerate on start. + * * @param {number} t * @returns {number} */ @@ -252,6 +306,8 @@ export function quintIn(t) { } /** + * Quintic scaling, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -260,6 +316,8 @@ export function quintOut(t) { } /** + * Sinusoidal effect, accelerate on start, decelerate towards end. + * * @param {number} t * @returns {number} */ @@ -268,6 +326,8 @@ export function sineInOut(t) { } /** + * Sinusoidal effect, accelerate on start. + * * @param {number} t * @returns {number} */ @@ -278,6 +338,8 @@ export function sineIn(t) { } /** + * Sinusoidal effect, decelerate towards end. + * * @param {number} t * @returns {number} */ diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 043b50b4b2..b086bfedff 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -15,6 +15,13 @@ export const BLOCK_EFFECT = 1 << 4; export const BRANCH_EFFECT = 1 << 5; export const ROOT_EFFECT = 1 << 6; export const BOUNDARY_EFFECT = 1 << 7; +/** + * Set on the effect that `pause_effect` was called on, i.e. the root of a paused subtree, + * as opposed to its descendants which are merely `INERT`. This allows `resume_effect` on + * an ancestor to skip subtrees that were paused for their own reasons (such as a block + * whose condition is still false) rather than resurrecting them + */ +export const PAUSED = 1 << 8; /** * Indicates that a reaction is connected to an effect root — either it is an effect, * or it is a derived that is depended on by at least one effect. If a derived has @@ -44,15 +51,6 @@ export const EFFECT_PRESERVED = 1 << 19; export const USER_EFFECT = 1 << 20; export const EFFECT_OFFSCREEN = 1 << 25; -// Flags exclusive to deriveds -/** - * Tells that we marked this derived and its reactions as visited during the "mark as (maybe) dirty"-phase. - * Will be lifted during execution of the derived and during checking its dirty state (both are necessary - * because a derived might be checked but not executed). This is a pure performance optimization flag and - * should not be used for any other purpose! - */ -export const WAS_MARKED = 1 << 16; - // Flags used for async export const REACTION_IS_UPDATING = 1 << 21; export const ASYNC = 1 << 22; @@ -60,6 +58,8 @@ export const ASYNC = 1 << 22; export const ERROR_VALUE = 1 << 23; export const STATE_SYMBOL = Symbol('$state'); +/** Marks component export objects, so that `proxy(...)` leaves them untouched */ +export const COMPONENT_SYMBOL = Symbol('component'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); export const PROXY_PATH_SYMBOL = Symbol('proxy path'); diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 0baef5c63e..c3bb660e8c 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -5,7 +5,9 @@ import { active_effect, active_reaction } from './runtime.js'; import { create_user_effect } from './reactivity/effects.js'; import { async_mode_flag, legacy_mode_flag } from '../flags/index.js'; import { FILENAME } from '../../constants.js'; -import { BRANCH_EFFECT } from './constants.js'; +import { BRANCH_EFFECT, COMPONENT_SYMBOL } from './constants.js'; +import { define_property } from '../shared/utils.js'; +import { create_context, get_or_init_context_map } from '../shared/context.js'; /** @type {ComponentContext | null} */ export let component_context = null; @@ -70,31 +72,25 @@ export function set_dev_current_component_function(fn) { } /** - * Returns a `[get, set]` pair of functions for working with context in a type-safe way. + * Returns a `[get, set, has]` triplet of functions for working with context in a type-safe way. * - * `get` will throw an error if no parent component called `set`. + * `get` will throw an error if `set` has not yet been called in the current component or any of + * its ancestors. * * @template T - * @returns {[() => T, (context: T) => T]} + * @returns {[() => T, (context: T) => T, () => boolean]} * @since 5.40.0 */ export function createContext() { - const key = {}; - - return [ - () => { - if (!hasContext(key)) { - e.missing_context(); - } - - return getContext(key); - }, - (context) => setContext(key, context) - ]; + return /** @type {[() => T, (context: T) => T, () => boolean]} */ ( + create_context(getContext, setContext, hasContext) + ); } /** - * Retrieves the context that belongs to the closest parent component with the specified `key`. + * Retrieves the context set with the specified `key` in the current component or any of its + * ancestors. If multiple components set the same key, the value from the closest one is returned. + * A `setContext` call in the current component is only visible to `getContext` calls that run after it. * Must be called during component initialisation. * * [`createContext`](https://svelte.dev/docs/svelte/svelte#createContext) is a type-safe alternative. @@ -104,15 +100,15 @@ export function createContext() { * @returns {T} */ export function getContext(key) { - const context_map = get_or_init_context_map('getContext'); + const context_map = get_or_init_context_map(component_context, 'getContext'); const result = /** @type {T} */ (context_map.get(key)); return result; } /** * Associates an arbitrary `context` object with the current component and the specified `key` - * and returns that object. The context is then available to children of the component - * (including slotted content) with `getContext`. + * and returns that object. The context is then available to the component itself and all of its + * descendants (including slotted content) with `getContext`. * * Like lifecycle functions, this must be called during component initialisation. * @@ -124,7 +120,7 @@ export function getContext(key) { * @returns {T} */ export function setContext(key, context) { - const context_map = get_or_init_context_map('setContext'); + const context_map = get_or_init_context_map(component_context, 'setContext'); if (async_mode_flag) { var flags = /** @type {Effect} */ (active_effect).f; @@ -144,27 +140,27 @@ export function setContext(key, context) { } /** - * Checks whether a given `key` has been set in the context of a parent component. - * Must be called during component initialisation. + * Checks whether a given `key` has been set in the context of the current component or any of + * its ancestors. Must be called during component initialisation. * * @param {any} key * @returns {boolean} */ export function hasContext(key) { - const context_map = get_or_init_context_map('hasContext'); + const context_map = get_or_init_context_map(component_context, 'hasContext'); return context_map.has(key); } /** - * Retrieves the whole context map that belongs to the closest parent component. - * Must be called during component initialisation. Useful, for example, if you - * programmatically create a component and want to pass the existing context to it. + * Retrieves the whole context map that belongs to the current component, including entries + * inherited from its ancestors. Must be called during component initialisation. Useful, for + * example, if you programmatically create a component and want to pass the existing context to it. * * @template {Map} [T=Map] * @returns {T} */ export function getAllContexts() { - const context_map = get_or_init_context_map('getAllContexts'); + const context_map = get_or_init_context_map(component_context, 'getAllContexts'); return /** @type {T} */ (context_map); } @@ -222,38 +218,19 @@ export function pop(component) { dev_current_component_function = component_context?.function ?? null; } - return component ?? /** @type {T} */ ({}); -} - -/** @returns {boolean} */ -export function is_runes() { - return !legacy_mode_flag || (component_context !== null && component_context.l === null); + return mark_as_component(component); } /** - * @param {string} name - * @returns {Map} + * Add a symbol to the object (or create one if undefined) to mark it as a component so it isn't proxified. + * @param {any} component */ -function get_or_init_context_map(name) { - if (component_context === null) { - e.lifecycle_outside_component(name); - } - - return (component_context.c ??= new Map(get_parent_context(component_context) || undefined)); +export function mark_as_component(component = {}) { + define_property(component, COMPONENT_SYMBOL, { value: true }); + return component; } -/** - * @param {ComponentContext} component_context - * @returns {Map | null} - */ -function get_parent_context(component_context) { - let parent = component_context.p; - while (parent !== null) { - const context_map = parent.c; - if (context_map !== null) { - return context_map; - } - parent = parent.p; - } - return null; +/** @returns {boolean} */ +export function is_runes() { + return !legacy_mode_flag || (component_context !== null && component_context.l === null); } diff --git a/packages/svelte/src/internal/client/dev/debug.js b/packages/svelte/src/internal/client/dev/debug.js index 83cc510ae2..2513d15c97 100644 --- a/packages/svelte/src/internal/client/dev/debug.js +++ b/packages/svelte/src/internal/client/dev/debug.js @@ -15,7 +15,6 @@ import { MAYBE_DIRTY, RENDER_EFFECT, ROOT_EFFECT, - WAS_MARKED, MANAGED_EFFECT } from '#client/constants'; import { snapshot } from '../../shared/clone.js'; @@ -204,7 +203,6 @@ export function log_reactions(signal) { if ((flags & DIRTY) !== 0) names.push('DIRTY'); if ((flags & MAYBE_DIRTY) !== 0) names.push('MAYBE_DIRTY'); if ((flags & CONNECTED) !== 0) names.push('CONNECTED'); - if ((flags & WAS_MARKED) !== 0) names.push('WAS_MARKED'); if ((flags & INERT) !== 0) names.push('INERT'); if ((flags & DESTROYED) !== 0) names.push('DESTROYED'); diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index 73dba95f9b..0e988fe66d 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -57,10 +57,21 @@ export function hmr(fn) { if (ran) set_should_intro(true); }); - // Forward the nodes from the inner effect to the outer active effect which would - // get them if the HMR wrapper wasn't there. Do this inside the block not outside - // so that HMR updates to the component will also update the nodes on the active effect. - /** @type {Effect} */ (active_effect).nodes = effect.nodes; + // Forward the start/end DOM nodes from the inner effect to the outer active effect + // which would get them if the HMR wrapper wasn't there. Do this inside the block not + // outside so that HMR updates to the component will also update the nodes on the + // active effect. We copy only start/end, not the full nodes object, so that + // pause_children does not collect transitions from both effects and fire outroend twice. + var inner_nodes = effect.nodes; + if (inner_nodes) { + var ae = /** @type {Effect} */ (active_effect); + if (ae.nodes) { + ae.nodes.start = inner_nodes.start; + ae.nodes.end = inner_nodes.end; + } else { + ae.nodes = { start: inner_nodes.start, end: inner_nodes.end, a: null, t: null }; + } + } }, EFFECT_TRANSPARENT); ran = true; diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 0c0903ff52..fd6bbdf5b4 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,14 +1,8 @@ /** @import { Effect, Source, TemplateNode, } from '#client' */ -import { - BOUNDARY_EFFECT, - DIRTY, - EFFECT_PRESERVED, - EFFECT_TRANSPARENT, - MAYBE_DIRTY -} from '#client/constants'; +import { BOUNDARY_EFFECT, EFFECT_PRESERVED, EFFECT_TRANSPARENT } from '#client/constants'; import { HYDRATION_START_ELSE, HYDRATION_START_FAILED } from '../../../../constants.js'; import { component_context, set_component_context } from '../../context.js'; -import { handle_error, invoke_error_boundary } from '../../error-handling.js'; +import { invoke_error_boundary } from '../../error-handling.js'; import { block, branch, @@ -199,17 +193,68 @@ export class Boundary { */ #hydrate_failed_content(error) { const failed = this.#props.failed; + const { reset, invoke_onerror } = this.#create_reset(error); + + // `onerror` may mutate state, which is disallowed while hydrating + queue_micro_task(invoke_onerror); + if (!failed) return; this.#failed_effect = branch(() => { failed( this.#anchor, () => error, - () => () => {} + () => reset ); }); } + /** + * Creates the `reset` function for a failed boundary, along with a function + * that invokes `onerror` with it (if provided) + * @param {unknown} error + * @returns {{ reset: () => void, invoke_onerror: () => void }} + */ + #create_reset(error) { + var did_reset = false; + var calling_on_error = false; + + const reset = () => { + if (did_reset) { + w.svelte_boundary_reset_noop(); + return; + } + + did_reset = true; + + if (calling_on_error) { + e.svelte_boundary_reset_onerror(); + } + + if (this.#failed_effect !== null) { + pause_effect(this.#failed_effect, () => { + this.#failed_effect = null; + }); + } + + this.#run(() => { + this.#render(); + }); + }; + + const invoke_onerror = () => { + try { + calling_on_error = true; + this.#props.onerror?.(error, reset); + calling_on_error = false; + } catch (err) { + invoke_error_boundary(err, this.#effect && this.#effect.parent); + } + }; + + return { reset, invoke_onerror }; + } + #hydrate_pending_content() { const pending = this.#props.pending; if (!pending) return; @@ -220,13 +265,31 @@ export class Boundary { queue_micro_task(() => { var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); var anchor = create_text(); + var handled = false; fragment.append(anchor); this.#main_effect = this.#run(() => { - return branch(() => this.#children(anchor)); + try { + return branch(() => this.#children(anchor)); + } catch (error) { + try { + this.error(error); + handled = true; + } catch (error) { + invoke_error_boundary(error, this.#effect.parent); + } + + return null; + } }); + if (this.#main_effect === null) { + this.#offscreen_fragment = null; + if (handled) this.#resolve(/** @type {Batch} */ (current_batch)); + return; + } + if (this.#pending_count === 0) { this.#anchor.before(fragment); this.#offscreen_fragment = null; @@ -311,9 +374,6 @@ export class Boundary { try { Batch.ensure(); return fn(); - } catch (e) { - handle_error(e); - return null; } finally { set_active_effect(previous_effect); set_active_reaction(previous_reaction); @@ -429,43 +489,13 @@ export class Boundary { set_hydrate_node(skip_nodes()); } - var onerror = this.#props.onerror; let failed = this.#props.failed; - var did_reset = false; - var calling_on_error = false; - - const reset = () => { - if (did_reset) { - w.svelte_boundary_reset_noop(); - return; - } - - did_reset = true; - - if (calling_on_error) { - e.svelte_boundary_reset_onerror(); - } - - if (this.#failed_effect !== null) { - pause_effect(this.#failed_effect, () => { - this.#failed_effect = null; - }); - } - - this.#run(() => { - this.#render(); - }); - }; /** @param {unknown} transformed_error */ const handle_error_result = (transformed_error) => { - try { - calling_on_error = true; - onerror?.(transformed_error, reset); - calling_on_error = false; - } catch (error) { - invoke_error_boundary(error, this.#effect && this.#effect.parent); - } + const { reset, invoke_onerror } = this.#create_reset(transformed_error); + + invoke_onerror(); if (failed) { this.#failed_effect = this.#run(() => { diff --git a/packages/svelte/src/internal/client/dom/blocks/css-props.js b/packages/svelte/src/internal/client/dom/blocks/css-props.js index 084a5561be..e5b5458d49 100644 --- a/packages/svelte/src/internal/client/dom/blocks/css-props.js +++ b/packages/svelte/src/internal/client/dom/blocks/css-props.js @@ -18,10 +18,10 @@ export function css_props(element, get_styles) { for (var key in styles) { var value = styles[key]; - if (value) { - element.style.setProperty(key, value); - } else { + if (value == null || value === '') { element.style.removeProperty(key); + } else { + element.style.setProperty(key, value); } } }); diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 2df1d6ffa1..9a2504f887 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -103,8 +103,12 @@ function pause_effects(state, to_destroy, controlled_anchor) { if (remaining === 0) { // If we're in a controlled each block (i.e. the block is the only child of an // element), and we are removing all items, _and_ there are no out transitions, - // we can use the fast path — emptying the element and replacing the anchor - var fast_path = transitions.length === 0 && controlled_anchor !== null; + // we can use the fast path — emptying the element and replacing the anchor. + // Skip the fast path when another batch is still pending on this each block: + // that batch's keys still reference EachItems in `state.items`, which + // `destroy_effects` needs to preserve offscreen (see #18610). + var fast_path = + transitions.length === 0 && controlled_anchor !== null && state.pending.size === 0; if (fast_path) { var anchor = /** @type {Element} */ (controlled_anchor); diff --git a/packages/svelte/src/internal/client/dom/blocks/slot.js b/packages/svelte/src/internal/client/dom/blocks/slot.js index 10b2959582..491f04a5ea 100644 --- a/packages/svelte/src/internal/client/dom/blocks/slot.js +++ b/packages/svelte/src/internal/client/dom/blocks/slot.js @@ -1,4 +1,6 @@ import { hydrate_next, hydrating } from '../hydration.js'; +import { create_element, create_text } from '../operations.js'; +import { append } from '../template.js'; /** * @param {Comment} anchor @@ -12,6 +14,23 @@ export function slot(anchor, $$props, name, slot_props, fallback_fn) { hydrate_next(); } + // Custom element slots are native DOM slots. + // Use the stored reference because the shadow root may be closed. + if ($$props.$$host?.$$shadowRoot) { + const element = create_element('slot'); + if (name !== 'default') element.name = name; + + append(anchor, element); + + if (fallback_fn !== null) { + const fallback_anchor = create_text(); + element.append(fallback_anchor); + fallback_fn(fallback_anchor); + } + + return; + } + var slot_fn = $$props.$$slots?.[name]; // Interop: Can use snippets to fill slots var is_interop = false; diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index 5721f7b056..d22d74b275 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -52,6 +52,14 @@ export function head(hash, render_fn) { block(() => { var e = branch(() => render_fn(anchor)); e.f |= HEAD_EFFECT; + + if (!hydrating) { + if (e.nodes === null) { + e.nodes = { start: anchor, end: anchor, a: null, t: null }; + } else { + e.nodes.end = anchor; + } + } }); } finally { if (was_hydrating) { diff --git a/packages/svelte/src/internal/client/dom/css.js b/packages/svelte/src/internal/client/dom/css.js index 74bf2d49f9..23f4eff4f7 100644 --- a/packages/svelte/src/internal/client/dom/css.js +++ b/packages/svelte/src/internal/client/dom/css.js @@ -2,14 +2,19 @@ import { DEV } from 'esm-env'; import { register_style } from '../dev/css.js'; import { effect } from '../reactivity/effects.js'; import { create_element } from './operations.js'; +import { active_effect } from '../runtime.js'; /** * @param {Node} anchor * @param {{ hash: string, code: string }} css */ export function append_styles(anchor, css) { - // Use `queue_micro_task` to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results + // Use an effect to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results effect(() => { + // Bit of a hack: branches.js/each.js use offscreen fragments with temporary text nodes that will + // never be connected to the real dom. Therfore walk up to the branch that has created the component + // whose styles we want to append, and check its node instead. It will be connected by the time we get here. + anchor = active_effect?.parent?.nodes?.start ?? anchor; var root = anchor.getRootNode(); var target = /** @type {ShadowRoot} */ (root).host diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index 589685b5f2..0fe1b8b2d3 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -26,7 +26,12 @@ import { set_class } from './class.js'; import { set_style } from './style.js'; import { ATTACHMENT_KEY, NAMESPACE_HTML, UNINITIALIZED } from '../../../../constants.js'; import { branch, destroy_effect, effect, managed } from '../../reactivity/effects.js'; -import { init_select, select_option } from './bindings/select.js'; +import { + init_select, + select_option, + set_default_select_value, + set_selected +} from './bindings/select.js'; import { flatten } from '../../reactivity/async.js'; export const CLASS = Symbol('class'); @@ -122,25 +127,6 @@ export function set_checked(element, checked) { element.checked = checked; } -/** - * Sets the `selected` attribute on an `option` element. - * Not set through the property because that doesn't reflect to the DOM, - * which means it wouldn't be taken into account when a form is reset. - * @param {HTMLOptionElement} element - * @param {boolean} selected - */ -export function set_selected(element, selected) { - if (selected) { - // The selected option could've changed via user selection, and - // setting the value without this check would set it back. - if (!element.hasAttribute('selected')) { - element.setAttribute('selected', ''); - } - } else { - element.removeAttribute('selected'); - } -} - /** * Applies the default checked property without influencing the current checked property. * @param {HTMLInputElement} element @@ -201,7 +187,7 @@ export function set_attribute(element, attribute, value, skip_warning) { if (value == null) { element.removeAttribute(attribute); - } else if (typeof value !== 'string' && get_setters(element).includes(attribute)) { + } else if (typeof value !== 'string' && get_setters(element).has(attribute)) { // @ts-ignore element[attribute] = value; } else { @@ -252,7 +238,7 @@ export function set_custom_element_data(node, prop, value) { // customElements may not be available in browser extension contexts !customElements || customElements.get(node.getAttribute('is') || node.nodeName.toLowerCase()) - ? get_setters(node).includes(prop) + ? get_setters(node).has(prop) : value && typeof value === 'object') ) { // @ts-expect-error @@ -291,11 +277,8 @@ function set_attributes( skip_warning = false ) { if (hydrating && should_remove_defaults && element.nodeName === INPUT_TAG) { - var input = /** @type {HTMLInputElement} */ (element); - var attribute = input.type === 'checkbox' ? 'defaultChecked' : 'defaultValue'; - - if (!(attribute in next)) { - remove_input_defaults(input); + if (!('defaultValue' in next || 'defaultChecked' in next)) { + remove_input_defaults(/** @type {HTMLInputElement} */ (element)); } } @@ -313,9 +296,11 @@ function set_attributes( var current = prev || {}; var is_option_element = element.nodeName === OPTION_TAG; + var is_select_element = element.nodeName === SELECT_TAG; for (var key in prev) { - if (!(key in next)) { + // don't null our internal $$onX listeners + if (!(key in next) && key[0] + key[1] !== '$$') { next[key] = null; } } @@ -448,6 +433,9 @@ function set_attributes( var is_default = name === 'defaultValue' || name === 'defaultChecked'; + // A select's default value is represented by selected options, not a property. + if (is_select_element && name === 'defaultValue') continue; + if (value == null && !is_custom_element && !is_default) { attributes[key] = null; @@ -472,7 +460,7 @@ function set_attributes( } } else if ( is_default || - (setters.includes(name) && (is_custom_element || typeof value !== 'string')) + ((is_custom_element || typeof value !== 'string') && setters.has(name)) ) { // @ts-ignore element[name] = value; @@ -533,8 +521,16 @@ export function attribute_effect( skip_warning ); - if (inited && is_select && 'value' in next) { - select_option(/** @type {HTMLSelectElement} */ (element), next.value); + if (inited && is_select) { + var select = /** @type {HTMLSelectElement} */ (element); + + if ('defaultValue' in next) { + set_default_select_value(select, next.defaultValue); + } + + if ('value' in next) { + select_option(select, next.value); + } } for (let symbol of Object.getOwnPropertySymbols(effects)) { @@ -559,7 +555,13 @@ export function attribute_effect( var select = /** @type {HTMLSelectElement} */ (element); effect(() => { - select_option(select, /** @type {Record} */ (prev).value, true); + var attrs = /** @type {Record} */ (prev); + + if ('defaultValue' in attrs) { + set_default_select_value(select, attrs.defaultValue); + } + + select_option(select, attrs.value, true); init_select(select); }); } @@ -581,7 +583,7 @@ function get_attributes(element) { ); } -/** @type {Map} */ +/** @type {Map>} */ var setters_cache = new Map(); /** @param {Element} element */ @@ -589,7 +591,7 @@ function get_setters(element) { var cache_key = element.getAttribute('is') || element.nodeName; var setters = setters_cache.get(cache_key); if (setters) return setters; - setters_cache.set(cache_key, (setters = [])); + setters_cache.set(cache_key, (setters = new Set())); var descriptors; var proto = element; // In the case of custom elements there might be setters on the instance @@ -608,7 +610,7 @@ function get_setters(element) { key !== 'textContent' && key !== 'innerText' ) { - setters.push(key); + setters.add(key); } } diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js index eb26062653..cac7531ed2 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js @@ -6,6 +6,71 @@ import * as w from '../../../warnings.js'; import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js'; import { async_mode_flag } from '../../../../flags/index.js'; +/** + * Sets the `selected` attribute on an option so form reset can restore it. + * @param {HTMLOptionElement} option + * @param {boolean} selected + */ +export function set_selected(option, selected) { + if (selected) { + if (!option.hasAttribute('selected')) option.setAttribute('selected', ''); + } else { + option.removeAttribute('selected'); + } +} + +/** + * Sets the options a form reset should restore. The first call selects + * them if nothing has set a value, later calls leave the current selection alone. + * @param {HTMLSelectElement} select + * @param {any} value + */ +export function set_default_select_value(select, value) { + var mounting = !('__defaultValue' in select); + // @ts-expect-error + if (!mounting && select.__defaultValue === value) return; + // @ts-expect-error + select.__defaultValue = value; + apply_default_select_value(select, !mounting || '__value' in select); +} + +/** + * Marks the options matching `__defaultValue` as selected. Without `preserve` + * a newly matching option gets selected, as an inserted `