diff --git a/.agents/skills/performance-investigation/SKILL.md b/.agents/skills/performance-investigation/SKILL.md new file mode 100644 index 0000000000..cbc5d81882 --- /dev/null +++ b/.agents/skills/performance-investigation/SKILL.md @@ -0,0 +1,70 @@ +--- +name: performance-investigation +description: Investigate performance regressions and find opportunities for optimization +--- + +## Quick start + +1. Start from a branch you want to measure (for example `foo`). +2. Run: + +```sh +pnpm bench:compare main foo +``` + +If you pass one branch, `bench:compare` automatically compares it to `main`. + +## Where outputs go + +- Summary report: `benchmarking/compare/.results/report.txt` +- Raw benchmark numbers: + - `benchmarking/compare/.results/main.json` + - `benchmarking/compare/.results/.json` +- CPU profiles (per benchmark, per branch): + - `benchmarking/compare/.profiles/main/*.cpuprofile` + - `benchmarking/compare/.profiles/main/*.md` + - `benchmarking/compare/.profiles//*.cpuprofile` + - `benchmarking/compare/.profiles//*.md` + +The `.md` files are generated summaries of the CPU profile and are usually the fastest way to inspect hotspots. + +## Suggested investigation flow + +1. Open `benchmarking/compare/.results/report.txt` and identify largest regressions first. +2. For each high-delta benchmark, compare: + - `benchmarking/compare/.profiles/main/.md` + - `benchmarking/compare/.profiles//.md` +3. Look for changes in self/inclusive hotspot share in runtime internals (`runtime.js`, `reactivity/batch.js`, `reactivity/deriveds.js`, `reactivity/sources.js`). +4. Make one optimization change at a time, then re-run targeted benches before re-running full compare. + +## Fast benchmark loops + +Run only selected reactivity benchmarks by substring: + +```sh +pnpm bench kairo_mux kairo_deep kairo_broad kairo_triangle +pnpm bench repeated_deps sbench_create_signals mol_owned +``` + +## Tests to run after perf changes + +Runtime reactivity regressions are most likely in runes runtime tests: + +```sh +pnpm test runtime-runes +``` + +## Helpful script + +For quick cpuprofile hotspot deltas between two branches: + +```sh +node benchmarking/compare/profile-diff.mjs kairo_mux_owned main foo +``` + +This prints top function sample-share deltas for the selected benchmark. + +## Practical gotchas + +- `bench:compare` checks out branches while running. Avoid uncommitted changes (or stash them) so branch switching is safe. +- Each `bench:compare` run rewrites `benchmarking/compare/.results` and `benchmarking/compare/.profiles`. diff --git a/.changeset/warm-cougars-behave.md b/.changeset/warm-cougars-behave.md new file mode 100644 index 0000000000..15e32f7436 --- /dev/null +++ b/.changeset/warm-cougars-behave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reject pending async deriveds on discard diff --git a/.editorconfig b/.editorconfig index 2f52d9993f..900cdf7cdc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,7 +4,6 @@ root = true end_of_line = lf insert_final_newline = true indent_style = tab -indent_size = 2 charset = utf-8 trim_trailing_whitespace = true diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 0000000000..b6c26e0792 --- /dev/null +++ b/.github/workflows/autofix.yml @@ -0,0 +1,69 @@ +name: Autofix Lint + +on: + issue_comment: + types: [created] + workflow_dispatch: + +permissions: {} + +jobs: + autofix-lint: + permissions: + contents: write # to push the generated types commit + pull-requests: read # to resolve the PR head ref + # prevents this action from running on forks + if: | + github.repository == 'sveltejs/svelte' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.event.issue.pull_request != null && + github.event.comment.body == '/autofix' && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + ) + ) + runs-on: ubuntu-latest + steps: + - name: Get PR ref + if: github.event_name != 'workflow_dispatch' + id: pr + uses: actions/github-script@v8 + with: + script: | + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + if (pull.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'Cannot autofix: this PR is from a forked repository. The autofix workflow can only push to branches within this repository.' + }); + core.setFailed('PR is from a fork'); + } + core.setOutput('ref', pull.head.ref); + - uses: actions/checkout@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 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build + run: pnpm -F svelte build + - name: Run prettier + run: pnpm format + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git diff --staged --quiet || git commit -m "chore: autofix" + git push origin HEAD diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbdd1e420c..365717755e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,26 +12,29 @@ env: jobs: Tests: + permissions: {} runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: matrix: include: - - node-version: 18 + # Vitest 4 requires Node 20+, so tests run on 20/22/24. The published + # Svelte package still supports Node >=18 (see packages/svelte/package.json). + - node-version: 20 os: windows-latest - - node-version: 18 + - node-version: 20 os: macOS-latest - - node-version: 18 - os: ubuntu-latest - node-version: 20 os: ubuntu-latest - node-version: 22 os: ubuntu-latest + - node-version: 24 + os: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: pnpm @@ -40,15 +43,50 @@ jobs: - run: pnpm test env: CI: true - Lint: + TestNoAsync: + permissions: {} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm playwright install chromium + - run: pnpm test runtime-runes + env: + CI: true + SVELTE_NO_ASYNC: true + TSGo: + permissions: {} runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - name: install + run: pnpm install --frozen-lockfile + - name: install tsgo + run: cd packages/svelte && pnpm i -D @typescript/native-preview + - name: type check + run: cd packages/svelte && pnpm check:tsgo + Lint: + permissions: {} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + - uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 24 cache: pnpm - name: install run: pnpm install --frozen-lockfile @@ -59,16 +97,19 @@ jobs: run: pnpm lint - name: build and check generated types if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail - run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally and commit the changes after you have reviewed them"; git diff; exit 1); } + run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); } + - name: check browser-support docs page is up to date + run: '{ [ "`git status --porcelain=v1 documentation/docs/07-misc/.generated/`" == "" ] || (echo "The browser-support docs page is out of date — please regenerate it locally with \`cd packages/svelte && pnpm generate:browser-support\` and commit the changes"; git diff documentation/docs/07-misc/.generated/; exit 1); }' Benchmarks: + permissions: {} runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + - uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm bench diff --git a/.github/workflows/docs-preview-create-request.yml b/.github/workflows/docs-preview-create-request.yml deleted file mode 100644 index f57766dc36..0000000000 --- a/.github/workflows/docs-preview-create-request.yml +++ /dev/null @@ -1,26 +0,0 @@ -# https://github.com/sveltejs/svelte.dev/blob/main/apps/svelte.dev/scripts/sync-docs/README.md -name: Docs preview create request - -on: - pull_request_target: - branches: - - main - -jobs: - dispatch: - runs-on: ubuntu-latest - steps: - - name: Repository Dispatch - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.SYNC_REQUEST_TOKEN }} - repository: sveltejs/svelte.dev - event-type: docs-preview-create - client-payload: |- - { - "package": "svelte", - "repo": "${{ github.repository }}", - "owner": "${{ github.event.pull_request.head.repo.owner.login }}", - "branch": "${{ github.event.pull_request.head.ref }}", - "pr": ${{ github.event.pull_request.number }} - } diff --git a/.github/workflows/docs-preview-delete-request.yml b/.github/workflows/docs-preview-delete-request.yml deleted file mode 100644 index 4eb0e996a6..0000000000 --- a/.github/workflows/docs-preview-delete-request.yml +++ /dev/null @@ -1,27 +0,0 @@ -# https://github.com/sveltejs/svelte.dev/blob/main/apps/svelte.dev/scripts/sync-docs/README.md -name: Docs preview delete request - -on: - pull_request_target: - branches: - - main - types: [closed] - -jobs: - dispatch: - runs-on: ubuntu-latest - steps: - - name: Repository Dispatch - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.SYNC_REQUEST_TOKEN }} - repository: sveltejs/svelte.dev - event-type: docs-preview-delete - client-payload: |- - { - "package": "svelte", - "repo": "${{ github.repository }}", - "owner": "${{ github.event.pull_request.head.repo.owner.login }}", - "branch": "${{ github.event.pull_request.head.ref }}", - "pr": ${{ github.event.pull_request.number }} - } diff --git a/.github/workflows/ecosystem-ci-trigger.yml b/.github/workflows/ecosystem-ci-trigger.yml index ce7bf04136..8a6d1bf345 100644 --- a/.github/workflows/ecosystem-ci-trigger.yml +++ b/.github/workflows/ecosystem-ci-trigger.yml @@ -4,12 +4,21 @@ on: issue_comment: types: [created] +permissions: {} + jobs: trigger: runs-on: ubuntu-latest if: github.repository == 'sveltejs/svelte' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/ecosystem-ci run') + permissions: + issues: write # to add / delete reactions, post comments + pull-requests: write # to read PR data, and to add labels + actions: read # to check workflow status + contents: read # to clone the repo steps: - - uses: actions/github-script@v6 + - name: Check User Permissions + uses: actions/github-script@v8 + id: check-permissions with: script: | const user = context.payload.sender.login @@ -28,7 +37,7 @@ jobs: } if (hasTriagePermission) { - console.log('Allowed') + console.log('User is allowed. Adding +1 reaction.') await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, @@ -36,16 +45,18 @@ jobs: content: '+1', }) } else { - console.log('Not allowed') + console.log('User is not allowed. Adding -1 reaction.') await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, content: '-1', }) - throw new Error('not allowed') + throw new Error('User does not have the necessary permissions.') } - - uses: actions/github-script@v6 + + - name: Get PR Data + uses: actions/github-script@v8 id: get-pr-data with: script: | @@ -55,27 +66,65 @@ jobs: repo: context.repo.repo, pull_number: context.issue.number }) + + const commentCreatedAt = new Date(context.payload.comment.created_at) + const commitPushedAt = new Date(pr.head.repo.pushed_at) + + console.log(`Comment created at: ${commentCreatedAt.toISOString()}`) + console.log(`PR last pushed at: ${commitPushedAt.toISOString()}`) + + // Check if any commits were pushed after the comment was created + if (commitPushedAt > commentCreatedAt) { + const errorMsg = [ + '⚠️ Security warning: PR was updated after the trigger command was posted.', + '', + `Comment posted at: ${commentCreatedAt.toISOString()}`, + `PR last pushed at: ${commitPushedAt.toISOString()}`, + '', + 'This could indicate an attempt to inject code after approval.', + 'Please review the latest changes and re-run /ecosystem-ci run if they are acceptable.' + ].join('\n') + + core.setFailed(errorMsg) + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: errorMsg + }) + + throw new Error('PR was pushed to after comment was created') + } + return { num: context.issue.number, branchName: pr.head.ref, + commit: pr.head.sha, repo: pr.head.repo.full_name } - - id: generate-token - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 #keep pinned for security reasons, currently 1.8.0 + + - name: Generate Token + id: generate-token + uses: actions/create-github-app-token@v2 with: - app_id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }} - private_key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }} - repository: '${{ github.repository_owner }}/svelte-ecosystem-ci' - - uses: actions/github-script@v6 + app-id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }} + private-key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }} + repositories: | + svelte + svelte-ecosystem-ci + + - name: Trigger Downstream Workflow + uses: actions/github-script@v8 id: trigger env: COMMENT: ${{ github.event.comment.body }} + PR_DATA: ${{ steps.get-pr-data.outputs.result }} with: github-token: ${{ steps.generate-token.outputs.token }} - result-encoding: string script: | const comment = process.env.COMMENT.trim() - const prData = ${{ steps.get-pr-data.outputs.result }} + const prData = JSON.parse(process.env.PR_DATA) const suite = comment.split('\n')[0].replace(/^\/ecosystem-ci run/, '').trim() @@ -88,6 +137,7 @@ jobs: prNumber: '' + prData.num, branchName: prData.branchName, repo: prData.repo, + commit: prData.commit, suite: suite === '' ? '-' : suite } }) diff --git a/.github/workflows/pkg.pr.new-comment.yml b/.github/workflows/pkg.pr.new-comment.yml deleted file mode 100644 index 1698a456d3..0000000000 --- a/.github/workflows/pkg.pr.new-comment.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Update pkg.pr.new comment - -on: - workflow_run: - workflows: ['Publish Any Commit'] - types: - - completed - -jobs: - build: - name: 'Update comment' - runs-on: ubuntu-latest - steps: - - name: Download artifact - uses: actions/download-artifact@v4 - with: - name: output - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ github.event.workflow_run.id }} - - - run: ls -R . - - name: 'Post or update comment' - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const output = JSON.parse(fs.readFileSync('output.json', 'utf8')); - - const bot_comment_identifier = ``; - - const body = (number) => `${bot_comment_identifier} - - [Playground](https://svelte.dev/playground?version=pr-${number}) - - \`\`\` - ${output.packages.map((p) => `pnpm add https://pkg.pr.new/${p.name}@${number}`).join('\n')} - \`\`\` - `; - - async function find_bot_comment(issue_number) { - if (!issue_number) return null; - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue_number, - }); - return comments.data.find((comment) => - comment.body.includes(bot_comment_identifier) - ); - } - - async function create_or_update_comment(issue_number) { - if (!issue_number) { - console.log('No issue number provided. Cannot post or update comment.'); - return; - } - - const existing_comment = await find_bot_comment(issue_number); - if (existing_comment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing_comment.id, - body: body(issue_number), - }); - } else { - await github.rest.issues.createComment({ - issue_number: issue_number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body(issue_number), - }); - } - } - - async function log_publish_info() { - const svelte_package = output.packages.find(p => p.name === 'svelte'); - const svelte_sha = svelte_package.url.replace(/^.+@([^@]+)$/, '$1'); - console.log('\n' + '='.repeat(50)); - console.log('Publish Information'); - console.log('='.repeat(50)); - console.log('\nPublished Packages:'); - console.log(output.packages.map((p) => `${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.url.replace(/^.+@([^@]+)$/, '$1')}`).join('\n')); - if(svelte_sha){ - console.log('\nPlayground URL:'); - console.log(`\nhttps://svelte.dev/playground?version=commit-${svelte_sha}`) - } - console.log('\n' + '='.repeat(50)); - } - - if (output.event_name === 'pull_request') { - if (output.number) { - await create_or_update_comment(output.number); - } - } else if (output.event_name === 'push') { - const pull_requests = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - head: `${context.repo.owner}:${output.ref.replace('refs/heads/', '')}`, - }); - - if (pull_requests.data.length > 0) { - await create_or_update_comment(pull_requests.data[0].number); - } else { - console.log( - 'No open pull request found for this push. Logging publish information to console:' - ); - await log_publish_info(); - } - } diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index 4292ec900a..0fcda5a778 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -1,18 +1,44 @@ -name: Publish Any Commit -on: [push, pull_request] +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: - - name: Checkout code - uses: actions/checkout@v4 + - 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 }} - - run: corepack enable - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + - uses: actions/setup-node@v6 with: - node-version: 18.x + node-version: 22.x cache: pnpm - name: Install dependencies @@ -22,21 +48,182 @@ jobs: run: pnpm build - run: pnpx pkg-pr-new publish --comment=off --json output.json --compact --no-template './packages/svelte' - - name: Add metadata to output - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const output = JSON.parse(fs.readFileSync('output.json', 'utf8')); - output.number = context.issue.number; - output.event_name = context.eventName; - output.ref = context.ref; - fs.writeFileSync('output.json', JSON.stringify(output), 'utf8'); + - name: Upload output uses: actions/upload-artifact@v4 with: name: output path: ./output.json - - run: ls -R . + # 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 a17f49bbeb..359fcb7eea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,11 @@ on: branches: - main +concurrency: + # prevent two release workflows from running at once + # race conditions here can result in releases failing + group: ${{ github.workflow }} + permissions: {} jobs: release: @@ -18,30 +23,29 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@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@v4 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 18.x + node-version: 24.x cache: pnpm - name: Install run: pnpm install --frozen-lockfile - name: Build - run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally and commit the changes after you have reviewed them"; git diff; exit 1); } + run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); } - name: Create Release Pull Request or Publish to npm id: changesets - uses: changesets/action@v1 + uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1 with: version: pnpm changeset:version publish: pnpm changeset:publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_CONFIG_PROVENANCE: true - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/sync-request.yml b/.github/workflows/sync-request.yml deleted file mode 100644 index de2ce77692..0000000000 --- a/.github/workflows/sync-request.yml +++ /dev/null @@ -1,22 +0,0 @@ -# https://github.com/sveltejs/svelte.dev/blob/main/apps/svelte.dev/scripts/sync-docs/README.md -name: Sync request - -on: - push: - branches: - - main - -jobs: - dispatch: - runs-on: ubuntu-latest - steps: - - name: Repository Dispatch - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.SYNC_REQUEST_TOKEN }} - repository: sveltejs/svelte.dev - event-type: sync-request - client-payload: |- - { - "package": "svelte" - } diff --git a/.gitignore b/.gitignore index d503437664..556cae6344 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,8 @@ coverage .DS_Store tmp +packages/svelte/scripts/_baseline/ +benchmarking/.profiles benchmarking/compare/.results +benchmarking/compare/.profiles diff --git a/.prettierignore b/.prettierignore index d5c124353c..28f447f359 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,8 @@ packages/**/config/*.js # packages/svelte packages/svelte/messages/**/*.md +packages/svelte/scripts/_bundle.js +packages/svelte/scripts/_baseline/*.ts packages/svelte/src/compiler/errors.js packages/svelte/src/compiler/warnings.js packages/svelte/src/internal/client/errors.js @@ -14,6 +16,7 @@ packages/svelte/src/internal/client/warnings.js packages/svelte/src/internal/shared/errors.js packages/svelte/src/internal/shared/warnings.js packages/svelte/src/internal/server/errors.js +packages/svelte/src/internal/server/warnings.js packages/svelte/tests/migrate/samples/*/output.svelte packages/svelte/tests/**/*.svelte packages/svelte/tests/**/_expected* @@ -23,19 +26,15 @@ packages/svelte/tests/**/_output packages/svelte/tests/**/shards/*.test.js packages/svelte/tests/hydration/samples/*/_expected.html packages/svelte/tests/hydration/samples/*/_override.html +packages/svelte/tests/parser-legacy/samples/*/_actual.json +packages/svelte/tests/parser-legacy/samples/*/output.json +packages/svelte/tests/parser-modern/samples/*/_actual.json +packages/svelte/tests/parser-modern/samples/*/output.json packages/svelte/types packages/svelte/compiler/index.js -playgrounds/sandbox/input/**.svelte -playgrounds/sandbox/output - -# sites/svelte.dev -sites/svelte.dev/static/svelte-app.json -sites/svelte.dev/scripts/svelte-app/ -sites/svelte.dev/src/routes/_components/Supporters/contributors.jpg -sites/svelte.dev/src/routes/_components/Supporters/contributors.js -sites/svelte.dev/src/routes/_components/Supporters/donors.jpg -sites/svelte.dev/src/routes/_components/Supporters/donors.js -sites/svelte.dev/src/lib/generated +playgrounds/sandbox/dist/* +playgrounds/sandbox/output/* +playgrounds/sandbox/src/* **/node_modules **/.svelte-kit diff --git a/.prettierrc b/.prettierrc index c4fd5d9f2f..c2d09a4289 100644 --- a/.prettierrc +++ b/.prettierrc @@ -17,12 +17,6 @@ "useTabs": false, "tabWidth": 2 } - }, - { - "files": ["sites/svelte-5-preview/src/routes/docs/content/**/*.md"], - "options": { - "printWidth": 60 - } } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 21a2a11c84..4d360cbc8a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,3 @@ { - "search.exclude": { - "sites/svelte-5-preview/static/*": true - }, "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/.well-known/funding-manifest-urls b/.well-known/funding-manifest-urls new file mode 100644 index 0000000000..b8ccc27b78 --- /dev/null +++ b/.well-known/funding-manifest-urls @@ -0,0 +1,2 @@ +https://svelte.dev/funding.json + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..7f143248aa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Svelte Coding Agent Guide + +This guide is for AI coding agents working in the Svelte monorepo. + +**Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here. + +When submitting a PR, you **MUST** read [`PULL_REQUEST_TEMPLATE.md`](./.github/PULL_REQUEST_TEMPLATE.md) and fill it out correctly. **DO NOT** submit a PR without running the full test suite. + +## Quick Reference + +If asked to do a performance investigation, use the `performance-investigation` skill. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd7bbb476e..586c6fe6ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ The [Open Source Guides](https://opensource.guide/) website has a collection of ## Get involved -There are many ways to contribute to Svelte, and many of them do not involve writing any code. Here's a few ideas to get started: +There are many ways to contribute to Svelte, and many of them do not involve writing any code. Here are a few ideas to get started: - Simply start using Svelte. Go through the [Getting Started](https://svelte.dev/docs#getting-started) guide. Does everything work as expected? If not, we're always looking for improvements. Let us know by [opening an issue](#reporting-new-issues). - Look through the [open issues](https://github.com/sveltejs/svelte/issues). A good starting point would be issues tagged [good first issue](https://github.com/sveltejs/svelte/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Provide workarounds, ask for clarification, or suggest labels. Help [triage issues](#triaging-issues-and-pull-requests). @@ -43,7 +43,7 @@ The maintainers meet on the final Saturday of each month. While these meetings a ### Prioritization -We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active ones repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. +We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. ## Bugs @@ -51,7 +51,7 @@ We use [GitHub issues](https://github.com/sveltejs/svelte/issues) for our public If you have questions about using Svelte, contact us on Discord at [svelte.dev/chat](https://svelte.dev/chat), and we will do our best to answer your questions. -If you see anything you'd like to be implemented, create a [feature request issue](https://github.com/sveltejs/svelte/issues/new?template=feature_request.yml) +If you see anything you'd like to be implemented, create a [feature request issue](https://github.com/sveltejs/svelte/issues/new?template=feature_request.yml). ### Reporting new issues @@ -62,8 +62,6 @@ When [opening a new issue](https://github.com/sveltejs/svelte/issues/new/choose) ## Pull requests -> HEADS UP: Svelte 5 will likely change a lot on the compiler. For that reason, please don't open PRs that are large in scope, touch more than a couple of files etc. In other words, bug fixes are fine, but big feature PRs will likely not be merged. - ### Proposing a change If you would like to request a new feature or enhancement but are not yet thinking about opening a pull request, you can also file an issue with [feature template](https://github.com/sveltejs/svelte/issues/new?template=feature_request.yml). @@ -92,9 +90,9 @@ A good test plan has the exact commands you ran and their output, provides scree #### Writing tests -All tests are located in `/test` folder. +All tests are located in the `/tests` folder. -Test samples are kept in `/test/xxx/samples` folder. +Test samples are kept in `/tests/xxx/samples` folders. #### Running tests @@ -103,14 +101,14 @@ Test samples are kept in `/test/xxx/samples` folder. 1. To run test, run `pnpm test`. 1. To run a particular test suite, use `pnpm test `, for example: - ```bash + ```sh pnpm test validator ``` -1. To filter tests _within_ a test suite, use `pnpm test -- -t `, for example: +1. To filter tests _within_ a test suite, use `pnpm test -t `, for example: - ```bash - pnpm test validator -- -t a11y-alt-text + ```sh + pnpm test validator -t a11y-alt-text ``` (You can also do `FILTER= pnpm test ` which removes other tests rather than simply skipping them — this will result in faster and more compact test results, but it's non-idiomatic. Choose your fighter.) diff --git a/LICENSE.md b/LICENSE.md index e2a8b89fa4..f872adf738 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Copyright (c) 2016-24 [these people](https://github.com/sveltejs/svelte/graphs/contributors) +Copyright (c) 2016-2025 [Svelte Contributors](https://github.com/sveltejs/svelte/graphs/contributors) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index be94cba63c..7ea7164752 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ -[![Cybernetically enhanced web apps: Svelte](https://sveltejs.github.io/assets/banner.png)](https://svelte.dev) + + + + Svelte - web development for the rest of us + + -[![license](https://img.shields.io/npm/l/svelte.svg)](LICENSE.md) [![Chat](https://img.shields.io/discord/457912077277855764?label=chat&logo=discord)](https://svelte.dev/chat) +[![License](https://img.shields.io/npm/l/svelte.svg)](LICENSE.md) [![Chat](https://img.shields.io/discord/457912077277855764?label=chat&logo=discord)](https://svelte.dev/chat) ## What is Svelte? @@ -24,10 +29,6 @@ You may view [our roadmap](https://svelte.dev/roadmap) if you'd like to see what Please see the [Contributing Guide](CONTRIBUTING.md) and the [`svelte`](packages/svelte) package for information on contributing to Svelte. -### svelte.dev - -The source code for https://svelte.dev lives in the [sites](https://github.com/sveltejs/svelte/tree/master/sites/svelte.dev) folder, with all the documentation right [here](https://github.com/sveltejs/svelte/tree/master/documentation). The site is built with [SvelteKit](https://svelte.dev/docs/kit). - ## Is svelte.dev down? Probably not, but it's possible. If you can't seem to access any `.dev` sites, check out [this SuperUser question and answer](https://superuser.com/q/1413402). diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000000..3428b278bf Binary files /dev/null and b/assets/banner.png differ diff --git a/assets/banner_dark.png b/assets/banner_dark.png new file mode 100644 index 0000000000..1adba40d8e Binary files /dev/null and b/assets/banner_dark.png differ diff --git a/benchmarking/benchmarks/reactivity/index.js b/benchmarking/benchmarks/reactivity/index.js index 58b3f5cb29..3fe9639376 100644 --- a/benchmarking/benchmarks/reactivity/index.js +++ b/benchmarking/benchmarks/reactivity/index.js @@ -1,12 +1,6 @@ -import { kairo_avoidable_owned, kairo_avoidable_unowned } from './kairo/kairo_avoidable.js'; -import { kairo_broad_owned, kairo_broad_unowned } from './kairo/kairo_broad.js'; -import { kairo_deep_owned, kairo_deep_unowned } from './kairo/kairo_deep.js'; -import { kairo_diamond_owned, kairo_diamond_unowned } from './kairo/kairo_diamond.js'; -import { kairo_mux_unowned, kairo_mux_owned } from './kairo/kairo_mux.js'; -import { kairo_repeated_unowned, kairo_repeated_owned } from './kairo/kairo_repeated.js'; -import { kairo_triangle_owned, kairo_triangle_unowned } from './kairo/kairo_triangle.js'; -import { kairo_unstable_owned, kairo_unstable_unowned } from './kairo/kairo_unstable.js'; -import { mol_bench_owned, mol_bench_unowned } from './mol_bench.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import 'svelte/internal/flags/async'; import { sbench_create_0to1, sbench_create_1000to1, @@ -19,10 +13,14 @@ import { sbench_create_4to1, sbench_create_signals } from './sbench.js'; +import { fileURLToPath } 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) // Not all tests are the same, and many parts have been tweaked to capture different data. +const dirname = path.dirname(fileURLToPath(import.meta.url)); + export const reactivity_benchmarks = [ sbench_create_signals, sbench_create_0to1, @@ -33,23 +31,16 @@ export const reactivity_benchmarks = [ sbench_create_1to2, sbench_create_1to4, sbench_create_1to8, - sbench_create_1to1000, - kairo_avoidable_owned, - kairo_avoidable_unowned, - kairo_broad_owned, - kairo_broad_unowned, - kairo_deep_owned, - kairo_deep_unowned, - kairo_diamond_owned, - kairo_diamond_unowned, - kairo_triangle_owned, - kairo_triangle_unowned, - kairo_mux_owned, - kairo_mux_unowned, - kairo_repeated_owned, - kairo_repeated_unowned, - kairo_unstable_owned, - kairo_unstable_unowned, - mol_bench_owned, - mol_bench_unowned + sbench_create_1to1000 ]; + +for (const file of fs.readdirSync(`${dirname}/tests`)) { + if (!file.includes('.bench.')) continue; + + const name = file.replace('.bench.js', ''); + + const module = await import(`${dirname}/tests/${file}`); + const { owned, unowned } = create_test(name, module.default); + + reactivity_benchmarks.push(owned, unowned); +} diff --git a/benchmarking/benchmarks/reactivity/kairo/kairo_avoidable.js b/benchmarking/benchmarks/reactivity/kairo/kairo_avoidable.js deleted file mode 100644 index 1237547ebe..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/kairo_avoidable.js +++ /dev/null @@ -1,91 +0,0 @@ -import { assert, fastest_test } from '../../../utils.js'; -import * as $ from 'svelte/internal/client'; -import { busy } from './util.js'; - -function setup() { - let head = $.state(0); - let computed1 = $.derived(() => $.get(head)); - let computed2 = $.derived(() => ($.get(computed1), 0)); - let computed3 = $.derived(() => (busy(), $.get(computed2) + 1)); // heavy computation - let computed4 = $.derived(() => $.get(computed3) + 2); - let computed5 = $.derived(() => $.get(computed4) + 3); - - const destroy = $.effect_root(() => { - $.render_effect(() => { - $.get(computed5); - busy(); // heavy side effect - }); - }); - - return { - destroy, - run() { - $.flush_sync(() => { - $.set(head, 1); - }); - assert($.get(computed5) === 6); - for (let i = 0; i < 1000; i++) { - $.flush_sync(() => { - $.set(head, i); - }); - assert($.get(computed5) === 6); - } - } - }; -} - -export async function kairo_avoidable_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - destroy(); - - return { - benchmark: 'kairo_avoidable_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function kairo_avoidable_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'kairo_avoidable_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} diff --git a/benchmarking/benchmarks/reactivity/kairo/kairo_broad.js b/benchmarking/benchmarks/reactivity/kairo/kairo_broad.js deleted file mode 100644 index 8148a743ea..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/kairo_broad.js +++ /dev/null @@ -1,97 +0,0 @@ -import { assert, fastest_test } from '../../../utils.js'; -import * as $ from 'svelte/internal/client'; - -function setup() { - 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; - }); - $.render_effect(() => { - $.get(current2); - counter++; - }); - last = current2; - } - }); - - return { - destroy, - run() { - $.flush_sync(() => { - $.set(head, 1); - }); - counter = 0; - for (let i = 0; i < 50; i++) { - $.flush_sync(() => { - $.set(head, i); - }); - assert($.get(last) === i + 50); - } - assert(counter === 50 * 50); - } - }; -} - -export async function kairo_broad_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - destroy(); - - return { - benchmark: 'kairo_broad_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function kairo_broad_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'kairo_broad_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} diff --git a/benchmarking/benchmarks/reactivity/kairo/kairo_deep.js b/benchmarking/benchmarks/reactivity/kairo/kairo_deep.js deleted file mode 100644 index 806042cc72..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/kairo_deep.js +++ /dev/null @@ -1,97 +0,0 @@ -import { assert, fastest_test } from '../../../utils.js'; -import * as $ from 'svelte/internal/client'; - -let len = 50; -const iter = 50; - -function setup() { - 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(() => { - $.render_effect(() => { - $.get(current); - counter++; - }); - }); - - return { - destroy, - run() { - $.flush_sync(() => { - $.set(head, 1); - }); - counter = 0; - for (let i = 0; i < iter; i++) { - $.flush_sync(() => { - $.set(head, i); - }); - assert($.get(current) === len + i); - } - assert(counter === iter); - } - }; -} - -export async function kairo_deep_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - destroy(); - - return { - benchmark: 'kairo_deep_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function kairo_deep_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'kairo_deep_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} diff --git a/benchmarking/benchmarks/reactivity/kairo/kairo_diamond.js b/benchmarking/benchmarks/reactivity/kairo/kairo_diamond.js deleted file mode 100644 index deb9482de9..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/kairo_diamond.js +++ /dev/null @@ -1,101 +0,0 @@ -import { assert, fastest_test } from '../../../utils.js'; -import * as $ from 'svelte/internal/client'; - -let width = 5; - -function setup() { - let head = $.state(0); - let current = []; - for (let i = 0; i < width; i++) { - current.push( - $.derived(() => { - return $.get(head) + 1; - }) - ); - } - let sum = $.derived(() => { - return current.map((x) => $.get(x)).reduce((a, b) => a + b, 0); - }); - let counter = 0; - - const destroy = $.effect_root(() => { - $.render_effect(() => { - $.get(sum); - counter++; - }); - }); - - return { - destroy, - run() { - $.flush_sync(() => { - $.set(head, 1); - }); - assert($.get(sum) === 2 * width); - counter = 0; - for (let i = 0; i < 500; i++) { - $.flush_sync(() => { - $.set(head, i); - }); - assert($.get(sum) === (i + 1) * width); - } - assert(counter === 500); - } - }; -} - -export async function kairo_diamond_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - destroy(); - - return { - benchmark: 'kairo_diamond_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function kairo_diamond_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'kairo_diamond_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} diff --git a/benchmarking/benchmarks/reactivity/kairo/kairo_mux.js b/benchmarking/benchmarks/reactivity/kairo/kairo_mux.js deleted file mode 100644 index 8eafacc9eb..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/kairo_mux.js +++ /dev/null @@ -1,94 +0,0 @@ -import { assert, fastest_test } from '../../../utils.js'; -import * as $ from 'svelte/internal/client'; - -function setup() { - let heads = new Array(100).fill(null).map((_) => $.state(0)); - const mux = $.derived(() => { - return Object.fromEntries(heads.map((h) => $.get(h)).entries()); - }); - const splited = heads - .map((_, index) => $.derived(() => $.get(mux)[index])) - .map((x) => $.derived(() => $.get(x) + 1)); - - const destroy = $.effect_root(() => { - splited.forEach((x) => { - $.render_effect(() => { - $.get(x); - }); - }); - }); - - return { - destroy, - run() { - for (let i = 0; i < 10; i++) { - $.flush_sync(() => { - $.set(heads[i], i); - }); - assert($.get(splited[i]) === i + 1); - } - for (let i = 0; i < 10; i++) { - $.flush_sync(() => { - $.set(heads[i], i * 2); - }); - assert($.get(splited[i]) === i * 2 + 1); - } - } - }; -} - -export async function kairo_mux_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - destroy(); - - return { - benchmark: 'kairo_mux_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function kairo_mux_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'kairo_mux_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} diff --git a/benchmarking/benchmarks/reactivity/kairo/kairo_repeated.js b/benchmarking/benchmarks/reactivity/kairo/kairo_repeated.js deleted file mode 100644 index 2bddf879c9..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/kairo_repeated.js +++ /dev/null @@ -1,98 +0,0 @@ -import { assert, fastest_test } from '../../../utils.js'; -import * as $ from 'svelte/internal/client'; - -let size = 30; - -function setup() { - let head = $.state(0); - let current = $.derived(() => { - let result = 0; - for (let i = 0; i < size; i++) { - result += $.get(head); - } - return result; - }); - - let counter = 0; - - const destroy = $.effect_root(() => { - $.render_effect(() => { - $.get(current); - counter++; - }); - }); - - return { - destroy, - run() { - $.flush_sync(() => { - $.set(head, 1); - }); - assert($.get(current) === size); - counter = 0; - for (let i = 0; i < 100; i++) { - $.flush_sync(() => { - $.set(head, i); - }); - assert($.get(current) === i * size); - } - assert(counter === 100); - } - }; -} - -export async function kairo_repeated_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - destroy(); - - return { - benchmark: 'kairo_repeated_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function kairo_repeated_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'kairo_repeated_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} diff --git a/benchmarking/benchmarks/reactivity/kairo/kairo_triangle.js b/benchmarking/benchmarks/reactivity/kairo/kairo_triangle.js deleted file mode 100644 index 9d99b7815b..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/kairo_triangle.js +++ /dev/null @@ -1,111 +0,0 @@ -import { assert, fastest_test } from '../../../utils.js'; -import * as $ from 'svelte/internal/client'; - -let width = 10; - -function count(number) { - return new Array(number) - .fill(0) - .map((_, i) => i + 1) - .reduce((x, y) => x + y, 0); -} - -function setup() { - let head = $.state(0); - let current = head; - let list = []; - for (let i = 0; i < width; i++) { - let c = current; - list.push(current); - current = $.derived(() => { - return $.get(c) + 1; - }); - } - let sum = $.derived(() => { - return list.map((x) => $.get(x)).reduce((a, b) => a + b, 0); - }); - - let counter = 0; - - const destroy = $.effect_root(() => { - $.render_effect(() => { - $.get(sum); - counter++; - }); - }); - - return { - destroy, - run() { - const constant = count(width); - $.flush_sync(() => { - $.set(head, 1); - }); - assert($.get(sum) === constant); - counter = 0; - for (let i = 0; i < 100; i++) { - $.flush_sync(() => { - $.set(head, i); - }); - assert($.get(sum) === constant - width + i * width); - } - assert(counter === 100); - } - }; -} - -export async function kairo_triangle_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - destroy(); - - return { - benchmark: 'kairo_triangle_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function kairo_triangle_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'kairo_triangle_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} diff --git a/benchmarking/benchmarks/reactivity/kairo/kairo_unstable.js b/benchmarking/benchmarks/reactivity/kairo/kairo_unstable.js deleted file mode 100644 index c30c007561..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/kairo_unstable.js +++ /dev/null @@ -1,97 +0,0 @@ -import { assert, fastest_test } from '../../../utils.js'; -import * as $ from 'svelte/internal/client'; - -function setup() { - let head = $.state(0); - const double = $.derived(() => $.get(head) * 2); - const inverse = $.derived(() => -$.get(head)); - let current = $.derived(() => { - let result = 0; - for (let i = 0; i < 20; i++) { - result += $.get(head) % 2 ? $.get(double) : $.get(inverse); - } - return result; - }); - - let counter = 0; - - const destroy = $.effect_root(() => { - $.render_effect(() => { - $.get(current); - counter++; - }); - }); - - return { - destroy, - run() { - $.flush_sync(() => { - $.set(head, 1); - }); - assert($.get(current) === 40); - counter = 0; - for (let i = 0; i < 100; i++) { - $.flush_sync(() => { - $.set(head, i); - }); - } - assert(counter === 100); - } - }; -} - -export async function kairo_unstable_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - destroy(); - - return { - benchmark: 'kairo_unstable_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function kairo_unstable_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - run(); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'kairo_unstable_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} diff --git a/benchmarking/benchmarks/reactivity/kairo/util.js b/benchmarking/benchmarks/reactivity/kairo/util.js deleted file mode 100644 index 75e3641ab9..0000000000 --- a/benchmarking/benchmarks/reactivity/kairo/util.js +++ /dev/null @@ -1,6 +0,0 @@ -export function busy() { - let a = 0; - for (let i = 0; i < 1_00; i++) { - a++; - } -} diff --git a/benchmarking/benchmarks/reactivity/sbench.js b/benchmarking/benchmarks/reactivity/sbench.js index ddeaef2514..e197f970c8 100644 --- a/benchmarking/benchmarks/reactivity/sbench.js +++ b/benchmarking/benchmarks/reactivity/sbench.js @@ -1,3 +1,4 @@ +/** @import { Source } from '../../../packages/svelte/src/internal/client/types.js' */ import { fastest_test } from '../../utils.js'; import * as $ from '../../../packages/svelte/src/internal/client/index.js'; @@ -7,360 +8,177 @@ const COUNT = 1e5; * @param {number} n * @param {any[]} sources */ -function create_data_signals(n, sources) { +function create_sources(n, sources) { for (let i = 0; i < n; i++) { sources[i] = $.state(i); } + return sources; } /** - * @param {number} i + * @param {Source} source */ -function create_computation_0(i) { - $.derived(() => i); +function create_derived(source) { + $.derived(() => $.get(source)); } /** - * @param {any} s1 - */ -function create_computation_1(s1) { - $.derived(() => $.get(s1)); -} -/** - * @param {any} s1 - * @param {any} s2 + * + * @param {string} label + * @param {(n: number, sources: Array>) => void} fn + * @param {number} count + * @param {number} num_sources */ -function create_computation_2(s1, s2) { - $.derived(() => $.get(s1) + $.get(s2)); -} +function create_sbench_test(label, count, num_sources, fn) { + return { + label, + fn: async () => { + // Do 3 loops to warm up JIT + for (let i = 0; i < 3; i++) { + fn(count, create_sources(num_sources, [])); + } -function create_computation_1000(ss, offset) { - $.derived(() => { - let sum = 0; - for (let i = 0; i < 1000; i++) { - sum += $.get(ss[offset + i]); + return await fastest_test(10, () => { + const destroy = $.effect_root(() => { + for (let i = 0; i < 10; i++) { + fn(count, create_sources(num_sources, [])); + } + }); + destroy(); + }); } - return sum; - }); + }; } -/** - * @param {number} n - */ -function create_computations_0to1(n) { - for (let i = 0; i < n; i++) { - create_computation_0(i); - } -} +export const sbench_create_signals = create_sbench_test( + 'sbench_create_signals', + COUNT, + COUNT, + create_sources +); -/** - * @param {number} n - * @param {any[]} sources - */ -function create_computations_1to1(n, sources) { - for (let i = 0; i < n; i++) { - const source = sources[i]; - create_computation_1(source); - } -} - -/** - * @param {number} n - * @param {any[]} sources - */ -function create_computations_2to1(n, sources) { +export const sbench_create_0to1 = create_sbench_test('sbench_create_0to1', COUNT, 0, (n) => { for (let i = 0; i < n; i++) { - create_computation_2(sources[i * 2], sources[i * 2 + 1]); + $.derived(() => i); } -} - -function create_computation_4(s1, s2, s3, s4) { - $.derived(() => $.get(s1) + $.get(s2) + $.get(s3) + $.get(s4)); -} +}); -function create_computations_1000to1(n, sources) { - for (let i = 0; i < n; i++) { - create_computation_1000(sources, i * 1000); - } -} - -function create_computations_1to2(n, sources) { - for (let i = 0; i < n / 2; i++) { - const source = sources[i]; - create_computation_1(source); - create_computation_1(source); - } -} - -function create_computations_1to4(n, sources) { - for (let i = 0; i < n / 4; i++) { - const source = sources[i]; - create_computation_1(source); - create_computation_1(source); - create_computation_1(source); - create_computation_1(source); - } -} - -function create_computations_1to8(n, sources) { - for (let i = 0; i < n / 8; i++) { - const source = sources[i]; - create_computation_1(source); - create_computation_1(source); - create_computation_1(source); - create_computation_1(source); - create_computation_1(source); - create_computation_1(source); - create_computation_1(source); - create_computation_1(source); - } -} - -function create_computations_1to1000(n, sources) { - for (let i = 0; i < n / 1000; i++) { - const source = sources[i]; - for (let j = 0; j < 1000; j++) { - create_computation_1(source); +export const sbench_create_1to1 = create_sbench_test( + 'sbench_create_1to1', + COUNT, + COUNT, + (n, sources) => { + for (let i = 0; i < n; i++) { + create_derived(sources[i]); } } -} - -function create_computations_4to1(n, sources) { - for (let i = 0; i < n; i++) { - create_computation_4( - sources[i * 4], - sources[i * 4 + 1], - sources[i * 4 + 2], - sources[i * 4 + 3] - ); - } -} - -/** - * @param {any} fn - * @param {number} count - * @param {number} scount - */ -function bench(fn, count, scount) { - let sources = create_data_signals(scount, []); - - fn(count, sources); -} - -export async function sbench_create_signals() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_data_signals, COUNT, COUNT); - } +); - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { - bench(create_data_signals, COUNT, COUNT); +export const sbench_create_2to1 = create_sbench_test( + 'sbench_create_2to1', + COUNT / 2, + COUNT, + (n, sources) => { + for (let i = 0; i < n; i++) { + $.derived(() => $.get(sources[i * 2]) + $.get(sources[i * 2 + 1])); } - }); - - return { - benchmark: 'sbench_create_signals', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_0to1() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_0to1, COUNT, 0); - } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_0to1, COUNT, 0); - } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_0to1', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_1to1() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_1to1, COUNT, COUNT); } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_1to1, COUNT, COUNT); - } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_1to1', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_2to1() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_2to1, COUNT / 2, COUNT); - } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_2to1, COUNT / 2, COUNT); - } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_2to1', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_4to1() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_4to1, COUNT / 4, COUNT); +); + +export const sbench_create_4to1 = create_sbench_test( + 'sbench_create_4to1', + COUNT / 4, + COUNT, + (n, sources) => { + for (let i = 0; i < n; i++) { + $.derived( + () => + $.get(sources[i * 4]) + + $.get(sources[i * 4 + 1]) + + $.get(sources[i * 4 + 2]) + + $.get(sources[i * 4 + 3]) + ); + } } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_4to1, COUNT / 4, COUNT); - } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_4to1', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_1000to1() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_1000to1, COUNT / 1000, COUNT); +); + +export const sbench_create_1000to1 = create_sbench_test( + 'sbench_create_1000to1', + COUNT / 1000, + COUNT, + (n, sources) => { + for (let i = 0; i < n; i++) { + const offset = i * 1000; + + $.derived(() => { + let sum = 0; + for (let i = 0; i < 1000; i++) { + sum += $.get(sources[offset + i]); + } + return sum; + }); + } } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_1000to1, COUNT / 1000, COUNT); - } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_1000to1', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_1to2() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_1to2, COUNT, COUNT / 2); +); + +export const sbench_create_1to2 = create_sbench_test( + 'sbench_create_1to2', + COUNT, + COUNT / 2, + (n, sources) => { + for (let i = 0; i < n / 2; i++) { + const source = sources[i]; + create_derived(source); + create_derived(source); + } } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_1to2, COUNT, COUNT / 2); - } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_1to2', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_1to4() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_1to4, COUNT, COUNT / 4); +); + +export const sbench_create_1to4 = create_sbench_test( + 'sbench_create_1to4', + COUNT, + COUNT / 4, + (n, sources) => { + for (let i = 0; i < n / 4; i++) { + const source = sources[i]; + create_derived(source); + create_derived(source); + create_derived(source); + create_derived(source); + } } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_1to4, COUNT, COUNT / 4); - } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_1to4', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_1to8() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_1to8, COUNT, COUNT / 8); +); + +export const sbench_create_1to8 = create_sbench_test( + 'sbench_create_1to8', + COUNT, + COUNT / 8, + (n, sources) => { + for (let i = 0; i < n / 8; i++) { + const source = sources[i]; + create_derived(source); + create_derived(source); + create_derived(source); + create_derived(source); + create_derived(source); + create_derived(source); + create_derived(source); + create_derived(source); + } } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_1to8, COUNT, COUNT / 8); +); + +export const sbench_create_1to1000 = create_sbench_test( + 'sbench_create_1to1000', + COUNT, + COUNT / 1000, + (n, sources) => { + for (let i = 0; i < n / 1000; i++) { + const source = sources[i]; + for (let j = 0; j < 1000; j++) { + create_derived(source); } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_1to8', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function sbench_create_1to1000() { - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - bench(create_computations_1to1000, COUNT, COUNT / 1000); + } } - - const { timing } = await fastest_test(10, () => { - const destroy = $.effect_root(() => { - for (let i = 0; i < 10; i++) { - bench(create_computations_1to1000, COUNT, COUNT / 1000); - } - }); - destroy(); - }); - - return { - benchmark: 'sbench_create_1to1000', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} +); diff --git a/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js b/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js new file mode 100644 index 0000000000..a617554f1b --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/clean_effects.bench.js @@ -0,0 +1,32 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +export default () => { + const a = $.state(1); + const b = $.state(2); + + let total = 0; + + const destroy = $.effect_root(() => { + for (let i = 0; i < 1000; i += 1) { + $.render_effect(() => { + total += $.get(a); + }); + } + + $.render_effect(() => { + total += $.get(b); + }); + }); + + return { + destroy, + run() { + for (let i = 0; i < 5; i++) { + total = 0; + $.flush(() => $.set(b, i)); + assert.equal(total, i); + } + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_avoidable.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_avoidable.bench.js new file mode 100644 index 0000000000..d4ba858824 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_avoidable.bench.js @@ -0,0 +1,35 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; +import { busy } from '../util.js'; + +export default () => { + let head = $.state(0); + let computed1 = $.derived(() => $.get(head)); + let computed2 = $.derived(() => ($.get(computed1), 0)); + let computed3 = $.derived(() => (busy(), $.get(computed2) + 1)); // heavy computation + let computed4 = $.derived(() => $.get(computed3) + 2); + let computed5 = $.derived(() => $.get(computed4) + 3); + + const destroy = $.effect_root(() => { + $.render_effect(() => { + $.get(computed5); + busy(); // heavy side effect + }); + }); + + return { + destroy, + run() { + $.flush(() => { + $.set(head, 1); + }); + assert.equal($.get(computed5), 6); + for (let i = 0; i < 1000; i++) { + $.flush(() => { + $.set(head, i); + }); + assert.equal($.get(computed5), 6); + } + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_broad.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_broad.bench.js new file mode 100644 index 0000000000..aebae7a898 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_broad.bench.js @@ -0,0 +1,41 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +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; + }); + $.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.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_deep.bench.js new file mode 100644 index 0000000000..4a361e9bfc --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_deep.bench.js @@ -0,0 +1,41 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +let len = 50; +const iter = 50; + +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(() => { + $.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/benchmarks/reactivity/tests/kairo_diamond.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_diamond.bench.js new file mode 100644 index 0000000000..17d9bd85e5 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_diamond.bench.js @@ -0,0 +1,45 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +let width = 5; + +export default () => { + let head = $.state(0); + let current = []; + for (let i = 0; i < width; i++) { + current.push( + $.derived(() => { + return $.get(head) + 1; + }) + ); + } + let sum = $.derived(() => { + return current.map((x) => $.get(x)).reduce((a, b) => a + b, 0); + }); + let counter = 0; + + const destroy = $.effect_root(() => { + $.render_effect(() => { + $.get(sum); + counter++; + }); + }); + + return { + destroy, + run() { + $.flush(() => { + $.set(head, 1); + }); + assert.equal($.get(sum), 2 * width); + counter = 0; + for (let i = 0; i < 500; i++) { + $.flush(() => { + $.set(head, i); + }); + assert.equal($.get(sum), (i + 1) * width); + } + assert.equal(counter, 500); + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_mux.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_mux.bench.js new file mode 100644 index 0000000000..4af6bf7873 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_mux.bench.js @@ -0,0 +1,38 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +export default () => { + let heads = new Array(100).fill(null).map((_) => $.state(0)); + const mux = $.derived(() => { + return Object.fromEntries(heads.map((h) => $.get(h)).entries()); + }); + const splited = heads + .map((_, index) => $.derived(() => $.get(mux)[index])) + .map((x) => $.derived(() => $.get(x) + 1)); + + const destroy = $.effect_root(() => { + splited.forEach((x) => { + $.render_effect(() => { + $.get(x); + }); + }); + }); + + return { + destroy, + run() { + for (let i = 0; i < 10; i++) { + $.flush(() => { + $.set(heads[i], i); + }); + assert.equal($.get(splited[i]), i + 1); + } + for (let i = 0; i < 10; i++) { + $.flush(() => { + $.set(heads[i], i * 2); + }); + assert.equal($.get(splited[i]), i * 2 + 1); + } + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_repeated.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_repeated.bench.js new file mode 100644 index 0000000000..cab7689fea --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_repeated.bench.js @@ -0,0 +1,42 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +let size = 30; + +export default () => { + let head = $.state(0); + let current = $.derived(() => { + let result = 0; + for (let i = 0; i < size; i++) { + result += $.get(head); + } + return result; + }); + + let counter = 0; + + const destroy = $.effect_root(() => { + $.render_effect(() => { + $.get(current); + counter++; + }); + }); + + return { + destroy, + run() { + $.flush(() => { + $.set(head, 1); + }); + assert.equal($.get(current), size); + counter = 0; + for (let i = 0; i < 100; i++) { + $.flush(() => { + $.set(head, i); + }); + assert.equal($.get(current), i * size); + } + assert.equal(counter, 100); + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_triangle.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_triangle.bench.js new file mode 100644 index 0000000000..b4b46c0209 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_triangle.bench.js @@ -0,0 +1,55 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +let width = 10; + +function count(number) { + return new Array(number) + .fill(0) + .map((_, i) => i + 1) + .reduce((x, y) => x + y, 0); +} + +export default () => { + let head = $.state(0); + let current = head; + let list = []; + for (let i = 0; i < width; i++) { + let c = current; + list.push(current); + current = $.derived(() => { + return $.get(c) + 1; + }); + } + let sum = $.derived(() => { + return list.map((x) => $.get(x)).reduce((a, b) => a + b, 0); + }); + + let counter = 0; + + const destroy = $.effect_root(() => { + $.render_effect(() => { + $.get(sum); + counter++; + }); + }); + + return { + destroy, + run() { + const constant = count(width); + $.flush(() => { + $.set(head, 1); + }); + assert.equal($.get(sum), constant); + counter = 0; + for (let i = 0; i < 100; i++) { + $.flush(() => { + $.set(head, i); + }); + assert.equal($.get(sum), constant - width + i * width); + } + assert.equal(counter, 100); + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/tests/kairo_unstable.bench.js b/benchmarking/benchmarks/reactivity/tests/kairo_unstable.bench.js new file mode 100644 index 0000000000..e7723fae0d --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/kairo_unstable.bench.js @@ -0,0 +1,41 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +export default () => { + let head = $.state(0); + const double = $.derived(() => $.get(head) * 2); + const inverse = $.derived(() => -$.get(head)); + let current = $.derived(() => { + let result = 0; + for (let i = 0; i < 20; i++) { + result += $.get(head) % 2 ? $.get(double) : $.get(inverse); + } + return result; + }); + + let counter = 0; + + const destroy = $.effect_root(() => { + $.render_effect(() => { + $.get(current); + counter++; + }); + }); + + return { + destroy, + run() { + $.flush(() => { + $.set(head, 1); + }); + assert.equal($.get(current), 40); + counter = 0; + for (let i = 0; i < 100; i++) { + $.flush(() => { + $.set(head, i); + }); + } + assert.equal(counter, 100); + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/mol_bench.js b/benchmarking/benchmarks/reactivity/tests/mol.bench.js similarity index 51% rename from benchmarking/benchmarks/reactivity/mol_bench.js rename to benchmarking/benchmarks/reactivity/tests/mol.bench.js index c9f492f619..e66f0191d1 100644 --- a/benchmarking/benchmarks/reactivity/mol_bench.js +++ b/benchmarking/benchmarks/reactivity/tests/mol.bench.js @@ -1,4 +1,4 @@ -import { assert, fastest_test } from '../../utils.js'; +import assert from 'node:assert'; import * as $ from 'svelte/internal/client'; /** @@ -18,7 +18,7 @@ function hard(n) { const numbers = Array.from({ length: 5 }, (_, i) => i); -function setup() { +export default () => { let res = []; const A = $.state(0); const B = $.state(0); @@ -51,71 +51,18 @@ function setup() { */ run(i) { res.length = 0; - $.flush_sync(() => { + $.flush(() => { $.set(B, 1); $.set(A, 1 + i * 2); }); - $.flush_sync(() => { + $.flush(() => { $.set(A, 2 + i * 2); $.set(B, 2); }); - assert(res[0] === 3198 && res[1] === 1601 && res[2] === 3195 && res[3] === 1598); + assert.equal(res[0], 3198); + assert.equal(res[1], 1601); + assert.equal(res[2], 3195); + assert.equal(res[3], 1598); } }; -} - -export async function mol_bench_owned() { - let run, destroy; - - const destroy_owned = $.effect_root(() => { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(0); - destroy(); - } - - ({ run, destroy } = setup()); - }); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 1e4; i++) { - run(i); - } - }); - - // @ts-ignore - destroy(); - destroy_owned(); - - return { - benchmark: 'mol_bench_owned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} - -export async function mol_bench_unowned() { - // Do 10 loops to warm up JIT - for (let i = 0; i < 10; i++) { - const { run, destroy } = setup(); - run(0); - destroy(); - } - - const { run, destroy } = setup(); - - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 1e4; i++) { - run(i); - } - }); - - destroy(); - - return { - benchmark: 'mol_bench_unowned', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; -} +}; diff --git a/benchmarking/benchmarks/reactivity/tests/repeated_deps.bench.js b/benchmarking/benchmarks/reactivity/tests/repeated_deps.bench.js new file mode 100644 index 0000000000..a8fbcfdbd6 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/tests/repeated_deps.bench.js @@ -0,0 +1,35 @@ +import assert from 'node:assert'; +import * as $ from 'svelte/internal/client'; + +const ARRAY_SIZE = 1000; + +export default () => { + const signals = Array.from({ length: ARRAY_SIZE }, (_, i) => $.state(i)); + const order = $.state(0); + + // break skipped_deps fast path by changing order of reads + const total = $.derived(() => { + const ord = $.get(order); + let sum = 0; + for (let i = 0; i < ARRAY_SIZE; i++) { + sum += /** @type {number} */ ($.get(signals[(i + ord) % ARRAY_SIZE])); + } + return sum; + }); + + const destroy = $.effect_root(() => { + $.render_effect(() => { + $.get(total); + }); + }); + + return { + destroy, + run() { + for (let i = 0; i < 5; i++) { + $.flush(() => $.set(order, i)); + assert.equal($.get(total), (ARRAY_SIZE * (ARRAY_SIZE - 1)) / 2); // sum of 0..999 + } + } + }; +}; diff --git a/benchmarking/benchmarks/reactivity/util.js b/benchmarking/benchmarks/reactivity/util.js new file mode 100644 index 0000000000..da5e5c51f5 --- /dev/null +++ b/benchmarking/benchmarks/reactivity/util.js @@ -0,0 +1,71 @@ +import * as $ from 'svelte/internal/client'; +import { fastest_test } from '../../utils.js'; + +export function busy() { + let a = 0; + for (let i = 0; i < 1_00; i++) { + a++; + } +} + +/** + * + * @param {string} label + * @param {() => { run: (i?: number) => void, destroy: () => void }} setup + */ +export function create_test(label, setup) { + return { + unowned: { + label: `${label}_unowned`, + fn: async () => { + // Do 10 loops to warm up JIT + for (let i = 0; i < 10; i++) { + const { run, destroy } = setup(); + run(0); + destroy(); + } + + const { run, destroy } = setup(); + + const result = await fastest_test(10, () => { + for (let i = 0; i < 1000; i++) { + run(i); + } + }); + + destroy(); + + return result; + } + }, + owned: { + label: `${label}_owned`, + fn: async () => { + let run, destroy; + + const destroy_owned = $.effect_root(() => { + // Do 10 loops to warm up JIT + for (let i = 0; i < 10; i++) { + const { run, destroy } = setup(); + run(0); + destroy(); + } + + ({ run, destroy } = setup()); + }); + + const result = await fastest_test(10, () => { + for (let i = 0; i < 1000; i++) { + run(i); + } + }); + + // @ts-ignore + destroy(); + destroy_owned(); + + return result; + } + } + }; +} diff --git a/benchmarking/benchmarks/ssr/wrapper/wrapper_bench.js b/benchmarking/benchmarks/ssr/wrapper/wrapper_bench.js index ba0457b80e..9a8dda617d 100644 --- a/benchmarking/benchmarks/ssr/wrapper/wrapper_bench.js +++ b/benchmarking/benchmarks/ssr/wrapper/wrapper_bench.js @@ -1,13 +1,16 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { render } from 'svelte/server'; -import { fastest_test, read_file, write } from '../../../utils.js'; +import { fastest_test } from '../../../utils.js'; import { compile } from 'svelte/compiler'; const dir = `${process.cwd()}/benchmarking/benchmarks/ssr/wrapper`; async function compile_svelte() { - const output = compile(read_file(`${dir}/App.svelte`), { + const output = compile(read(`${dir}/App.svelte`), { generate: 'server' }); + write(`${dir}/output/App.js`, output.js.code); const module = await import(`${dir}/output/App.js`); @@ -15,22 +18,39 @@ async function compile_svelte() { return module.default; } -export async function wrapper_bench() { - const App = await compile_svelte(); - // Do 3 loops to warm up JIT - for (let i = 0; i < 3; i++) { - render(App); - } +export const wrapper_bench = { + label: 'wrapper_bench', + fn: async () => { + const App = await compile_svelte(); - const { timing } = await fastest_test(10, () => { - for (let i = 0; i < 100; i++) { + // Do 3 loops to warm up JIT + for (let i = 0; i < 3; i++) { render(App); } - }); - return { - benchmark: 'wrapper_bench', - time: timing.time.toFixed(2), - gc_time: timing.gc_time.toFixed(2) - }; + return await fastest_test(10, () => { + for (let i = 0; i < 100; i++) { + render(App); + } + }); + } +}; + +/** + * @param {string} file + */ +function read(file) { + return fs.readFileSync(file, 'utf-8').replace(/\r\n/g, '\n'); +} + +/** + * @param {string} file + * @param {string} contents + */ +function write(file, contents) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } catch {} + + fs.writeFileSync(file, contents); } diff --git a/benchmarking/compare/generate-report.js b/benchmarking/compare/generate-report.js new file mode 100644 index 0000000000..a61f58909b --- /dev/null +++ b/benchmarking/compare/generate-report.js @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export function generate_report(outdir) { + const result_files = fs + .readdirSync(outdir) + .filter((file) => file.endsWith('.json')) + .sort((a, b) => a.localeCompare(b)); + + const branches = result_files.map((file) => file.slice(0, -5)); + const results = result_files.map((file) => + JSON.parse(fs.readFileSync(`${outdir}/${file}`, 'utf-8')) + ); + + if (results.length === 0) { + console.error(`No result files found in ${outdir}`); + process.exit(1); + } + + const report_file = path.join(outdir, 'report.txt'); + + fs.writeFileSync(report_file, ''); + + const write = (str) => { + fs.appendFileSync(report_file, str + '\n'); + console.log(str); + }; + + for (let i = 0; i < branches.length; i += 1) { + write(`${char(i)}: ${branches[i]}`); + } + + write(''); + + for (let i = 0; i < results[0].length; i += 1) { + write(`${results[0][i].benchmark}`); + + for (const metric of ['time', 'gc_time']) { + const times = results.map((result) => +result[i][metric]); + let min = Infinity; + let max = -Infinity; + let min_index = -1; + + for (let b = 0; b < times.length; b += 1) { + const time = times[b]; + + if (time < min) { + min = time; + min_index = b; + } + + if (time > max) { + max = time; + } + } + + if (min !== 0) { + write(` ${metric}: fastest is ${char(min_index)} (${branches[min_index]})`); + times.forEach((time, b) => { + const SIZE = 20; + const n = Math.round(SIZE * (time / max)); + + write(` ${char(b)}: ${'◼'.repeat(n)}${' '.repeat(SIZE - n)} ${time.toFixed(2)}ms`); + }); + } + } + + write(''); + } +} + +function char(i) { + return String.fromCharCode(97 + i); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const outdir = path.resolve(process.argv[1], '../.results'); + + generate_report(outdir); +} diff --git a/benchmarking/compare/index.js b/benchmarking/compare/index.js index a5fc6d10a9..9064ee7da9 100644 --- a/benchmarking/compare/index.js +++ b/benchmarking/compare/index.js @@ -2,7 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { execSync, fork } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { benchmarks } from '../benchmarks.js'; +import { safe } from '../utils.js'; +import { generate_report } from './generate-report.js'; // if (execSync('git status --porcelain').toString().trim()) { // console.error('Working directory is not clean'); @@ -13,39 +14,61 @@ const filename = fileURLToPath(import.meta.url); const runner = path.resolve(filename, '../runner.js'); const outdir = path.resolve(filename, '../.results'); -if (fs.existsSync(outdir)) fs.rmSync(outdir, { recursive: true }); -fs.mkdirSync(outdir); +fs.mkdirSync(outdir, { recursive: true }); -const branches = []; +const requested_branches = []; + +let PROFILE_DIR = path.resolve(filename, '../.profiles'); +fs.mkdirSync(PROFILE_DIR, { recursive: true }); for (const arg of process.argv.slice(2)) { if (arg.startsWith('--')) continue; if (arg === filename) continue; - branches.push(arg); + requested_branches.push(arg); } -if (branches.length === 0) { - branches.push( +if (requested_branches.length === 0) { + requested_branches.push( execSync('git symbolic-ref --short -q HEAD || git rev-parse --short HEAD').toString().trim() ); } -if (branches.length === 1) { - branches.push('main'); +const original_ref = execSync('git symbolic-ref --short -q HEAD || git rev-parse --short HEAD') + .toString() + .trim(); + +if ( + requested_branches.length === 1 && + !requested_branches.includes('main') && + !fs.existsSync(`${outdir}/main.json`) +) { + requested_branches.push('main'); } process.on('exit', () => { - execSync(`git checkout ${branches[0]}`); + execSync(`git checkout ${original_ref}`); }); -for (const branch of branches) { +for (const branch of requested_branches) { console.group(`Benchmarking ${branch}`); + const branch_profile_dir = `${PROFILE_DIR}/${safe(branch)}`; + if (fs.existsSync(branch_profile_dir)) + fs.rmSync(branch_profile_dir, { recursive: true, force: true }); + + const branch_result_file = `${outdir}/${branch}.json`; + if (fs.existsSync(branch_result_file)) fs.rmSync(branch_result_file, { force: true }); + execSync(`git checkout ${branch}`); await new Promise((fulfil, reject) => { - const child = fork(runner); + const child = fork(runner, [], { + env: { + ...process.env, + BENCH_PROFILE_DIR: branch_profile_dir + } + }); child.on('message', (results) => { fs.writeFileSync(`${outdir}/${branch}.json`, JSON.stringify(results, null, ' ')); @@ -58,33 +81,8 @@ for (const branch of branches) { console.groupEnd(); } -const results = branches.map((branch) => { - return JSON.parse(fs.readFileSync(`${outdir}/${branch}.json`, 'utf-8')); -}); - -for (let i = 0; i < results[0].length; i += 1) { - console.group(`${results[0][i].benchmark}`); - - for (const metric of ['time', 'gc_time']) { - const times = results.map((result) => +result[i][metric]); - let min = Infinity; - let min_index = -1; - - for (let b = 0; b < times.length; b += 1) { - if (times[b] < min) { - min = times[b]; - min_index = b; - } - } - - if (min !== 0) { - console.group(`${metric}: fastest is ${branches[min_index]}`); - times.forEach((time, b) => { - console.log(`${branches[b]}: ${time.toFixed(2)}ms (${((time / min) * 100).toFixed(2)}%)`); - }); - console.groupEnd(); - } - } - - console.groupEnd(); +if (PROFILE_DIR !== null) { + console.log(`\nCPU profiles written to ${PROFILE_DIR}`); } + +generate_report(outdir); diff --git a/benchmarking/compare/profile-diff.mjs b/benchmarking/compare/profile-diff.mjs new file mode 100644 index 0000000000..c6a9061ab2 --- /dev/null +++ b/benchmarking/compare/profile-diff.mjs @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const [benchmark, baseBranch = 'main', candidateBranch] = process.argv.slice(2); + +if (!benchmark || !candidateBranch) { + console.error( + 'Usage: node benchmarking/compare/profile-diff.mjs ' + ); + process.exit(1); +} + +const root = path.resolve('benchmarking/compare/.profiles'); + +function safe(name) { + return name.replace(/[^a-z0-9._-]+/gi, '_'); +} + +function read_profile(branch, bench) { + const file = path.join(root, safe(branch), `${bench}.cpuprofile`); + const profile = JSON.parse(fs.readFileSync(file, 'utf8')); + const nodes = Array.isArray(profile.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile.samples) ? profile.samples : []; + + const id_to_node = new Map(nodes.map((node) => [node.id, node])); + const self_counts = new Map(); + + for (const sample of samples) { + if (typeof sample !== 'number') continue; + self_counts.set(sample, (self_counts.get(sample) ?? 0) + 1); + } + + const total = samples.length || 1; + const by_fn = new Map(); + + for (const [id, count] of self_counts) { + const node = id_to_node.get(id); + if (!node || typeof node !== 'object') continue; + + const frame = node.callFrame ?? {}; + const function_name = frame.functionName || '(anonymous)'; + const url = frame.url || ''; + const line = typeof frame.lineNumber === 'number' ? frame.lineNumber + 1 : 0; + + const label = url + ? `${function_name} @ ${url.replace(/^.*packages\//, 'packages/')}:${line}` + : function_name; + + by_fn.set(label, (by_fn.get(label) ?? 0) + count); + } + + return { by_fn, total }; +} + +const base = read_profile(baseBranch, benchmark); +const candidate = read_profile(candidateBranch, benchmark); + +const keys = new Set([...base.by_fn.keys(), ...candidate.by_fn.keys()]); +const rows = [...keys] + .map((key) => { + const base_pct = ((base.by_fn.get(key) ?? 0) * 100) / base.total; + const candidate_pct = ((candidate.by_fn.get(key) ?? 0) * 100) / candidate.total; + return { + key, + delta: candidate_pct - base_pct, + base_pct, + candidate_pct + }; + }) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + .slice(0, 20); + +console.log(`Benchmark: ${benchmark}`); +console.log(`Base: ${baseBranch}`); +console.log(`Candidate: ${candidateBranch}`); +console.log(''); + +for (const row of rows) { + const sign = row.delta >= 0 ? '+' : ''; + console.log( + `${sign}${row.delta.toFixed(2).padStart(6)}pp candidate ${row.candidate_pct.toFixed(2).padStart(6)}% base ${row.base_pct.toFixed(2).padStart(6)}% ${row.key}` + ); +} diff --git a/benchmarking/compare/runner.js b/benchmarking/compare/runner.js index 6fa58e2bac..31a8e6b44b 100644 --- a/benchmarking/compare/runner.js +++ b/benchmarking/compare/runner.js @@ -1,10 +1,18 @@ -import { benchmarks } from '../benchmarks.js'; +import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js'; +import { with_cpu_profile } from '../utils.js'; const results = []; -for (const benchmark of benchmarks) { - const result = await benchmark(); - console.error(result.benchmark); - results.push(result); +const PROFILE_DIR = process.env.BENCH_PROFILE_DIR; + +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} `); + results.push({ + benchmark: benchmark.label, + ...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn())) + }); + process.stderr.write('\x1b[2K\r'); } process.send(results); diff --git a/benchmarking/run.js b/benchmarking/run.js index bd96b9c2dc..80e40a5ff1 100644 --- a/benchmarking/run.js +++ b/benchmarking/run.js @@ -1,55 +1,94 @@ import * as $ from '../packages/svelte/src/internal/client/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'; -let total_time = 0; -let total_gc_time = 0; +// 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, name: 'reactivity benchmarks' }, - { benchmarks: ssr_benchmarks, name: 'server-side rendering 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); + +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; + +let total_time = 0; +let total_gc_time = 0; -// eslint-disable-next-line no-console -console.log('\x1b[1m', '-- Benchmarking Started --', '\x1b[0m'); $.push({}, true); + try { for (const { benchmarks, name } of suites) { let suite_time = 0; let suite_gc_time = 0; - // eslint-disable-next-line no-console + 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 benchmark(); - // eslint-disable-next-line no-console - console.log(results); - total_time += Number(results.time); - total_gc_time += Number(results.gc_time); - suite_time += Number(results.time); - suite_gc_time += Number(results.gc_time); + const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); + 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(`\nFinished ${name}.\n`); + 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)); + } - // eslint-disable-next-line no-console - console.log({ - suite_time: suite_time.toFixed(2), - suite_gc_time: suite_gc_time.toFixed(2) - }); + if (PROFILE_DIR !== null) { + console.log(`\nCPU profiles written to ${PROFILE_DIR}`); } } catch (e) { - // eslint-disable-next-line no-console - console.log('\x1b[1m', '\n-- Benchmarking Failed --\n', '\x1b[0m'); // eslint-disable-next-line no-console console.error(e); process.exit(1); } + $.pop(); -// eslint-disable-next-line no-console -console.log('\x1b[1m', '\n-- Benchmarking Complete --\n', '\x1b[0m'); -// eslint-disable-next-line no-console -console.log({ - total_time: total_time.toFixed(2), - total_gc_time: total_gc_time.toFixed(2) -}); + +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]) +); diff --git a/benchmarking/utils.js b/benchmarking/utils.js index 684d2ee02b..2f4be3c567 100644 --- a/benchmarking/utils.js +++ b/benchmarking/utils.js @@ -1,119 +1,333 @@ import { performance, PerformanceObserver } from 'node:perf_hooks'; +import fs from 'node:fs'; +import path from 'node:path'; +import inspector from 'node:inspector/promises'; import v8 from 'v8-natives'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; // Credit to https://github.com/milomg/js-reactivity-benchmark for the logic for timing + GC tracking. -class GarbageTrack { - track_id = 0; - observer = new PerformanceObserver((list) => this.perf_entries.push(...list.getEntries())); - perf_entries = []; - periods = []; +async function track(fn) { + v8.collectGarbage(); - watch(fn) { - this.track_id++; - const start = performance.now(); - const result = fn(); - const end = performance.now(); - this.periods.push({ track_id: this.track_id, start, end }); + /** @type {PerformanceEntry[]} */ + const entries = []; - return { result, track_id: this.track_id }; - } + const observer = new PerformanceObserver((list) => entries.push(...list.getEntries())); + observer.observe({ entryTypes: ['gc'] }); - /** - * @param {number} track_id - */ - async gcDuration(track_id) { - await promise_delay(10); + const start = performance.now(); + fn(); + const end = performance.now(); - const period = this.periods.find((period) => period.track_id === track_id); - if (!period) { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - return Promise.reject('no period found'); - } + await new Promise((f) => setTimeout(f, 10)); - const entries = this.perf_entries.filter( - (e) => e.startTime >= period.start && e.startTime < period.end - ); - return entries.reduce((t, e) => e.duration + t, 0); - } + const gc_time = entries + .filter((e) => e.startTime >= start && e.startTime < end) + .reduce((t, e) => e.duration + t, 0); - destroy() { - this.observer.disconnect(); - } + observer.disconnect(); - constructor() { - this.observer.observe({ entryTypes: ['gc'] }); + return { time: end - start, gc_time }; +} + +/** + * @param {number} times + * @param {() => void} fn + */ +export async function fastest_test(times, fn) { + /** @type {Array<{ time: number, gc_time: number }>} */ + const results = []; + + for (let i = 0; i < times; i++) { + results.push(await track(fn)); } + + return results.reduce((a, b) => (a.time < b.time ? a : b)); } -function promise_delay(timeout = 0) { - return new Promise((resolve) => setTimeout(resolve, timeout)); +export function safe(name) { + return name.replace(/[^a-z0-9._-]+/gi, '_'); } /** - * @param {{ (): void; (): any; }} fn + * @param {unknown} value */ -function run_timed(fn) { - const start = performance.now(); - const result = fn(); - const time = performance.now() - start; - return { result, time }; +function format_markdown_value(value) { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) return value.map((item) => String(item)).join(', '); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); } /** - * @param {() => void} fn + * @param {string} text */ -async function run_tracked(fn) { - v8.collectGarbage(); - const gc_track = new GarbageTrack(); - const { result: wrappedResult, track_id } = gc_track.watch(() => run_timed(fn)); - const gc_time = await gc_track.gcDuration(track_id); - const { result, time } = wrappedResult; - gc_track.destroy(); - return { result, timing: { time, gc_time } }; +function escape_markdown_cell(text) { + return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); } /** - * @param {number} times - * @param {() => void} fn + * @param {string} value */ -export async function fastest_test(times, fn) { - const results = []; - for (let i = 0; i < times; i++) { - const run = await run_tracked(fn); - results.push(run); +function normalize_profile_url(value) { + if (!value) return ''; + + if (value.startsWith('file://')) { + try { + const pathname = decodeURIComponent(new URL(value).pathname); + const relative = path.relative(process.cwd(), pathname); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative; + return pathname; + } catch { + return value; + } } - const fastest = results.reduce((a, b) => (a.timing.time < b.timing.time ? a : b)); - return fastest; + if (path.isAbsolute(value)) { + const relative = path.relative(process.cwd(), value); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative; + } + + return value; } /** - * @param {boolean} a + * @param {string} function_name */ -export function assert(a) { - if (!a) { - throw new Error('Assertion failed'); - } +function is_special_runtime_node(function_name) { + return function_name === '(idle)' || function_name === '(garbage collector)'; } /** - * @param {string} file + * @param {string} normalized_url */ -export function read_file(file) { - return fs.readFileSync(file, 'utf-8').replace(/\r\n/g, '\n'); +function is_svelte_source_url(normalized_url) { + return normalized_url.startsWith('packages/svelte/'); } /** - * @param {string} file - * @param {string} contents + * @param {Record} profile */ -export function write(file, contents) { - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - } catch {} +function profile_to_markdown(profile) { + /** @type {string[]} */ + const lines = ['# CPU profile']; + + const metadata = Object.entries(profile).filter( + ([key]) => key !== 'nodes' && key !== 'samples' && key !== 'timeDeltas' + ); + + if (metadata.length > 0) { + lines.push('', '## Metadata', '| Field | Value |', '| --- | --- |'); + for (const [key, value] of metadata) { + lines.push( + `| ${escape_markdown_cell(key)} | ${escape_markdown_cell(format_markdown_value(value))} |` + ); + } + } + + const nodes = Array.isArray(profile.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile.samples) ? profile.samples : []; + const timeDeltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : []; + /** @type {Set} */ + const included_node_ids = new Set(); + + if (nodes.length > 0) { + /** @type {Map>} */ + const nodes_by_id = new Map(); - fs.writeFileSync(file, contents); + /** @type {Map} */ + const parent_by_id = new Map(); + + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + if (typeof node.id !== 'number') continue; + nodes_by_id.set(node.id, node); + const children = Array.isArray(node.children) ? node.children : []; + for (const child of children) { + if (typeof child === 'number') { + parent_by_id.set(child, node.id); + } + } + + const callFrame = + node.callFrame && typeof node.callFrame === 'object' + ? /** @type {Record} */ (node.callFrame) + : /** @type {Record} */ ({}); + const functionName = + typeof callFrame.functionName === 'string' ? callFrame.functionName : '(anonymous)'; + const normalizedUrl = + typeof callFrame.url === 'string' ? normalize_profile_url(callFrame.url) : ''; + + if (is_special_runtime_node(functionName) || is_svelte_source_url(normalizedUrl)) { + included_node_ids.add(node.id); + } + } + + /** @type {Map} */ + const self_sample_count = new Map(); + for (const sample of samples) { + if (typeof sample !== 'number') continue; + if (!included_node_ids.has(sample)) continue; + self_sample_count.set(sample, (self_sample_count.get(sample) ?? 0) + 1); + } + + /** @type {Map} */ + const inclusive_sample_count = new Map(); + /** @type {Set} */ + const stack = new Set(); + + /** @param {number} node_id */ + const get_inclusive_count = (node_id) => { + const cached = inclusive_sample_count.get(node_id); + if (cached !== undefined) return cached; + if (stack.has(node_id)) return self_sample_count.get(node_id) ?? 0; + + stack.add(node_id); + const node = nodes_by_id.get(node_id); + const children = node && Array.isArray(node.children) ? node.children : []; + let total = self_sample_count.get(node_id) ?? 0; + + for (const child of children) { + if (typeof child !== 'number') continue; + total += get_inclusive_count(child); + } + + stack.delete(node_id); + inclusive_sample_count.set(node_id, total); + return total; + }; + + for (const node_id of included_node_ids) { + get_inclusive_count(node_id); + } + + const total_samples = [...self_sample_count.values()].reduce((sum, count) => sum + count, 0); + if (total_samples > 0) { + const hotspot_rows = [...included_node_ids] + .map((id) => nodes_by_id.get(id)) + .filter((node) => !!node) + .map((node) => { + const id = /** @type {number} */ (node.id); + const callFrame = + node.callFrame && typeof node.callFrame === 'object' + ? /** @type {Record} */ (node.callFrame) + : /** @type {Record} */ ({}); + const functionName = + typeof callFrame.functionName === 'string' && callFrame.functionName.length > 0 + ? callFrame.functionName + : '(anonymous)'; + const selfCount = self_sample_count.get(id) ?? 0; + const inclusiveCount = inclusive_sample_count.get(id) ?? selfCount; + return { id, functionName, selfCount, inclusiveCount }; + }) + .filter((row) => row.selfCount > 0 || row.inclusiveCount > 0) + .sort( + (a, b) => + b.inclusiveCount - a.inclusiveCount || + b.selfCount - a.selfCount || + String(a.id).localeCompare(String(b.id)) + ) + .slice(0, 25); + + if (hotspot_rows.length > 0) { + lines.push( + '', + '## Top hotspots', + '| Rank | Node ID | Function | Self samples | Self % | Inclusive samples | Inclusive % |', + '| --- | --- | --- | --- | --- | --- | --- |' + ); + + for (let i = 0; i < hotspot_rows.length; i += 1) { + const row = hotspot_rows[i]; + const selfPct = ((row.selfCount / total_samples) * 100).toFixed(2); + const inclusivePct = ((row.inclusiveCount / total_samples) * 100).toFixed(2); + lines.push( + `| ${i + 1} | ${row.id} | ${escape_markdown_cell(row.functionName)} | ${row.selfCount} | ${selfPct}% | ${row.inclusiveCount} | ${inclusivePct}% |` + ); + } + } + } + + lines.push( + '', + '## Nodes', + '| ID | Parent ID | Function | URL | Line | Column | Hit count | Children | Deopt reason |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- |' + ); + + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + if (typeof node.id !== 'number') continue; + if (!included_node_ids.has(node.id)) continue; + + const callFrame = + node.callFrame && typeof node.callFrame === 'object' + ? /** @type {Record} */ (node.callFrame) + : /** @type {Record} */ ({}); + + const id = typeof node.id === 'number' ? node.id : ''; + const parentId = + typeof id === 'number' && included_node_ids.has(parent_by_id.get(id) ?? NaN) + ? parent_by_id.get(id) ?? '' + : ''; + const functionName = + typeof callFrame.functionName === 'string' && callFrame.functionName.length > 0 + ? callFrame.functionName + : '(anonymous)'; + const url = typeof callFrame.url === 'string' ? normalize_profile_url(callFrame.url) : ''; + const lineNumber = + typeof callFrame.lineNumber === 'number' ? String(callFrame.lineNumber + 1) : ''; + const columnNumber = + typeof callFrame.columnNumber === 'number' ? String(callFrame.columnNumber + 1) : ''; + const hitCount = typeof node.hitCount === 'number' ? node.hitCount : ''; + const children = Array.isArray(node.children) + ? node.children + .filter((child) => typeof child === 'number' && included_node_ids.has(child)) + .join(', ') + : ''; + const deoptReason = typeof node.deoptReason === 'string' ? node.deoptReason : ''; + + lines.push( + `| ${escape_markdown_cell(String(id))} | ${escape_markdown_cell(String(parentId))} | ${escape_markdown_cell(functionName)} | ${escape_markdown_cell(url)} | ${escape_markdown_cell(lineNumber)} | ${escape_markdown_cell(columnNumber)} | ${escape_markdown_cell(String(hitCount))} | ${escape_markdown_cell(children)} | ${escape_markdown_cell(deoptReason)} |` + ); + } + } + + return `${lines.join('\n')}\n`; +} + +/** + * @template T + * @param {string | null} profile_dir + * @param {string} profile_name + * @param {() => T | Promise} fn + * @returns {Promise} + */ +export async function with_cpu_profile(profile_dir, profile_name, fn) { + if (profile_dir === null) { + return await fn(); + } + + fs.mkdirSync(profile_dir, { recursive: true }); + + const session = new inspector.Session(); + session.connect(); + + await session.post('Profiler.enable'); + await session.post('Profiler.start'); + + try { + return await fn(); + } finally { + const { profile } = /** @type {{ profile: object }} */ (await session.post('Profiler.stop')); + const safe_profile_name = safe(profile_name); + const profile_file = path.join(profile_dir, `${safe_profile_name}.cpuprofile`); + const markdown_file = path.join(profile_dir, `${safe_profile_name}.md`); + fs.writeFileSync(profile_file, JSON.stringify(profile)); + fs.writeFileSync( + markdown_file, + profile_to_markdown(/** @type {Record} */ (profile)) + ); + session.disconnect(); + } } diff --git a/documentation/docs/01-introduction/02-getting-started.md b/documentation/docs/01-introduction/02-getting-started.md index e035e6d6df..ecb1055443 100644 --- a/documentation/docs/01-introduction/02-getting-started.md +++ b/documentation/docs/01-introduction/02-getting-started.md @@ -2,9 +2,9 @@ title: Getting started --- -We recommend using [SvelteKit](../kit), the official application framework from the Svelte team powered by [Vite](https://vite.dev/): +We recommend using [SvelteKit](../kit), which lets you [build almost anything](../kit/project-types). It's the official application framework from the Svelte team and powered by [Vite](https://vite.dev/). Create a new project with: -```bash +```sh npx sv create myapp cd myapp npm install @@ -15,15 +15,18 @@ Don't worry if you don't know Svelte yet! You can ignore all the nice features S ## Alternatives to SvelteKit -You can also use Svelte directly with Vite by running `npm create vite@latest` and selecting the `svelte` option. With this, `npm run build` will generate HTML, JS and CSS files inside the `dist` directory using [vite-plugin-svelte](https://github.com/sveltejs/vite-plugin-svelte). In most cases, you will probably need to [choose a routing library](faq#Is-there-a-router) as well. +You can also use Svelte directly with Vite via [vite-plugin-svelte](https://github.com/sveltejs/vite-plugin-svelte) by running `npm create vite@latest` and selecting the `svelte` option (or, if working with an existing project, adding the plugin to your `vite.config.js` file). With this, `npm run build` will generate HTML, JS, and CSS files inside the `dist` directory. In most cases, you will probably need to [choose a routing library](/packages#routing) as well. -There are also plugins for [Rollup](https://github.com/sveltejs/rollup-plugin-svelte), [Webpack](https://github.com/sveltejs/svelte-loader) [and a few others](https://sveltesociety.dev/packages?category=build-plugins), but we recommend Vite. +>[!NOTE] Vite is often used in standalone mode to build [single page apps (SPAs)](../kit/glossary#SPA), which you can also [build with SvelteKit](../kit/single-page-apps). + +There are also [plugins for other bundlers](/packages#bundler-plugins), but we recommend Vite. ## Editor tooling -The Svelte team maintains a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode), and there are integrations with various other [editors](https://sveltesociety.dev/resources#editor-support) and tools as well. +The Svelte team maintains a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode), and there are integrations with various other [editors](https://sveltesociety.dev/collection/editor-support-c85c080efc292a34) and tools as well. + +You can also check your code from the command line using [`npx sv check`](https://svelte.dev/docs/cli/sv-check). -You can also check your code from the command line using [sv check](https://github.com/sveltejs/cli). ## Getting help diff --git a/documentation/docs/01-introduction/04-svelte-js-files.md b/documentation/docs/01-introduction/04-svelte-js-files.md index 0e05484299..1d3e3dd61a 100644 --- a/documentation/docs/01-introduction/04-svelte-js-files.md +++ b/documentation/docs/01-introduction/04-svelte-js-files.md @@ -4,7 +4,7 @@ title: .svelte.js and .svelte.ts files Besides `.svelte` files, Svelte also operates on `.svelte.js` and `.svelte.ts` files. -These behave like any other `.js` or `.ts` module, except that you can use runes. This is useful for creating reusable reactive logic, or sharing reactive state across your app. +These behave like any other `.js` or `.ts` module, except that you can use runes. This is useful for creating reusable reactive logic, or sharing reactive state across your app (though note that you [cannot export reassigned state]($state#Passing-state-across-modules)). > [!LEGACY] > This is a concept that didn't exist prior to Svelte 5 diff --git a/documentation/docs/01-introduction/xx-props.md b/documentation/docs/01-introduction/xx-props.md deleted file mode 100644 index cad854d878..0000000000 --- a/documentation/docs/01-introduction/xx-props.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: Public API of a component ---- - -### Public API of a component - -Svelte uses the `$props` rune to declare _properties_ or _props_, which means describing the public interface of the component which becomes accessible to consumers of the component. - -> [!NOTE] `$props` is one of several runes, which are special hints for Svelte's compiler to make things reactive. - -```svelte - -``` - -You can specify a fallback value for a prop. It will be used if the component's consumer doesn't specify the prop on the component when instantiating the component, or if the passed value is `undefined` at some point. - -```svelte - -``` - -To get all properties, use rest syntax: - -```svelte - -``` - -You can use reserved words as prop names. - -```svelte - -``` - -If you're using TypeScript, you can declare the prop types: - -```svelte - -``` - -If you're using JavaScript, you can declare the prop types using JSDoc: - -```svelte - -``` - -If you export a `const`, `class` or `function`, it is readonly from outside the component. - -```svelte - -``` - -Readonly props can be accessed as properties on the element, tied to the component using [`bind:this` syntax](bindings#bind:this). - -### Reactive variables - -To change component state and trigger a re-render, just assign to a locally declared variable that was declared using the `$state` rune. - -Update expressions (`count += 1`) and property assignments (`obj.x = y`) have the same effect. - -```svelte - -``` - -Svelte's ` -``` - -If you'd like to react to changes to a prop, use the `$derived` or `$effect` runes instead. - -```svelte - -``` - -For more information on reactivity, read the documentation around runes. diff --git a/documentation/docs/01-introduction/xx-reactivity-fundamentals.md b/documentation/docs/01-introduction/xx-reactivity-fundamentals.md deleted file mode 100644 index d5e67ada71..0000000000 --- a/documentation/docs/01-introduction/xx-reactivity-fundamentals.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -title: Reactivity fundamentals ---- - -Reactivity is at the heart of interactive UIs. When you click a button, you expect some kind of response. It's your job as a developer to make this happen. It's Svelte's job to make your job as intuitive as possible, by providing a good API to express reactive systems. - -## Runes - -Svelte 5 uses _runes_, a powerful set of primitives for controlling reactivity inside your Svelte components and inside `.svelte.js` and `.svelte.ts` modules. - -Runes are function-like symbols that provide instructions to the Svelte compiler. You don't need to import them from anywhere — when you use Svelte, they're part of the language. - -The following sections introduce the most important runes for declare state, derived state and side effects at a high level. For more details refer to the later sections on [state](state) and [side effects](side-effects). - -## `$state` - -Reactive state is declared with the `$state` rune: - -```svelte - - - -``` - -You can also use `$state` in class fields (whether public or private): - -```js -// @errors: 7006 2554 -class Todo { - done = $state(false); - text = $state(); - - constructor(text) { - this.text = text; - } -} -``` - -> [!LEGACY] -> In Svelte 4, state was implicitly reactive if the variable was declared at the top level -> -> ```svelte -> -> -> -> ``` - -## `$derived` - -Derived state is declared with the `$derived` rune: - -```svelte - - - - -

{count} doubled is {doubled}

-``` - -The expression inside `$derived(...)` should be free of side-effects. Svelte will disallow state changes (e.g. `count++`) inside derived expressions. - -As with `$state`, you can mark class fields as `$derived`. - -> [!LEGACY] -> In Svelte 4, you could use reactive statements for this. -> -> ```svelte -> -> -> -> ->

{count} doubled is {doubled}

-> ``` -> -> This only worked at the top level of a component. - -## `$effect` - -To run _side-effects_ when the component is mounted to the DOM, and when values change, we can use the `$effect` rune ([demo](/playground/untitled#H4sIAAAAAAAAE31T24rbMBD9lUG7kAQ2sbdlX7xOYNk_aB_rQhRpbAsU2UiTW0P-vbrYubSlYGzmzMzROTPymdVKo2PFjzMzfIusYB99z14YnfoQuD1qQh-7bmdFQEonrOppVZmKNBI49QthCc-OOOH0LZ-9jxnR6c7eUpOnuv6KeT5JFdcqbvbcBcgDz1jXKGg6ncFyBedYR6IzLrAZwiN5vtSxaJA-EzadfJEjKw11C6GR22-BLH8B_wxdByWpvUYtqqal2XB6RVkG1CoHB6U1WJzbnYFDiwb3aGEdDa3Bm1oH12sQLTcNPp7r56m_00mHocSG97_zd7ICUXonA5fwKbPbkE2ZtMJGGVkEdctzQi4QzSwr9prnFYNk5hpmqVuqPQjNnfOJoMF22lUsrq_UfIN6lfSVyvQ7grB3X2mjMZYO3XO9w-U5iLx42qg29md3BP_ni5P4gy9ikTBlHxjLzAtPDlyYZmRdjAbGq7HprEQ7p64v4LU_guu0kvAkhBim3nMplWl8FreQD-CW20aZR0wq12t-KqDWeBywhvexKC3memmDwlHAv9q4Vo2ZK8KtK0CgX7u9J8wXbzdKv-nRnfF_2baTqlYoWUF2h5efl9-n0O6koAMAAA==)): - -```svelte - - - -``` - -The function passed to `$effect` will run when the component mounts, and will re-run after any changes to the values it reads that were declared with `$state` or `$derived` (including those passed in with `$props`). Re-runs are batched (i.e. changing `color` and `size` in the same moment won't cause two separate runs), and happen after any DOM updates have been applied. - -> [!LEGACY] -> In Svelte 4, you could use reactive statements for this. -> -> ```svelte -> -> -> -> ``` -> -> This only worked at the top level of a component. diff --git a/documentation/docs/02-runes/01-what-are-runes.md b/documentation/docs/02-runes/01-what-are-runes.md index dc163ebdf1..59c371eb49 100644 --- a/documentation/docs/02-runes/01-what-are-runes.md +++ b/documentation/docs/02-runes/01-what-are-runes.md @@ -2,7 +2,7 @@ title: What are runes? --- -> [!NOTE] **rune** /ro͞on/ _noun_ +> [!NOTE] **rune** /ruːn/ _noun_ > > A letter or mark used as a mystical or magic symbol. diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md index 77140dc690..b90c71366a 100644 --- a/documentation/docs/02-runes/02-$state.md +++ b/documentation/docs/02-runes/02-$state.md @@ -1,5 +1,6 @@ --- title: $state +tags: rune-state --- The `$state` rune allows you to create _reactive state_, which means that your UI _reacts_ when it changes. @@ -20,9 +21,7 @@ Unlike other frameworks you may have encountered, there is no API for interactin If `$state` is used with an array or a simple object, the result is a deeply reactive _state proxy_. [Proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) allow Svelte to run code when you read or write properties, including via methods like `array.push(...)`, triggering granular updates. -> [!NOTE] Classes like `Set` and `Map` will not be proxied, but Svelte provides reactive implementations for various built-ins like these that can be imported from [`svelte/reactivity`](./svelte-reactivity). - -State is proxified recursively until Svelte finds something other than an array or simple object. In a case like this... +State is proxified recursively until Svelte finds something other than an array or simple object (like a class or an object created with `Object.create`). In a case like this... ```js let todos = $state([ @@ -44,12 +43,7 @@ todos[0].done = !todos[0].done; If you push a new object to the array, it will also be proxified: ```js -// @filename: ambient.d.ts -declare global { - const todos: Array<{ done: boolean, text: string }> -} - -// @filename: index.js +let todos = [{ done: false, text: 'add more todos' }]; // ---cut--- todos.push({ done: false, @@ -57,7 +51,7 @@ todos.push({ }); ``` -> [!NOTE] When you update properties of proxies, the original object is _not_ mutated. +> [!NOTE] When you update properties of proxies, the original object is _not_ mutated. If you need to use your own proxy handlers in a state proxy, [you should wrap the object _after_ wrapping it in `$state`](https://svelte.dev/playground/hello-world?version=latest#H4sIAAAAAAAACpWR3WoDIRCFX2UqhWyIJL3erAulL9C7XnQLMe5ksbUqOpsfln33YuyGFNJC8UKdc2bOhw7Myk9kJXsJ0nttO9jcR5KEG9AWJDwHdzwxznbaYGTl68Do5JM_FRifuh-9X8Y9Gkq1rYx4q66cJbQUWcmqqIL2VDe2IYMEbvuOikBADi-GJDSkXG-phId0G-frye2DO2psQYDFQ0Ys8gQO350dUkEydEg82T0GOs0nsSG9g2IqgxACZueo2ZUlpdvoDC6N64qsg1QKY8T2bpZp8gpIfbCQ85Zn50Ud82HkeY83uDjspenxv3jXcSDyjPWf9L1vJf0GH666J-jLu1ery4dV257IWXBWGa0-xFDMQdTTn2ScxWKsn86ROsLwQxqrVR5QM84Ij8TKFD2-cUZSm4O2LSt30kQcvwCgCmfZnAIAAA==). Note that if you destructure a reactive value, the references are not reactive — as in normal JavaScript, they are evaluated at the point of destructuring: @@ -72,16 +66,15 @@ todos[0].done = !todos[0].done; ### Classes -You can also use `$state` in class fields (whether public or private): +Class instances are not proxied. Instead, you can use `$state` in class fields (whether public or private), or as the first assignment to a property immediately inside the `constructor`: ```js // @errors: 7006 2554 class Todo { done = $state(false); - text = $state(); constructor(text) { - this.text = text; + this.text = $state(text); } reset() { @@ -115,10 +108,9 @@ You can either use an inline function... // @errors: 7006 2554 class Todo { done = $state(false); - text = $state(); constructor(text) { - this.text = text; + this.text = $state(text); } +++reset = () => {+++ @@ -128,6 +120,10 @@ class Todo { } ``` +### Built-in classes + +Svelte provides reactive implementations of built-in classes like `Set`, `Map`, `Date` and `URL` that can be imported from [`svelte/reactivity`](svelte-reactivity). + ## `$state.raw` In cases where you don't want objects and arrays to be deeply reactive you can use `$state.raw`. @@ -152,6 +148,8 @@ person = { This can improve performance with large arrays and objects that you weren't planning to mutate anyway, since it avoids the cost of making them reactive. Note that raw state can _contain_ reactive state (for example, a raw array of reactive objects). +As with `$state`, you can declare class fields using `$state.raw`. + ## `$state.snapshot` To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`: @@ -169,6 +167,23 @@ To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snaps This is handy when you want to pass some state to an external library or API that doesn't expect a proxy, such as `structuredClone`. +If a value has a `toJSON` method, the snapshot will clone the value returned from `toJSON` instead of the original object. + +## `$state.eager` + +When state changes, it may not be reflected in the UI immediately if it is used by an `await` expression, because [updates are synchronized](await-expressions#Synchronized-updates). + +In some cases, you may want to update the UI as soon as the state changes. For example, you might want to update a navigation bar when the user clicks on a link, so that they get visual feedback while waiting for the new page to load. To do this, use `$state.eager(value)`: + +```svelte + +``` + +Use this feature sparingly, and only to provide feedback in response to user action — in general, allowing Svelte to coordinate updates will provide a better user experience. + ## Passing state into functions JavaScript is a _pass-by-value_ language — when you call a function, the arguments are the _values_ rather than the _variables_. In other words: @@ -255,3 +270,83 @@ console.log(total.value); // 7 ``` ...though if you find yourself writing code like that, consider using [classes](#Classes) instead. + +## Passing state across modules + +You can declare state in `.svelte.js` and `.svelte.ts` files, but you can only _export_ that state if it's not directly reassigned. In other words you can't do this: + +```js +/// file: state.svelte.js +export let count = $state(0); + +export function increment() { + count += 1; +} +``` + +That's because every reference to `count` is transformed by the Svelte compiler — the code above is roughly equivalent to this: + +```js +/// file: state.svelte.js (compiler output) +// @filename: index.ts +interface Signal { + value: T; +} + +interface Svelte { + state(value?: T): Signal; + get(source: Signal): T; + set(source: Signal, value: T): void; +} +declare const $: Svelte; +// ---cut--- +export let count = $.state(0); + +export function increment() { + $.set(count, $.get(count) + 1); +} +``` + +> [!NOTE] You can see the code Svelte generates by clicking the 'JS Output' tab in the [playground](/playground). + +Since the compiler only operates on one file at a time, if another file imports `count` Svelte doesn't know that it needs to wrap each reference in `$.get` and `$.set`: + +```js +// @filename: state.svelte.js +export let count = 0; + +// @filename: index.js +// ---cut--- +import { count } from './state.svelte.js'; + +console.log(typeof count); // 'object', not 'number' +``` + +This leaves you with two options for sharing state between modules — either don't reassign it... + +```js +// This is allowed — since we're updating +// `counter.count` rather than `counter`, +// Svelte doesn't wrap it in `$.state` +export const counter = $state({ + count: 0 +}); + +export function increment() { + counter.count += 1; +} +``` + +...or don't directly export it: + +```js +let count = $state(0); + +export function getCount() { + return count; +} + +export function increment() { + count += 1; +} +``` diff --git a/documentation/docs/02-runes/03-$derived.md b/documentation/docs/02-runes/03-$derived.md index 6b38f99746..f85ba90baa 100644 --- a/documentation/docs/02-runes/03-$derived.md +++ b/documentation/docs/02-runes/03-$derived.md @@ -1,5 +1,6 @@ --- title: $derived +tags: rune-derived --- Derived state is declared with the `$derived` rune: @@ -50,4 +51,96 @@ In essence, `$derived(expression)` is equivalent to `$derived.by(() => expressio Anything read synchronously inside the `$derived` expression (or `$derived.by` function body) is considered a _dependency_ of the derived state. When the state changes, the derived will be marked as _dirty_ and recalculated when it is next read. +In addition, if an expression contains an [`await`](await-expressions), Svelte transforms it such that any state _after_ the `await` is also tracked — in other words, in a case like this... + +```js +let a = Promise.resolve(1); +let b = 2; +// ---cut--- +let total = $derived(await a + b); +``` + +...both `a` and `b` are tracked, even though `b` is only read once `a` has resolved, after the initial execution. (This does not apply to `await` in functions that are called by the expression, only the expression itself.) + To exempt a piece of state from being treated as a dependency, use [`untrack`](svelte#untrack). + +## Overriding derived values + +Derived expressions are recalculated when their dependencies change, but you can temporarily override their values by reassigning them (unless they are declared with `const`). This can be useful for things like _optimistic UI_, where a value is derived from the 'source of truth' (such as data from your server) but you'd like to show immediate feedback to the user: + +```svelte + + + +``` + +> [!NOTE] Prior to Svelte 5.25, deriveds were read-only. + +## Deriveds and reactivity + +Unlike `$state`, which converts objects and arrays to [deeply reactive proxies]($state#Deep-state), `$derived` values are left as-is. For example, [in a case like this](/playground/untitled#H4sIAAAAAAAAE4VU22rjMBD9lUHd3aaQi9PdstS1A3t5XvpQ2Ic4D7I1iUUV2UjjNMX431eS7TRdSosxgjMzZ45mjt0yzffIYibvy0ojFJWqDKCQVBk2ZVup0LJ43TJ6rn2aBxw-FP2o67k9oCKP5dziW3hRaUJNjoYltjCyplWmM1JIIAn3FlL4ZIkTTtYez6jtj4w8WwyXv9GiIXiQxLVs9pfTMR7EuoSLIuLFbX7Z4930bZo_nBrD1bs834tlfvsBz9_SyX6PZXu9XaL4gOWn4sXjeyzftv4ZWfyxubpzxzg6LfD4MrooxELEosKCUPigQCMPKCZh0OtQE1iSxcsmdHuBvCiHZXALLXiN08EL3RRkaJ_kDVGle0HcSD5TPEeVtj67O4Nrg9aiSNtBY5oODJkrL5QsHtN2cgXp6nSJMWzpWWGasdlsGEMbzi5jPr5KFr0Ep7pdeM2-TCelCddIhDxAobi1jqF3cMaC1RKp64bAW9iFAmXGIHfd4wNXDabtOLN53w8W53VvJoZLh7xk4Rr3CoL-UNoLhWHrT1JQGcM17u96oES5K-kc2XOzkzqGCKL5De79OUTyyrg1zgwXsrEx3ESfx4Bz0M5UjVMHB24mw9SuXtXFoN13fYKOM1tyUT3FbvbWmSWCZX2Er-41u5xPoml45svRahl9Wb9aasbINJixDZwcPTbyTLZSUsAvrg_cPuCR7s782_WU8343Y72Qtlb8OYatwuOQvuN13M_hJKNfxann1v1U_B1KZ_D_mzhzhz24fw85CSz2irtN9w9HshBK7AQAAA==)... + +```js +// @errors: 7005 +let items = $state([ /*...*/ ]); + +let index = $state(0); +let selected = $derived(items[index]); +``` + +...you can change (or `bind:` to) properties of `selected` and it will affect the underlying `items` array. If `items` was _not_ deeply reactive, mutating `selected` would have no effect. + +## Destructuring + +If you use destructuring with a `$derived` declaration, the resulting variables will all be reactive — this... + +```js +function stuff() { return { a: 1, b: 2, c: 3 } } +// ---cut--- +let { a, b, c } = $derived(stuff()); +``` + +...is roughly equivalent to this: + +```js +function stuff() { return { a: 1, b: 2, c: 3 } } +// ---cut--- +let _stuff = $derived(stuff()); +let a = $derived(_stuff.a); +let b = $derived(_stuff.b); +let c = $derived(_stuff.c); +``` + +## Update propagation + +Svelte uses something called _push-pull reactivity_ — when state is updated, everything that depends on the state (whether directly or indirectly) is immediately notified of the change (the 'push'), but derived values are not re-evaluated until they are actually read (the 'pull'). + +If the new value of a derived is referentially identical to its previous value, downstream updates will be skipped. In other words, Svelte will only update the text inside the button when `large` changes, not when `count` changes, even though `large` depends on `count`: + +```svelte + + + +``` diff --git a/documentation/docs/02-runes/04-$effect.md b/documentation/docs/02-runes/04-$effect.md index b338795220..a13fc7bc46 100644 --- a/documentation/docs/02-runes/04-$effect.md +++ b/documentation/docs/02-runes/04-$effect.md @@ -1,16 +1,13 @@ --- title: $effect +tags: rune-effect --- -Effects are what make your application _do things_. When Svelte runs an effect function, it tracks which pieces of state (and derived state) are accessed (unless accessed inside [`untrack`](svelte#untrack)), and re-runs the function when that state later changes. +Effects are functions that run when state updates, and can be used for things like calling third-party libraries, drawing on `` elements, or making network requests. They only run in the browser, not during server-side rendering. -Most of the effects in a Svelte app are created by Svelte itself — they're the bits that update the text in `

hello {name}!

` when `name` changes, for example. +Generally speaking, you should _not_ update state inside effects, as it will make code more convoluted and will often lead to never-ending update cycles. If you find yourself doing so, see [when not to use `$effect`](#When-not-to-use-$effect) to learn about alternative approaches. -But you can also create your own effects with the `$effect` rune, which is useful when you need to synchronize an external system (whether that's a library, or a `` element, or something across a network) with state inside your Svelte app. - -> [!NOTE] Avoid overusing `$effect`! When you do too much work in effects, code often becomes difficult to understand and maintain. See [when not to use `$effect`](#When-not-to-use-$effect) to learn about alternative approaches. - -Your effects run after the component has been mounted to the DOM, and in a [microtask](https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide) after state changes ([demo](/playground/untitled#H4sIAAAAAAAAE31S246bMBD9lZF3pSRSAqTVvrCAVPUP2sdSKY4ZwJJjkD0hSVH-vbINuWxXfQH5zMyZc2ZmZLVUaFn6a2R06ZGlHmBrpvnBvb71fWQHVOSwPbf4GS46TajJspRlVhjZU1HqkhQSWPkHIYdXS5xw-Zas3ueI6FRn7qHFS11_xSRZhIxbFtcDtw7SJb1iXaOg5XIFeQGjzyPRaevYNOGZIJ8qogbpe8CWiy_VzEpTXiQUcvPDkSVrSNZz1UlW1N5eLcqmpdXUvaQ4BmqlhZNUCgxuzFHDqUWNAxrYeUM76AzsnOsdiJbrBp_71lKpn3RRbii-4P3f-IMsRxS-wcDV_bL4PmSdBa2wl7pKnbp8DMgVvJm8ZNskKRkEM_OzyOKQFkgqOYBQ3Nq89Ns0nbIl81vMFN-jKoLMTOr-SOBOJS-Z8f5Y6D1wdcR8dFqvEBdetK-PHwj-z-cH8oHPY54wRJ8Ys7iSQ3Bg3VA9azQbmC9k35kKzYa6PoVtfwbbKVnBixBiGn7Pq0rqJoUtHiCZwAM3jdTPWCVtr_glhVrhecIa3vuksJ_b7TqFs4DPyriSjd5IwoNNQaAmNI-ESfR2p8zimzvN1swdCkvJHPH6-_oX8o1SgcIDAAA=)): +You can create an effect with the `$effect` rune ([demo](/playground/untitled#H4sIAAAAAAAAE31S246bMBD9lZF3pSRSAqTVvrCAVPUP2sdSKY4ZwJJjkD0hSVH-vbINuWxXfQH5zMyZc2ZmZLVUaFn6a2R06ZGlHmBrpvnBvb71fWQHVOSwPbf4GS46TajJspRlVhjZU1HqkhQSWPkHIYdXS5xw-Zas3ueI6FRn7qHFS11_xSRZhIxbFtcDtw7SJb1iXaOg5XIFeQGjzyPRaevYNOGZIJ8qogbpe8CWiy_VzEpTXiQUcvPDkSVrSNZz1UlW1N5eLcqmpdXUvaQ4BmqlhZNUCgxuzFHDqUWNAxrYeUM76AzsnOsdiJbrBp_71lKpn3RRbii-4P3f-IMsRxS-wcDV_bL4PmSdBa2wl7pKnbp8DMgVvJm8ZNskKRkEM_OzyOKQFkgqOYBQ3Nq89Ns0nbIl81vMFN-jKoLMTOr-SOBOJS-Z8f5Y6D1wdcR8dFqvEBdetK-PHwj-z-cH8oHPY54wRJ8Ys7iSQ3Bg3VA9azQbmC9k35kKzYa6PoVtfwbbKVnBixBiGn7Pq0rqJoUtHiCZwAM3jdTPWCVtr_glhVrhecIa3vuksJ_b7TqFs4DPyriSjd5IwoNNQaAmNI-ESfR2p8zimzvN1swdCkvJHPH6-_oX8o1SgcIDAAA=)): ```svelte - + ``` -Re-runs are batched (i.e. changing `color` and `size` in the same moment won't cause two separate runs), and happen after any DOM updates have been applied. +When Svelte runs an effect function, it tracks which pieces of state (and derived state) are accessed (unless accessed inside [`untrack`](svelte#untrack)), and re-runs the function when that state later changes. + +> [!NOTE] If you're having difficulty understanding why your `$effect` is rerunning or is not running see [understanding dependencies](#Understanding-dependencies). Effects are triggered differently than the `$:` blocks you may be used to if coming from Svelte 4. + +### Understanding lifecycle + +Your effects run after the component has been mounted to the DOM, and in a [microtask](https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide) after state changes. Re-runs are batched (i.e. changing `color` and `size` in the same moment won't cause two separate runs), and happen after any DOM updates have been applied. -You can place `$effect` anywhere, not just at the top level of a component, as long as it is called during component initialization (or while a parent effect is active). It is then tied to the lifecycle of the component (or parent effect) and will therefore destroy itself when the component unmounts (or the parent effect is destroyed). +You can use `$effect` anywhere, not just at the top level of a component, as long as it is called while a parent effect is running. -You can return a function from `$effect`, which will run immediately before the effect re-runs, and before it is destroyed ([demo](/playground/untitled#H4sIAAAAAAAAE42RQY-bMBCF_8rI2kPopiXpMQtIPfbeW6m0xjyKtWaM7CFphPjvFVB2k2oPe7LmzXzyezOjaqxDVKefo5JrD3VaBLVXrLu5-tb3X-IZTmat0hHv6cazgCWqk8qiCbaXouRSHISMH1gop4coWrA7JE9bp7PO2QjjuY5vA8fDYZ3hUh7QNDCy2yWUFzTOUilpSj9aG-linaMKFGACtKCmSwvGGYGeLQvCWbtnMq3m34grajxHoa1JOUXI93_V_Sfz7Oz7Mafj0ypN-zvHm8dSAmQITP_xaUq2IU1GO1dp80I2Uh_82dao92Rl9R8GvgF0QrbrUFstcFeq0PgAkha0LoICPoeB4w1SJUvsZcj4rvcMlvmvGlGCv6J-DeSgw2vabQnJlm55p7nM0rcTctYei3HZxZSl7XHVqkHEM3k2zpqXfFyj393zU05fpyI6f0HI0hUoPoamC9roKDeo2ivBH1EnCQOmX9NfYw2GHrgCAAA=)). +> [!NOTE] Svelte uses effects internally to represent logic and expressions in your template — this is how `

hello {name}!

` updates when `name` changes. +An effect can return a _teardown function_ which will run immediately before the effect re-runs: + + ```svelte + + + + + +

{a} + {b} = {await add(a, b)}

+ +{#if $effect.pending()} +

pending promises: {$effect.pending()}

+{/if} ``` + ## `$effect.root` The `$effect.root` rune is an advanced feature that creates a non-tracked scope that doesn't auto-cleanup. This is useful for nested effects that you want to manually control. This rune also allows for the creation of effects outside of the component initialisation phase. -```svelte - +// later... +destroy(); ``` ## When not to use `$effect` @@ -288,11 +302,15 @@ In general, `$effect` is best considered something of an escape hatch — useful > [!NOTE] For things that are more complicated than a simple expression like `count * 2`, you can also use `$derived.by`. -You might be tempted to do something convoluted with effects to link one value to another. The following example shows two inputs for "money spent" and "money left" that are connected to each other. If you update one, the other should update accordingly. Don't use effects for this ([demo](/playground/untitled#H4sIAAAAAAAACpVRy26DMBD8FcvKgUhtoIdeHBwp31F6MGSJkBbHwksEQvx77aWQqooq9bgzOzP7mGTdIHipPiZJowOpGJAv0po2VmfnDv4OSBErjYdneHWzBJaCjcx91TWOToUtCIEE3cig0OIty44r5l1oDtjOkyFIsv3GINQ_CNYyGegd1DVUlCR7oU9iilDUcP8S8roYs9n8p2wdYNVFm4csTx872BxNCcjr5I11fdgonEkXsjP2CoUUZWMv6m6wBz2x7yxaM-iJvWeRsvSbSVeUy5i0uf8vKA78NIeJLSZWv1I8jQjLdyK4XuTSeIdmVKJGGI4LdjVOiezwDu1yG74My8PLCQaSiroe5s_5C2PHrkVGAgAA)): +If you're using an effect because you want to be able to reassign the derived value (to build an optimistic UI, for example) note that [deriveds can be directly overridden]($derived#Overriding-derived-values) as of Svelte 5.25. +You might be tempted to do something convoluted with effects to link one value to another. The following example shows two inputs for "money spent" and "money left" that are connected to each other. If you update one, the other should update accordingly. Instead of using effects for this... + + ```svelte + - - - - + ``` + -If you need to use bindings, for whatever reason (for example when you want some kind of "writable `$derived`"), consider using getters and setters to synchronise state ([demo](/playground/untitled#H4sIAAAAAAAACpWRwW6DMBBEf8WyekikFOihFwcq9TvqHkyyQUjGsfCCQMj_XnvBNKpy6Qn2DTOD1wu_tRocF18Lx9kCFwT4iRvVxenT2syNoDGyWjl4xi93g2AwxPDSXfrW4oc0EjUgwzsqzSr2VhTnxJwNHwf24lAhHIpjVDZNwy1KS5wlNoGMSg9wOCYksQccerMlv65p51X0p_Xpdt_4YEy9yTkmV3z4MJT579-bUqsaNB2kbI0dwlnCgirJe2UakJzVrbkKaqkWivasU1O1ULxnOVk3JU-Uxti0p_-vKO4no_enbQ_yXhnZn0aHs4b1jiJMK7q2zmo1C3bTMG3LaZQVrMjeoSPgaUtkDxePMCEX2Ie6b_8D4WyJJEwCAAA=)): +...use `oninput` callbacks or — better still — [function bindings](bind#Function-bindings) where possible: + ```svelte + + + ``` + If you absolutely have to update `$state` within an effect and run into an infinite loop because you read and write to the same `$state`, use [untrack](svelte#untrack). diff --git a/documentation/docs/02-runes/05-$props.md b/documentation/docs/02-runes/05-$props.md index 58e9b36f8e..3cf67837f6 100644 --- a/documentation/docs/02-runes/05-$props.md +++ b/documentation/docs/02-runes/05-$props.md @@ -1,5 +1,6 @@ --- title: $props +tags: rune-props --- The inputs to a component are referred to as _props_, which is short for _properties_. You pass props to components just like you pass attributes to elements: @@ -37,7 +38,7 @@ On the other side, inside `MyComponent.svelte`, we can receive props with the `$ ## Fallback values -Destructuring allows us to declare fallback values, which are used if the parent component does not set a given prop: +Destructuring allows us to declare fallback values, which are used if the parent component does not set a given prop (or the value is `undefined`): ```js let { adjective = 'happy' } = $props(); @@ -63,8 +64,9 @@ let { a, b, c, ...others } = $props(); ## Updating props -References to a prop inside a component update when the prop itself updates — when `count` changes in `App.svelte`, it will also change inside `Child.svelte`. But the child component is able to temporarily override the prop value, which can be useful for unsaved ephemeral state ([demo](/playground/untitled#H4sIAAAAAAAAE6WQ0WrDMAxFf0WIQR0Wmu3VTQJln7HsIfVcZubIxlbGRvC_DzuBraN92qPula50tODZWB1RPi_IX16jLALWSOOUq6P3-_ihLWftNEZ9TVeOWBNHlNhGFYznfqCBzeRdYHh6M_YVzsFNsNs3pdpGd4eBcqPVDMrNxNDBXeSRtXioDgO1zU8ataeZ2RE4Utao924RFXQ9iHXwvoPHKpW1xY4g_Bg0cSVhKS0p560Za95612ZC02ONrD8ZJYdZp_rGQ37ff_mSP86Np2TWZaNNmdcH56P4P67K66_SXoK9pG-5dF5Z9QEAAA==)): +References to a prop inside a component update when the prop itself updates — when `count` changes in `App.svelte`, it will also change inside `Child.svelte`. But the child component is able to temporarily override the prop value, which can be useful for unsaved ephemeral state: + ```svelte + + +``` ```svelte @@ -162,6 +179,7 @@ The fallback value of a prop not declared with `$bindable` is left untouched — clicks: {object.count} ``` + In summary: don't mutate props. Either use callback props to communicate changes, or — if parent and child should share the same object — use the [`$bindable`]($bindable) rune. @@ -196,4 +214,29 @@ You can, of course, separate the type declaration from the annotation: ``` +> [!NOTE] Interfaces for native DOM elements are provided in the `svelte/elements` module (see [Typing wrapper components](typescript#Typing-wrapper-components)) + +If your component exposes [snippet](snippet) props like `children`, these should be typed using the `Snippet` interface imported from `'svelte'` — see [Typing snippets](snippet#Typing-snippets) for examples. + Adding types is recommended, as it ensures that people using your component can easily discover which props they should provide. + + +## `$props.id()` + +This rune, added in version 5.20.0, generates an ID that is unique to the current component instance. When hydrating a server-rendered component, the value will be consistent between server and client. + +This is useful for linking elements via attributes like `for` and `aria-labelledby`. + +```svelte + + +
+ + + + + +
+``` diff --git a/documentation/docs/02-runes/06-$bindable.md b/documentation/docs/02-runes/06-$bindable.md index 14bc8ddbec..3675a56b16 100644 --- a/documentation/docs/02-runes/06-$bindable.md +++ b/documentation/docs/02-runes/06-$bindable.md @@ -4,7 +4,7 @@ title: $bindable Ordinarily, props go one way, from parent to child. This makes it easy to understand how data flows around your app. -In Svelte, component props can be _bound_, which means that data can also flow _up_ from child to parent. This isn't something you should do often, but it can simplify your code if used sparingly and carefully. +In Svelte, component props can be _bound_, which means that data can also flow _up_ from child to parent. This isn't something you should do often — overuse can make your data flow unpredictable and your components harder to maintain — but it can simplify your code if used sparingly and carefully. It also means that a state proxy can be _mutated_ in the child. @@ -33,7 +33,7 @@ Now, a component that uses `` can add the [`bind:`](bind) directive ```svelte -/// App.svelte +/// file: App.svelte @@ -71,6 +73,7 @@ Snippets can be declared anywhere inside your component. They can reference valu {@render hello('alice')} {@render hello('bob')} ``` + ...and they are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings): @@ -91,9 +94,11 @@ Snippets can be declared anywhere inside your component. They can reference valu {@render x()} ``` -Snippets can reference themselves and each other ([demo](/playground/untitled#H4sIAAAAAAAAE2WPTQqDMBCFrxLiRqH1Zysi7TlqF1YnENBJSGJLCYGeo5tesUeosfYH3c2bee_jjaWMd6BpfrAU6x5oTvdS0g01V-mFPkNnYNRaDKrxGxto5FKCIaeu1kYwFkauwsoUWtZYPh_3W5FMY4U2mb3egL9kIwY0rbhgiO-sDTgjSEqSTvIDs-jiOP7i_MHuFGAL6p9BtiSbOTl0GtzCuihqE87cqtyam6WRGz_vRcsZh5bmRg3gju4Fptq_kzQBAAA=)): +Snippets can reference themselves and each other: + ```svelte + {#snippet blastoff()} 🚀 {/snippet} @@ -109,12 +114,17 @@ Snippets can reference themselves and each other ([demo](/playground/untitled#H4 {@render countdown(10)} ``` + ## Passing snippets to components -Within the template, snippets are values just like any other. As such, they can be passed to components as props ([demo](/playground/untitled#H4sIAAAAAAAAE3VS247aMBD9lZGpBGwDASRegonaPvQL2qdlH5zYEKvBNvbQLbL875VzAcKyj3PmzJnLGU8UOwqSkd8KJdaCk4TsZS0cyV49wYuJuQiQpGd-N2bu_ooaI1YwJ57hpVYoFDqSEepKKw3mO7VDeTTaIvxiRS1gb_URxvO0ibrS8WanIrHUyiHs7Vmigy28RmyHHmKvDMbMmFq4cQInvGSwTsBYWYoMVhCSB2rBFFPsyl0uruTlR3JZCWvlTXl1Yy_mawiR_rbZKZrellJ-5JQ0RiBUgnFhJ9OGR7HKmwVoilXeIye8DOJGfYCgRlZ3iE876TBsZPX7hPdteO75PC4QaIo8vwNPePmANQ2fMeEFHrLD7rR1jTNkW986E8C3KwfwVr8HSHOSEBT_kGRozyIkn_zQveXDL3rIfPJHtUDwzShJd_Qk3gQCbOGLsdq4yfTRJopRuin3I7nv6kL7ARRjmLdBDG3uv1mhuLA3V2mKtqNEf_oCn8p9aN-WYqH5peP4kWBl1UwJzAEPT9U7K--0fRrrWnPTXpCm1_EVdXjpNmlA8G1hPPyM1fKgMqjFHjctXGjLhZ05w0qpDhksGrybuNEHtJnCalZWsuaTlfq6nPaaBSv_HKw-K57BjzOiVj9ZKQYKzQjZodYFqydYTRN4gPhVzTDO2xnma3HsVWjaLjT8nbfwHy7Q5f2dBAAA)): +### Explicit props +Within the template, snippets are values just like any other. As such, they can be passed to components as props: + + ```svelte + + + + {#if header} + + {@render header()} + + {/if} + + + {#each data as d} + {@render row(d)} + {/each} + +
+ + ``` + Think about it like passing content instead of data to a component. The concept is similar to slots in web components. -As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component ([demo](/playground/untitled#H4sIAAAAAAAAE3VSTa_aMBD8Kyu_SkAbCA-JSzBR20N_QXt6vIMTO8SqsY29tI2s_PcqTiB8vaPHs7MzuxuIZgdBMvJLo0QlOElIJZXwJHsLBBvb_XUASc7Mb9Yu_B-hsMMK5sUzvDQahUZPMkJ96aTFfKd3KA_WOISfrFACKmcOMFmk8TWUTjY73RFLoz1C5U4SPWzhrcN2GKDrlcGEWauEnyRwxCaDdQLWyVJksII2uaMWTDPNLtzX5YX8-kgua-GcHJVXI3u5WEPb0d83O03TMZSmfRzOkG1Db7mNacOL19JagVALxoWbztq-H8U6j0SaYp2P2BGbOyQ2v8PQIFMXLKRDk177pq0zf6d8bMrzwBdd0pamyPMb-IjNEzS2f86Gz_Dwf-2F9nvNSUJQ_EOSoTuJNvngqK5v4Pas7n4-OCwlEEJcQTIMO-nSQwtb-GSdsX46e9gbRoP9yGQ11I0rEuycunu6PHx1QnPhxm3SFN15MOlYEFJZtf0dUywMbwZOeBGsrKNLYB54-1R9WNqVdki7usim6VmQphf7mnpshiQRhNAXdoOfMyX3OgMlKtz0cGEcF27uLSul3mewjPjgOOoDukxjPS9rqfh0pb-8zs6aBSt_7505aZ7B9xOi0T9YKW4UooVsr0zB1BTrWQJ3EL-oWcZ572GxFoezCk37QLe3897-B2i2U62uBAAA)): +### Implicit props + +As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component: + ```svelte - + + + {#snippet header()} @@ -165,10 +225,54 @@ As an authoring convenience, snippets declared directly _inside_ a component imp
fruit
``` -Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet ([demo](/playground/untitled#H4sIAAAAAAAAE3WOQQrCMBBFrzIMggql3ddY1Du4si5sOmIwnYRkFKX07lKqglqX8_7_w2uRDw1hjlsWI5ZqTPBoLEXMdy3K3fdZDzB5Ndfep_FKVnpWHSKNce1YiCVijirqYLwUJQOYxrsgsLmIOIZjcA1M02w4n-PpomSVvTclqyEutDX6DA2pZ7_ABIVugrmEC3XJH92P55_G39GodCmWBFrQJ2PrQAwdLGHig_NxNv9xrQa1dhWIawrv1Wzeqawa8953D-8QOmaEAQAA)): +```svelte + + + + + {#if header} + + {@render header()} + + {/if} + + + {#each data as d} + {@render row(d)} + {/each} + +
+ + +``` + + +### Implicit `children` snippet +Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet: + + ```svelte + + ``` @@ -181,9 +285,12 @@ Any content inside the component tags that is _not_ a snippet declaration implic ``` + > [!NOTE] Note that you cannot have a prop called `children` if you also have content inside the component — for this reason, you should avoid having props with that name +### Optional snippet props + You can declare snippet props as being optional. You can either use optional chaining to not render anything if the snippet isn't set... ```svelte @@ -248,9 +355,21 @@ We can tighten things up further by declaring a generic, so that `data` and `row ## Exporting snippets -Snippets declared at the top level of a `.svelte` file can be exported from a ` + +{@render add(1, 2)} + +``` ```svelte + @@ -259,6 +378,7 @@ Snippets declared at the top level of a `.svelte` file can be exported from a `< {a} + {b} = {a + b} {/snippet} ``` + > [!NOTE] > This requires Svelte 5.5.0 or newer @@ -269,4 +389,4 @@ Snippets can be created programmatically with the [`createRawSnippet`](svelte#cr ## Snippets and slots -In Svelte 4, content can be passed to components using [slots](legacy-slots). Snippets are more powerful and flexible, and as such slots are deprecated in Svelte 5. +In Svelte 4, content can be passed to components using [slots](legacy-slots). Snippets are more powerful and flexible, and so slots have been deprecated in Svelte 5. diff --git a/documentation/docs/03-template-syntax/08-@html.md b/documentation/docs/03-template-syntax/08-@html.md index 30456fa666..36b8ad36b3 100644 --- a/documentation/docs/03-template-syntax/08-@html.md +++ b/documentation/docs/03-template-syntax/08-@html.md @@ -1,5 +1,6 @@ --- title: {@html ...} +tags: template-html --- To inject raw HTML into your component, use the `{@html ...}` tag: @@ -22,7 +23,7 @@ It also will not compile Svelte code. ## Styling -Content rendered this way is 'invisible' to Svelte and as such will not receive [scoped styles](scoped-styles) — in other words, this will not work, and the `a` and `img` styles will be regarded as unused: +Content rendered this way is 'invisible' to Svelte and as such will not receive [scoped styles](scoped-styles). In other words, this will not work, and the `a` and `img` styles will be regarded as unused: ```svelte diff --git a/documentation/docs/03-template-syntax/09-@attach.md b/documentation/docs/03-template-syntax/09-@attach.md new file mode 100644 index 0000000000..0087923b15 --- /dev/null +++ b/documentation/docs/03-template-syntax/09-@attach.md @@ -0,0 +1,175 @@ +--- +title: {@attach ...} +tags: attachments +--- + +Attachments are functions that run in an [effect]($effect) when an element is mounted to the DOM or when [state]($state) read inside the function updates. + +Optionally, they can return a function that is called before the attachment re-runs, or after the element is later removed from the DOM. + +> [!NOTE] +> Attachments are available in Svelte 5.29 and newer. + +```svelte + + + +
...
+``` + +An element can have any number of attachments. + +## Attachment factories + +A useful pattern is for a function, such as `tooltip` in this example, to _return_ an attachment ([demo](/playground/untitled#H4sIAAAAAAAAE3VT0XLaMBD8lavbDiaNCUlbHhTItG_5h5AH2T5ArdBppDOEMv73SkbGJGnH47F9t3un3TsfMyO3mInsh2SW1Sa7zlZKo8_E0zHjg42pGAjxBPxp7cTvUHOMldLjv-IVGUbDoUw295VTlh-WZslqa8kxsLL2ACtHWxh175NffnQfAAGikSGxYQGfPEvGfPSIWtOH0TiBVo2pWJEBJtKhQp4YYzjG9JIdcuMM5IZqHMPioY8vOSA997zQoevf4a7heO7cdp34olRiTGr07OhwH1IdoO2A7dLMbwahZq6MbRhKZWqxk7rBxTGVbuHmhCgb5qDgmIx_J6XtHHukHTrYYqx_YpzYng8aO4RYayql7hU-1ZJl0akqHBE_D9KLolwL-Dibzc7iSln9XjtqTF1UpMkJ2EmXR-BgQErsN4pxIJKr0RVO1qrxAqaTO4fbc9bKulZm3cfDY3aZDgvFGErWjmzhN7KmfX5rXyDeX8Pt1mU-hXjdBOrtuB97vK4GPUtmJ41XcRMEGDLD8do0nJ73zhUhSlyRw0t3vPqD8cjfLs-axiFgNBrkUd9Ulp50c-GLxlXAVlJX-ffpZyiSn7H0eLCUySZQcQdXlxj4El0Yv_FZvIKElqqGTruVLhzu7VRKCh22_5toOyxsWqLwwzK-cCbYNdg-hy-p9D7sbiZWUnts_wLUOF3CJgQAAA==)): + +```svelte + + + + + + +``` + +Since the `tooltip(content)` expression runs inside an [effect]($effect), the attachment will be destroyed and recreated whenever `content` changes. The same thing would happen for any state read _inside_ the attachment function when it first runs. (If this isn't what you want, see [Controlling when attachments re-run](#Controlling-when-attachments-re-run).) + +## Inline attachments + +Attachments can also be created inline ([demo](/playground/untitled#H4sIAAAAAAAAE71Wf3OaWBT9KoyTTnW3MS-I3dYmnWXVtnRAazRJzbozRSQEApiRhwKO333vuY8m225m_9yZGOT9OPfcc84D943UTfxGr_G7K6Xr3TVeNW7D2M8avT_3DVk-YAoDNF4vNB8e2tnWjyXGlm7mPzfurVPpp5JgGmeZtwkf5PtFupCxLzVvHa832rl2lElX-s2Xm2DZFNqp_hs-rZetd4v07ORpT3qmQHu7MF2td0BZp8k6z_xkvfXP902_pZ2_1_aYWEiqm0kN8I4r79qbdZ6umnq3q_2iNf22F4dE6qt2oimwdpim_uY6XMm7Fuo-IQT_iTD_CeGTHwZ38ieIJUFQRxirR1Xf39Dw0X5z0I72Af4tD61vvPNwWKQnqmfPTbduhsEd2J3vO_oBd3dc6fF2X7umNdWGf0vBRhSS6qoV7cCXfTXWfKmvWG61_si_vfU92Wz-E4RhsLhNIYinsox9QKGVd8-tuACCeKXRX12P-T_eKf7fhTq0Hvt-f3ailtSeoxJHRo1-58NoPe1UiBc1hkL8Yeh45y_vQ3mcuNl9T8s3cXPRWLnS7YWJG_gn2Tb4tUjid8jua-PVl08j_ab8I14mH8Llx0s5Tz5Err4ql52r_GYg0mVy1bEGZuD0ze64b5TWYFiM-16wSuJ4JT5vfVpDcztrcG_YkRU4s6HxufzDWF4XuVeJ1P10IbzBemt3Vp1V2e04ZXfrJd7Wicyd039brRIv_RIVu_nXi7X1cfL2sy66ztToUp1TO7qJ7NlwZ0f30pld5qNSVE5o6PbMojFHjgZB7oSicPpGteyLclQap7SvY0dXtM_LR1NT2JFHey3aaxa0VxCeYJ7RMHemoiCcgPZV9pR7o7kgcOjeGliYk9hjDZx8FAq6enwlTPSZj_vYPw9Il64dXdIY8ZmapzwfEd8-1ZyaxWhqkIZOibXUd-6Upqi1pD4uMicCV1GA_7zi73UN8BaF4sC8peJtMjfmjbHZBFwq5ov50qRaE0l96NZggnW4KqypYRAW-uhSz9ADvklwJF2J-5W0Z5fQPBhDX92R6I_0IFxRgDftge4l4dP-gH1hjD7uqU6fsOEZ9UNrCdPB-nys6uXgY6O3ZMd9sy5T9PghqrWHdjo4jB51CgLiKJaDYYA-7WgYONf1FbjkI-mE3EAfUY_rijfuJ_CVPaR50oe9JF7Q0pI8Dw3osxxYHdYPGbp2CnwHF8KvwJv2wEv0Z3ilQI6U9uwbZxbYJXvEmjjQjjCHkvNLvNg3yhzXQd1olamsT4IRrZmX0MUDpwL7R8zzHj7pSh9hPHFSHjLezKqAST51uC5zmtQ87skDUaneLokT5RbXkPWSYz53Abgjc8_o4KFGUZ-Hgv2Z1l5OTYM9D-HfUD0L-EwxH5wRnIG61gS-khfgY1bq7IAP_DA4l5xRuh9xlm8yGjutc8t-wHtkhWv3hc7aqGwiK5KzgvM5xRkZYn193uEln-su55j1GaIv7oM4iPrsVHiG0Dx7TR9-1lBfqFdwfvSd5LNL5xyZVp5NoHFZ57FkfiF6vKs4k5zvIfrX5xX6MXmt0gM5MTu8DjnhukrHHzTRd3jm0dma0_f_x5cxP9f4jBdqHvmbq2fUjzqcKh2Cp-yWj9ntcHanXmBXxhu7Q--eyjhfNFpaV7zgz4nWEUb7zUOhpevjjf_gu_KZ99pxFlZ-T3sttkmYqrco_26q35v0Ewzv5EZPbnL_8BfduWGMnyyN3q0bZ_7hb_7KG_L4CQAA)): + +```svelte + + { + const context = canvas.getContext('2d'); + + $effect(() => { + context.fillStyle = color; + context.fillRect(0, 0, canvas.width, canvas.height); + }); + }} +> +``` + +> [!NOTE] +> The nested effect runs whenever `color` changes, while the outer effect (where `canvas.getContext(...)` is called) only runs once, since it doesn't read any reactive state. + +## Conditional attachments + +Falsy values like `false` or `undefined` are treated as no attachment, enabling conditional usage: + +```svelte +
...
+``` + +## Passing attachments to components + +When used on a component, `{@attach ...}` will create a prop whose key is a [`Symbol`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). If the component then [spreads](/tutorial/svelte/spread-props) props onto an element, the element will receive those attachments. + +This allows you to create _wrapper components_ that augment elements ([demo](/playground/untitled#H4sIAAAAAAAAE3VUS3ObMBD-KxvajnFqsJM2PhA7TXrKob31FjITAbKtRkiMtDhJPfz3LiAMdpxhGJvdb1_fPnaeYjn3Iu-WIbJ04028lZDcetHDzsO3olbVApI74F1RhHbLJdayhFl-Sp5qhVwhufEWNjWiwJtYxSjyQhsEFEXxBiujcxg1_8O_dnQ9APwsEbVyiHDafjrvDZCgkiO4MLCEzxYZcn90z6XUZ6OxA61KlaIgV6i1pFC-sxjDrlbHaDiWRoGvdMbHsLzp5DES0mJnRxGaRBvcBHb7yFUTCQeunEWYcYtGv12TqgFUDbCK1WLaM6IWQhUlQiJUFm2ZLPly51xXMG0Rjoyd69C7UqqG2nu95QZyXvtvLVpri2-SN4hoLXXCZFfhQ8aQBU1VgdEaH_vSgyBZR_BpPp_vi0tY-rw2ulRZkGqpTQRbZvwa2BPgFC8bgbw31CbjJjAsE6WNYBZeGp7vtQXLMqHWnZx-5kM1TR5ycpkZXQR2wzL94l8Ur1C_3-g168SfQf1MyfRi3LW9fs77emJEw5QV9SREoLTq06tcczq7d6xEUcJX2vAhO1b843XK34e5unZEMBr15ekuKEusluWAF8lXhE2ZTP2r2RcIHJ-163FPKerCgYJLOB9i4GvNwviI5-gAQiFFBk3tBTOU3HFXEk0R8o86WvUD64aINhv5K3oRmpJXkw8uxMG6Hh6JY9X7OwGSqfUy9tDG3sHNoEi0d_d_fv9qndxRU0VClFqo3KVo3U655Hnt1PXB3Qra2Y2QGdEwgTAMCxopsoxOe6SD0gD8movDhT0LAnhqlE8gVCpLWnRoV7OJCkFAwEXitrYL1W7p7pbiE_P7XH6E_rihODm5s52XtiH9Ekaw0VgI9exadWL1uoEYjPtg2672k5szsxbKyWB2fdT0w5Y_0hcT8oXOlRetmLS8-g-6TLXXQgYAAA==)): + +```svelte + + + + + +``` + +```svelte + + + + + + +``` + +## Controlling when attachments re-run + +Attachments, unlike [actions](use), are fully reactive: `{@attach foo(bar)}` will re-run on changes to `foo` _or_ `bar` (or any state read inside `foo`): + +```js +// @errors: 7006 2304 2552 +function foo(bar) { + return (node) => { + veryExpensiveSetupWork(node); + update(node, bar); + }; +} +``` + +In the rare case that this is a problem (for example, if `foo` does expensive and unavoidable setup work) consider passing the data inside a function and reading it in a child effect: + +```js +// @errors: 7006 2304 2552 +function foo(+++getBar+++) { + return (node) => { + veryExpensiveSetupWork(node); + ++++ $effect(() => { + update(node, getBar()); + });+++ + } +} +``` + +## Creating attachments programmatically + +To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey). + +## Converting actions to attachments + +If you're using a library that only provides actions, you can convert them to attachments with [`fromAction`](svelte-attachments#fromAction), allowing you to (for example) use them with components. diff --git a/documentation/docs/03-template-syntax/09-@const.md b/documentation/docs/03-template-syntax/10-@const.md similarity index 55% rename from documentation/docs/03-template-syntax/09-@const.md rename to documentation/docs/03-template-syntax/10-@const.md index f4bde77c23..6f2edc1a37 100644 --- a/documentation/docs/03-template-syntax/09-@const.md +++ b/documentation/docs/03-template-syntax/10-@const.md @@ -2,6 +2,8 @@ title: {@const ...} --- +> [!NOTE] `{@const x = y}` is legacy syntax — use [`{const x = $derived(y)}`](declaration-tags) instead + The `{@const ...}` tag defines a local constant. ```svelte @@ -11,4 +13,4 @@ The `{@const ...}` tag defines a local constant. {/each} ``` -`{@const}` is only allowed as an immediate child of a block — `{#if ...}`, `{#each ...}`, `{#snippet ...}` and so on — or a ``. +`{@const}` is only allowed as an immediate child of a block — `{#if ...}`, `{#each ...}`, `{#snippet ...}` and so on — a `` or a ``. diff --git a/documentation/docs/03-template-syntax/10-@debug.md b/documentation/docs/03-template-syntax/11-@debug.md similarity index 100% rename from documentation/docs/03-template-syntax/10-@debug.md rename to documentation/docs/03-template-syntax/11-@debug.md diff --git a/documentation/docs/03-template-syntax/11-declaration-tags.md b/documentation/docs/03-template-syntax/11-declaration-tags.md new file mode 100644 index 0000000000..e0edaf6a38 --- /dev/null +++ b/documentation/docs/03-template-syntax/11-declaration-tags.md @@ -0,0 +1,72 @@ +--- +title: {let/const ...} +--- + +Declaration tags define local variables inside markup with `const` or `let`: + + +```svelte + + + +{#each boxes as box} + {const area = box.width * box.height} + {const label = `${box.width} ⨉ ${box.height} = ${area}`} + +

{label}

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

Hello {user.name}

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

{greeting}

+ + +{/if} +``` + + +Declaration tags can be used anywhere inside the component. They can reference values declared outside themselves (for example in the ` + +
+ + + {#if indeterminate} + waiting... + {:else if checked} + checked + {:else} + unchecked + {/if} +
+``` + ## `` -Inputs that work together can use `bind:group`. +Inputs that work together can use `bind:group`: + ```svelte + +

Customize your burrito

+ - - - + + + - - - - + + + + + +

Tortilla: {tortilla}

+

Fillings: {fillings.join(', ') || 'None'}

+ + ``` + > [!NOTE] `bind:group` only works if the inputs are in the same Svelte component. @@ -199,7 +241,7 @@ When the value of an `