diff --git a/.changeset/big-webs-sing.md b/.changeset/big-webs-sing.md deleted file mode 100644 index 946a41d881..0000000000 --- a/.changeset/big-webs-sing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: leave stale promises to wait for a later resolution, instead of rejecting diff --git a/.changeset/calm-events-cleanup.md b/.changeset/calm-events-cleanup.md new file mode 100644 index 0000000000..4f5c2357f1 --- /dev/null +++ b/.changeset/calm-events-cleanup.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: cancel deferred event listeners during cleanup diff --git a/.changeset/config.json b/.changeset/config.json index b56077a922..785aa01e70 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", - "changelog": ["@svitejs/changesets-changelog-github-compact", { "repo": "sveltejs/svelte" }], + "changelog": ["@changesets/changelog-github", { "repo": "sveltejs/svelte", "template": "\n- {summary} {ref}" }], "commit": false, "fixed": [], "linked": [], diff --git a/.changeset/cruel-boxes-serve.md b/.changeset/cruel-boxes-serve.md deleted file mode 100644 index 592cec4d01..0000000000 --- a/.changeset/cruel-boxes-serve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reapply context after transforming error during SSR diff --git a/.changeset/easy-points-tan.md b/.changeset/easy-points-tan.md new file mode 100644 index 0000000000..60edbf94eb --- /dev/null +++ b/.changeset/easy-points-tan.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: speed up parser interactions with Acorn or avoid them where possible diff --git a/.changeset/easy-singers-retire.md b/.changeset/easy-singers-retire.md deleted file mode 100644 index 4420286e13..0000000000 --- a/.changeset/easy-singers-retire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: don't rebase just-created batches diff --git a/.changeset/fine-bushes-marry.md b/.changeset/fine-bushes-marry.md deleted file mode 100644 index ccba53babe..0000000000 --- a/.changeset/fine-bushes-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: allow `null` for `pending` in typings diff --git a/.changeset/flat-shrimps-worry.md b/.changeset/flat-shrimps-worry.md deleted file mode 100644 index a5f76a0f9d..0000000000 --- a/.changeset/flat-shrimps-worry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: flush eager effects in production diff --git a/.changeset/fresh-stars-grin.md b/.changeset/fresh-stars-grin.md deleted file mode 100644 index 3d56792d1e..0000000000 --- a/.changeset/fresh-stars-grin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: rethrow error of failed iterable after calling `return()` diff --git a/.changeset/full-waves-tease.md b/.changeset/full-waves-tease.md deleted file mode 100644 index 3915334bf7..0000000000 --- a/.changeset/full-waves-tease.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: account for proxified instance when updating `bind:this` diff --git a/.changeset/green-inputs-ssr.md b/.changeset/green-inputs-ssr.md new file mode 100644 index 0000000000..e034d0e79a --- /dev/null +++ b/.changeset/green-inputs-ssr.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: serialize input default values during server rendering diff --git a/.changeset/many-pandas-add.md b/.changeset/many-pandas-add.md deleted file mode 100644 index 85de7acb35..0000000000 --- a/.changeset/many-pandas-add.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure scheduled batch is flushed if not obsolete diff --git a/.changeset/modern-tables-fetch.md b/.changeset/modern-tables-fetch.md deleted file mode 100644 index 89543910fa..0000000000 --- a/.changeset/modern-tables-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: resolve stale deriveds with latest value diff --git a/.changeset/olive-mice-argue.md b/.changeset/olive-mice-argue.md new file mode 100644 index 0000000000..6d749bcc3b --- /dev/null +++ b/.changeset/olive-mice-argue.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: throw `set_context_after_init` when `setContext` is called after an `await` during SSR diff --git a/.changeset/public-mammals-float.md b/.changeset/public-mammals-float.md deleted file mode 100644 index d890c9e070..0000000000 --- a/.changeset/public-mammals-float.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: remove unnecessary `increment_pending` calls diff --git a/.changeset/quiet-teams-pick.md b/.changeset/quiet-teams-pick.md deleted file mode 100644 index ed046168be..0000000000 --- a/.changeset/quiet-teams-pick.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: correctly compile component member expressions for SSR diff --git a/.changeset/red-crabs-ring.md b/.changeset/red-crabs-ring.md deleted file mode 100644 index 82b53c5464..0000000000 --- a/.changeset/red-crabs-ring.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reset `source.updated` stack traces after `flush` diff --git a/.changeset/shiny-squids-whisper.md b/.changeset/shiny-squids-whisper.md deleted file mode 100644 index a8d2d7378c..0000000000 --- a/.changeset/shiny-squids-whisper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"svelte": patch ---- - -fix: replacing async 'blocking' strategy with 'merging' diff --git a/.changeset/small-tools-walk.md b/.changeset/small-tools-walk.md deleted file mode 100644 index 2b275368b3..0000000000 --- a/.changeset/small-tools-walk.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: allow `@debug` tags to reference awaited variables diff --git a/.changeset/smooth-poems-tap.md b/.changeset/smooth-poems-tap.md deleted file mode 100644 index ac160656cf..0000000000 --- a/.changeset/smooth-poems-tap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: re-run fallback props if dependencies update diff --git a/.changeset/stupid-baboons-fall.md b/.changeset/stupid-baboons-fall.md deleted file mode 100644 index 66895ad015..0000000000 --- a/.changeset/stupid-baboons-fall.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: abort running obsolete async branches diff --git a/.changeset/tall-kids-juggle.md b/.changeset/tall-kids-juggle.md new file mode 100644 index 0000000000..f116590a28 --- /dev/null +++ b/.changeset/tall-kids-juggle.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: avoid regex matching in parser where possible diff --git a/.changeset/three-pears-build.md b/.changeset/three-pears-build.md deleted file mode 100644 index 8a638149a3..0000000000 --- a/.changeset/three-pears-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ignore comments when reading CSS values diff --git a/.changeset/tough-knives-smell.md b/.changeset/tough-knives-smell.md deleted file mode 100644 index 7687188c1a..0000000000 --- a/.changeset/tough-knives-smell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: wrap `Promise.all` in `save` during SSR diff --git a/.changeset/tricky-cooks-help.md b/.changeset/tricky-cooks-help.md new file mode 100644 index 0000000000..f61c7944a1 --- /dev/null +++ b/.changeset/tricky-cooks-help.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: in non-async mode, only push variable to current_sources when active_reaction is updating diff --git a/.changeset/twelve-cooks-speak.md b/.changeset/twelve-cooks-speak.md deleted file mode 100644 index d4fcd5c339..0000000000 --- a/.changeset/twelve-cooks-speak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ignore false-positive errors of `$inspect` dependencies diff --git a/.changeset/wild-cases-smile.md b/.changeset/wild-cases-smile.md new file mode 100644 index 0000000000..ac5edacfd1 --- /dev/null +++ b/.changeset/wild-cases-smile.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: recognise `aria-braillelabel` and `aria-brailleroledescription` as known ARIA attributes diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index b6c26e0792..5cdbd34e5b 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -28,7 +28,7 @@ jobs: - name: Get PR ref if: github.event_name != 'workflow_dispatch' id: pr - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const { data: pull } = await github.rest.pulls.get({ @@ -46,12 +46,12 @@ jobs: core.setFailed('PR is from a fork'); } core.setOutput('ref', pull.head.ref); - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 if: github.event_name == 'workflow_dispatch' || steps.pr.outcome == 'success' with: ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || steps.pr.outputs.ref }} - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df9f755874..3846690135 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,12 +18,12 @@ jobs: strategy: matrix: include: - - node-version: 18 + # Vitest 4 requires Node 20+, so tests run on 20/22/24. The published + # Svelte package still supports Node >=18 (see packages/svelte/package.json). + - node-version: 20 os: windows-latest - - node-version: 18 + - node-version: 20 os: macOS-latest - - node-version: 18 - os: ubuntu-latest - node-version: 20 os: ubuntu-latest - node-version: 22 @@ -32,9 +32,9 @@ jobs: os: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ matrix.node-version }} cache: pnpm @@ -48,9 +48,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 22 cache: pnpm @@ -65,9 +65,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm @@ -80,11 +80,11 @@ jobs: Lint: permissions: {} runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm @@ -98,14 +98,16 @@ jobs: - name: build and check generated types if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); } + - name: check browser-support docs page is up to date + run: '{ [ "`git status --porcelain=v1 documentation/docs/07-misc/.generated/`" == "" ] || (echo "The browser-support docs page is out of date — please regenerate it locally with \`cd packages/svelte && pnpm generate:browser-support\` and commit the changes"; git diff documentation/docs/07-misc/.generated/; exit 1); }' Benchmarks: permissions: {} runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm diff --git a/.github/workflows/ecosystem-ci-trigger.yml b/.github/workflows/ecosystem-ci-trigger.yml index 8a6d1bf345..8691a64ca4 100644 --- a/.github/workflows/ecosystem-ci-trigger.yml +++ b/.github/workflows/ecosystem-ci-trigger.yml @@ -17,7 +17,7 @@ jobs: contents: read # to clone the repo steps: - name: Check User Permissions - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: check-permissions with: script: | @@ -56,7 +56,7 @@ jobs: } - name: Get PR Data - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: get-pr-data with: script: | @@ -106,7 +106,7 @@ jobs: - name: Generate Token id: generate-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }} private-key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }} @@ -115,7 +115,7 @@ jobs: svelte-ecosystem-ci - name: Trigger Downstream Workflow - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: trigger env: COMMENT: ${{ github.event.comment.body }} diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml deleted file mode 100644 index 0fcda5a778..0000000000 --- a/.github/workflows/pkg.pr.new.yml +++ /dev/null @@ -1,229 +0,0 @@ -name: pkg.pr.new -on: - pull_request_target: - types: [opened, synchronize] - push: - branches: [main] - workflow_dispatch: - inputs: - sha: - description: 'Commit SHA to build' - required: true - type: string - pr: - description: 'PR number to comment on' - required: true - type: number - -permissions: {} - -jobs: - build: - # Skip pull_request_target events from forks — maintainers can use workflow_dispatch instead - if: > - github.event_name != 'pull_request_target' || - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - # No permissions — this job runs user-controlled code - permissions: {} - - steps: - - uses: actions/checkout@v6 - with: - # For pull_request_target, check out the PR head. - # For workflow_dispatch, check out the manually specified SHA. - # For push, fall back to the push SHA. - ref: ${{ github.event.pull_request.head.sha || inputs.sha || github.sha }} - - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - run: pnpx pkg-pr-new publish --comment=off --json output.json --compact --no-template './packages/svelte' - - - name: Upload output - uses: actions/upload-artifact@v4 - with: - name: output - path: ./output.json - - # Sanitizes the untrusted output from the build job before it's consumed by - # jobs with elevated permissions. This ensures that only known package names - # and valid SHA prefixes make it through. - sanitize: - needs: build - runs-on: ubuntu-latest - - permissions: {} - - steps: - - name: Download artifact - uses: actions/download-artifact@v7 - with: - name: output - - - name: Sanitize output - uses: actions/github-script@v8 - with: - script: | - const fs = require('fs'); - const raw = JSON.parse(fs.readFileSync('output.json', 'utf8')); - - const ALLOWED_PACKAGES = new Set(['svelte']); - const SHA_PATTERN = /^[0-9a-f]{7}$/; - - const packages = (raw.packages || []) - .filter(p => { - if (!ALLOWED_PACKAGES.has(p.name)) { - console.log(`Skipping unexpected package: ${JSON.stringify(p.name)}`); - return false; - } - const sha = p.url?.replace(/^.+@([^@]+)$/, '$1'); - if (!sha || !SHA_PATTERN.test(sha)) { - console.log(`Skipping package with invalid SHA: ${JSON.stringify(p.url)}`); - return false; - } - return true; - }) - .map(p => ({ - name: p.name, - sha: p.url.replace(/^.+@([^@]+)$/, '$1'), - })); - - fs.writeFileSync('sanitized-output.json', JSON.stringify({ packages }), 'utf8'); - - - name: Upload sanitized output - uses: actions/upload-artifact@v4 - with: - name: sanitized-output - path: ./sanitized-output.json - - comment: - needs: sanitize - if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - - permissions: - contents: read - pull-requests: write - - steps: - - name: Download sanitized artifact - uses: actions/download-artifact@v7 - with: - name: sanitized-output - - - name: Resolve PR number - id: pr - uses: actions/github-script@v8 - with: - script: | - if (context.eventName === 'pull_request_target') { - core.setOutput('number', context.issue.number); - return; - } - - // For workflow_dispatch, use the explicitly provided PR number. - // We can't use listPullRequestsAssociatedWithCommit because fork - // commits don't exist in the base repo, so the API returns nothing. - const pr = Number('${{ inputs.pr }}'); - if (!pr || isNaN(pr)) { - core.setFailed('workflow_dispatch requires a valid pr input'); - return; - } - - core.setOutput('number', pr); - - - name: Post or update comment - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8')); - - if (packages.length === 0) { - console.log('No valid packages found. Skipping comment.'); - return; - } - - const issue_number = parseInt('${{ steps.pr.outputs.number }}', 10); - - const bot_comment_identifier = ``; - - const body = `${bot_comment_identifier} - - [Playground](https://svelte.dev/playground?version=pr-${issue_number}) - - \`\`\` - ${packages.map(p => `pnpm add https://pkg.pr.new/${p.name}@${issue_number}`).join('\n')} - \`\`\` - `; - - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number, - }); - const existing = comments.data.find(c => c.body.includes(bot_comment_identifier)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number, - body, - }); - } - - log: - needs: sanitize - if: github.event_name == 'push' - runs-on: ubuntu-latest - - permissions: {} - - steps: - - name: Download sanitized artifact - uses: actions/download-artifact@v7 - with: - name: sanitized-output - - - name: Log publish info - uses: actions/github-script@v8 - with: - script: | - const fs = require('fs'); - const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8')); - - if (packages.length === 0) { - console.log('No valid packages found.'); - return; - } - - console.log('\n' + '='.repeat(50)); - console.log('Publish Information'); - console.log('='.repeat(50)); - for (const p of packages) { - console.log(`${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.sha}`); - } - const svelte = packages.find(p => p.name === 'svelte'); - if (svelte) { - console.log(`\nPlayground: https://svelte.dev/playground?version=commit-${svelte.sha}`); - } - console.log('='.repeat(50)); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 359fcb7eea..12fe17582e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,13 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repo - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits fetch-depth: 0 - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24.x cache: pnpm diff --git a/.gitignore b/.gitignore index d3c1819bd5..e4f9f9cf87 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,9 @@ coverage .DS_Store tmp +packages/svelte/scripts/_baseline/ benchmarking/.profiles benchmarking/compare/.results benchmarking/compare/.profiles +benchmarking/compare/results.html diff --git a/.prettierignore b/.prettierignore index 92d9bc797b..28f447f359 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ packages/**/config/*.js # packages/svelte packages/svelte/messages/**/*.md packages/svelte/scripts/_bundle.js +packages/svelte/scripts/_baseline/*.ts packages/svelte/src/compiler/errors.js packages/svelte/src/compiler/warnings.js packages/svelte/src/internal/client/errors.js diff --git a/AGENTS.md b/AGENTS.md index c6cd3ea310..7f143248aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ This guide is for AI coding agents working in the Svelte monorepo. **Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here. +When submitting a PR, you **MUST** read [`PULL_REQUEST_TEMPLATE.md`](./.github/PULL_REQUEST_TEMPLATE.md) and fill it out correctly. **DO NOT** submit a PR without running the full test suite. + ## Quick Reference If asked to do a performance investigation, use the `performance-investigation` skill. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e940252892..586c6fe6ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ The maintainers meet on the final Saturday of each month. While these meetings a ### Prioritization -We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active ones repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. +We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. ## Bugs diff --git a/benchmarking/analyze-compiler-profile.js b/benchmarking/analyze-compiler-profile.js new file mode 100644 index 0000000000..41729b7d93 --- /dev/null +++ b/benchmarking/analyze-compiler-profile.js @@ -0,0 +1,346 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(fileURLToPath(import.meta.url), '../..'); +const compiler_path = 'packages/svelte/src/compiler/'; + +/** + * Merge V8 profiles from a Vitest run into a compiler-only flame graph and hotspot summary. + * Samples retain callees outside the compiler once execution has entered compiler code. + * @param {string} run_dir + */ +export function analyze_compiler_profiles(run_dir) { + const raw_dir = path.join(run_dir, 'raw'); + const profile_files = fs + .readdirSync(raw_dir) + .filter((file) => file.endsWith('.cpuprofile')) + .sort(); + + if (profile_files.length === 0) { + throw new Error(`No CPU profiles found in ${raw_dir}`); + } + + /** @type {Array<{ name: string, file?: string, line?: number, col?: number }>} */ + const frames = []; + /** @type {Map} */ + const frame_indices = new Map(); + /** @type {Map} */ + const stacks = new Map(); + /** @type {Map} */ + const hotspots = new Map(); + /** @type {Map} */ + const files = new Map(); + let total_time = 0; + let compiler_time = 0; + let garbage_collection_time = 0; + let total_samples = 0; + let compiler_samples = 0; + + for (const profile_file of profile_files) { + let profile; + try { + profile = JSON.parse(fs.readFileSync(path.join(raw_dir, profile_file), 'utf8')); + } catch (error) { + throw new Error(`Could not parse CPU profile ${profile_file}`, { cause: error }); + } + const nodes = Array.isArray(profile.nodes) ? profile.nodes : []; + const samples = Array.isArray(profile.samples) ? profile.samples : []; + const time_deltas = Array.isArray(profile.timeDeltas) ? profile.timeDeltas : []; + const nodes_by_id = new Map(nodes.map((node) => [node.id, node])); + const parents = new Map(); + + for (const node of nodes) { + for (const child of node.children || []) { + parents.set(child, node.id); + } + } + + for (let i = 0; i < samples.length; i += 1) { + const weight = typeof time_deltas[i] === 'number' ? time_deltas[i] : 1; + const stack = get_stack(samples[i], nodes_by_id, parents); + total_time += weight; + total_samples += 1; + + if (stack.at(-1)?.function_name === '(garbage collector)') { + garbage_collection_time += weight; + } + + const compiler_index = stack.findIndex((frame) => frame.url.includes(compiler_path)); + if (compiler_index === -1) continue; + + const compiler_stack = stack.slice(compiler_index); + const stack_indices = compiler_stack.map(get_frame_index); + const stack_key = stack_indices.join(','); + const existing_stack = stacks.get(stack_key); + + if (existing_stack) { + existing_stack.weight += weight; + } else { + stacks.set(stack_key, { stack: stack_indices, weight }); + } + + compiler_time += weight; + compiler_samples += 1; + + const leaf = compiler_stack.at(-1); + if (leaf) { + get_hotspot(hotspots, frame_key(leaf), leaf).self += weight; + get_hotspot(files, leaf.url || '(native)', { + ...leaf, + function_name: leaf.url || '(native)' + }).self += weight; + } + + const seen_frames = new Set(); + const seen_files = new Set(); + + for (const frame of compiler_stack) { + const key = frame_key(frame); + if (!seen_frames.has(key)) { + get_hotspot(hotspots, key, frame).inclusive += weight; + seen_frames.add(key); + } + + const file = frame.url || '(native)'; + if (!seen_files.has(file)) { + get_hotspot(files, file, { ...frame, function_name: file }).inclusive += weight; + seen_files.add(file); + } + } + } + } + + if (compiler_time === 0) { + throw new Error('The CPU profiles contain no samples from packages/svelte/src/compiler'); + } + + const hotspot_rows = rank(hotspots, compiler_time); + const file_rows = rank(files, compiler_time); + const compact_stacks = [...stacks.values()]; + const summary = { + profile_files: profile_files.length, + total_samples, + compiler_samples, + total_time_microseconds: total_time, + compiler_time_microseconds: compiler_time, + garbage_collection_time_microseconds: garbage_collection_time, + compiler_share_percent: (compiler_time * 100) / total_time, + hotspots: hotspot_rows, + files: file_rows + }; + const speedscope = { + $schema: 'https://www.speedscope.app/file-format-schema.json', + name: `Svelte compiler: ${path.basename(run_dir)}`, + exporter: 'Svelte compiler test profiler', + activeProfileIndex: 0, + shared: { frames }, + profiles: [ + { + type: 'sampled', + name: 'Compiler-active samples from pnpm test', + unit: 'microseconds', + startValue: 0, + endValue: compiler_time, + samples: compact_stacks.map((entry) => entry.stack), + weights: compact_stacks.map((entry) => entry.weight) + } + ] + }; + + fs.writeFileSync( + path.join(run_dir, 'flamegraph.speedscope.json'), + `${JSON.stringify(speedscope)}\n` + ); + fs.writeFileSync(path.join(run_dir, 'summary.json'), `${JSON.stringify(summary, null, '\t')}\n`); + fs.writeFileSync(path.join(run_dir, 'summary.md'), render_markdown(summary)); + + return summary; + + /** @param {Frame} frame */ + function get_frame_index(frame) { + const key = frame_key(frame); + const existing = frame_indices.get(key); + if (existing !== undefined) return existing; + + const index = frames.length; + const location = frame.url ? ` (${frame.url}:${frame.line})` : ''; + frames.push({ + name: `${frame.function_name}${location}`, + ...(frame.url ? { file: frame.url, line: frame.line, col: frame.column } : {}) + }); + frame_indices.set(key, index); + return index; + } +} + +/** + * @param {number} leaf_id + * @param {Map} nodes_by_id + * @param {Map} parents + * @returns {Frame[]} + */ +function get_stack(leaf_id, nodes_by_id, parents) { + /** @type {Frame[]} */ + const stack = []; + const seen = new Set(); + let id = leaf_id; + + while (typeof id === 'number' && !seen.has(id)) { + seen.add(id); + const node = nodes_by_id.get(id); + if (!node) break; + + const call_frame = node.callFrame || {}; + stack.push({ + function_name: call_frame.functionName || '(anonymous)', + url: normalize_url(call_frame.url || ''), + line: typeof call_frame.lineNumber === 'number' ? call_frame.lineNumber + 1 : 0, + column: typeof call_frame.columnNumber === 'number' ? call_frame.columnNumber + 1 : 0 + }); + id = parents.get(id); + } + + return stack.reverse(); +} + +/** @param {string} url */ +function normalize_url(url) { + if (!url) return ''; + + let pathname = url.replace(/^\/\@fs\//, '/').replace(/[?#].*$/, ''); + if (pathname.startsWith('file://')) { + try { + pathname = fileURLToPath(pathname); + } catch { + return url; + } + } + + if (path.isAbsolute(pathname)) { + const relative = path.relative(root, pathname); + if (!relative.startsWith('..') && !path.isAbsolute(relative)) { + pathname = relative; + } + } + + return pathname.replaceAll(path.sep, '/'); +} + +/** @param {Frame} frame */ +function frame_key(frame) { + return `${frame.function_name}\0${frame.url}\0${frame.line}\0${frame.column}`; +} + +/** + * @param {Map} collection + * @param {string} key + * @param {Frame} frame + */ +function get_hotspot(collection, key, frame) { + let hotspot = collection.get(key); + if (!hotspot) { + hotspot = { ...frame, self: 0, inclusive: 0 }; + collection.set(key, hotspot); + } + return hotspot; +} + +/** + * @param {Map} collection + * @param {number} total + */ +function rank(collection, total) { + return [...collection.values()] + .map((hotspot) => ({ + function: hotspot.function_name, + url: hotspot.url, + line: hotspot.line, + column: hotspot.column, + self_microseconds: hotspot.self, + self_percent: (hotspot.self * 100) / total, + inclusive_microseconds: hotspot.inclusive, + inclusive_percent: (hotspot.inclusive * 100) / total + })) + .sort( + (a, b) => + b.self_microseconds - a.self_microseconds || + b.inclusive_microseconds - a.inclusive_microseconds + ); +} + +/** @param {ReturnType} summary */ +function render_markdown(summary) { + const lines = [ + '# Svelte compiler CPU profile', + '', + 'Open `flamegraph.speedscope.json` in [Speedscope](https://www.speedscope.app/) for the interactive flame graph.', + '', + '## Coverage', + '', + '| Metric | Value |', + '| --- | ---: |', + `| Raw profiles | ${summary.profile_files} |`, + `| All profiled CPU time | ${format_time(summary.total_time_microseconds)} |`, + `| Compiler-active CPU time | ${format_time(summary.compiler_time_microseconds)} |`, + `| Compiler share | ${summary.compiler_share_percent.toFixed(2)}% |`, + `| All-process garbage collection | ${format_time(summary.garbage_collection_time_microseconds)} |`, + '', + 'Compiler-active time includes external callees while a compiler frame is on the stack. Garbage collection is reported for context but cannot be attributed to compiler stacks by V8.', + '', + '## Top Self Hotspots', + '', + '| Rank | Function | Location | Self | Self % | Inclusive | Inclusive % |', + '| ---: | --- | --- | ---: | ---: | ---: | ---: |' + ]; + + for (const [index, row] of summary.hotspots.slice(0, 50).entries()) { + lines.push(render_row(index, row)); + } + + lines.push( + '', + '## Top Files', + '', + '| Rank | File | Self | Self % | Inclusive | Inclusive % |', + '| ---: | --- | ---: | ---: | ---: | ---: |' + ); + + for (const [index, row] of summary.files.slice(0, 50).entries()) { + lines.push( + `| ${index + 1} | ${escape_cell(row.function)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |` + ); + } + + return `${lines.join('\n')}\n`; +} + +/** @param {number} index @param {ReturnType[number]} row */ +function render_row(index, row) { + const location = row.url ? `${row.url}:${row.line}:${row.column}` : '(native)'; + return `| ${index + 1} | ${escape_cell(row.function)} | ${escape_cell(location)} | ${format_time(row.self_microseconds)} | ${row.self_percent.toFixed(2)}% | ${format_time(row.inclusive_microseconds)} | ${row.inclusive_percent.toFixed(2)}% |`; +} + +/** @param {number} microseconds */ +function format_time(microseconds) { + return `${(microseconds / 1000).toFixed(1)} ms`; +} + +/** @param {string} value */ +function escape_cell(value) { + return value.replaceAll('|', '\\|'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const run_dir = process.argv[2]; + if (!run_dir) { + console.error('Usage: node benchmarking/analyze-compiler-profile.js '); + process.exit(1); + } + analyze_compiler_profiles(path.resolve(run_dir)); +} + +/** + * @typedef {{ function_name: string, url: string, line: number, column: number }} Frame + * @typedef {Frame & { self: number, inclusive: number }} Hotspot + */ diff --git a/benchmarking/benchmarks/compiler/index.js b/benchmarking/benchmarks/compiler/index.js new file mode 100644 index 0000000000..6db5ca139d --- /dev/null +++ b/benchmarking/benchmarks/compiler/index.js @@ -0,0 +1,3 @@ +import { parser_benchmarks } from './parser.bench.js'; + +export const compiler_benchmarks = parser_benchmarks; diff --git a/benchmarking/benchmarks/compiler/parser.bench.js b/benchmarking/benchmarks/compiler/parser.bench.js new file mode 100644 index 0000000000..3ff0fefbe7 --- /dev/null +++ b/benchmarking/benchmarks/compiler/parser.bench.js @@ -0,0 +1,58 @@ +import { parse } from '../../../packages/svelte/src/compiler/phases/1-parse/index.js'; +import { fastest_test } from '../../utils.js'; + +const elements_source = Array.from( + { length: 1000 }, + (_, i) => + `

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

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

Benchmark comparison

+

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

+ +
+ +
+
+
+

Branch standings

+

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

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

Results by benchmark

+

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

+
+
+
+ + +
+
+
+
+
+ +
+
+ + + + + diff --git a/benchmarking/compare/runner.js b/benchmarking/compare/runner.js index 31a8e6b44b..fa746f9869 100644 --- a/benchmarking/compare/runner.js +++ b/benchmarking/compare/runner.js @@ -1,18 +1,67 @@ +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { reactivity_benchmarks } from '../benchmarks/reactivity/index.js'; import { with_cpu_profile } from '../utils.js'; -const results = []; -const PROFILE_DIR = process.env.BENCH_PROFILE_DIR; +const PROFILE_DIR = process.env.BENCH_PROFILE_DIR ?? null; +const single = process.env.BENCH_SINGLE; -for (let i = 0; i < reactivity_benchmarks.length; i += 1) { - const benchmark = reactivity_benchmarks[i]; +if (single) { + // child mode — run a single benchmark and report the result to the parent + const benchmark = reactivity_benchmarks.find((b) => b.label === single); - process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `); - results.push({ - benchmark: benchmark.label, - ...(await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn())) - }); - process.stderr.write('\x1b[2K\r'); -} + if (!benchmark) { + throw new Error(`Unknown benchmark ${single}`); + } + + const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); + + // exit via the callback so the message is guaranteed to be delivered + /** @type {NodeJS.Process} */ (process).send(result, () => process.exit(0)); +} else { + // parent mode — run every benchmark in its own child process, so that + // heap/GC/JIT state from one benchmark cannot contaminate the others + const filename = fileURLToPath(import.meta.url); + const results = []; + + for (let i = 0; i < reactivity_benchmarks.length; i += 1) { + const benchmark = reactivity_benchmarks[i]; + + process.stderr.write(`Running ${i + 1}/${reactivity_benchmarks.length} ${benchmark.label} `); + + const result = await new Promise((fulfil, reject) => { + const child = fork(filename, [], { + env: { + ...process.env, + BENCH_SINGLE: benchmark.label + } + }); + + /** @type {object | null} */ + let message_received = null; -process.send(results); + child.on('message', (message) => { + message_received = /** @type {object} */ (message); + }); + + child.on('error', reject); + + child.on('exit', (code) => { + if (message_received === null) { + reject(new Error(`benchmark ${benchmark.label} exited with code ${code}`)); + } else { + fulfil(message_received); + } + }); + }); + + results.push({ + benchmark: benchmark.label, + .../** @type {object} */ (result) + }); + + process.stderr.write('\x1b[2K\r'); + } + + /** @type {NodeJS.Process} */ (process).send(results); +} diff --git a/benchmarking/compiler-profiling.md b/benchmarking/compiler-profiling.md new file mode 100644 index 0000000000..cc2196958e --- /dev/null +++ b/benchmarking/compiler-profiling.md @@ -0,0 +1,43 @@ +# Compiler CPU profiling + +Run the full test suite with V8 CPU profiling enabled in every Vitest worker: + +```sh +pnpm profile:compiler +``` + +Arguments are passed to `pnpm test`, so a smaller smoke run can target a suite: + +```sh +pnpm profile:compiler snapshot +``` + +Each run is stored under `benchmarking/.profiles/compiler-tests/`. Set +`SVELTE_PROFILE_NAME` to give a run a stable name; existing runs are never overwritten. +`benchmarking/.profiles/compiler-tests/latest.txt` contains the latest successful run name. + +Each run contains: + +- `raw/*.cpuprofile`: the original V8 profile from every Vitest worker +- `flamegraph.speedscope.json`: compiler-active stacks merged for use in + [Speedscope](https://www.speedscope.app/) +- `summary.md`: ranked self/inclusive hotspots and files +- `summary.json`: the same measurements for further analysis +- `manifest.json`: the command, revision, timestamps, and test result + +The merged profile includes downstream callees while a compiler source frame is on the stack. +This captures parser, walker, and printer dependencies without including unrelated test runtime. +V8 reports garbage collection outside the originating stack, so the summary reports process-wide +GC separately rather than attributing it to the compiler. + +The default sampling interval is 5 ms. It can be changed for shorter, focused runs: + +```sh +SVELTE_CPU_PROF_INTERVAL=1000 pnpm profile:compiler snapshot +``` + +Existing raw profiles can be analyzed again without rerunning tests: + +```sh +node benchmarking/analyze-compiler-profile.js benchmarking/.profiles/compiler-tests/ +``` diff --git a/benchmarking/profile-compiler.js b/benchmarking/profile-compiler.js new file mode 100644 index 0000000000..323c2d27ea --- /dev/null +++ b/benchmarking/profile-compiler.js @@ -0,0 +1,94 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { analyze_compiler_profiles } from './analyze-compiler-profile.js'; + +const root = path.resolve(fileURLToPath(import.meta.url), '../..'); +const profiles_root = path.join(root, 'benchmarking/.profiles/compiler-tests'); +const requested_name = process.env.SVELTE_PROFILE_NAME; +const run_name = safe(requested_name || new Date().toISOString().replaceAll(':', '-')); +const run_dir = path.join(profiles_root, run_name); +const raw_dir = path.join(run_dir, 'raw'); +const test_args = process.argv.slice(2); +const sampling_interval = process.env.SVELTE_CPU_PROF_INTERVAL || '5000'; + +if (fs.existsSync(run_dir)) { + console.error(`Profile run already exists: ${path.relative(root, run_dir)}`); + process.exit(1); +} + +fs.mkdirSync(raw_dir, { recursive: true }); + +const revision = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8' +}); +const started_at = new Date().toISOString(); +const manifest_file = path.join(run_dir, 'manifest.json'); +const command = [ + 'pnpm', + 'test', + '--execArgv=--cpu-prof', + `--execArgv=--cpu-prof-dir=${raw_dir}`, + `--execArgv=--cpu-prof-interval=${sampling_interval}`, + ...test_args +]; + +write_manifest({ + command, + revision: revision.status === 0 ? revision.stdout.trim() : null, + started_at, + status: 'running' +}); + +console.log(`Compiler profiles will be written to ${path.relative(root, run_dir)}`); + +const result = spawnSync(command[0], command.slice(1), { + cwd: root, + stdio: 'inherit' +}); + +let analysis_error = null; + +try { + analyze_compiler_profiles(run_dir); + fs.writeFileSync(path.join(profiles_root, 'latest.txt'), `${run_name}\n`); +} catch (error) { + analysis_error = error instanceof Error ? error.stack || error.message : String(error); + console.error(analysis_error); +} + +write_manifest({ + command, + revision: revision.status === 0 ? revision.stdout.trim() : null, + started_at, + finished_at: new Date().toISOString(), + status: result.status === 0 && analysis_error === null ? 'completed' : 'failed', + test_exit_code: result.status, + test_signal: result.signal, + analysis_error +}); + +if (result.error) { + console.error(result.error); +} + +if (result.status === 0 && analysis_error === null) { + console.log( + `Compiler flame graph: ${path.relative(root, path.join(run_dir, 'flamegraph.speedscope.json'))}` + ); + console.log(`Compiler hotspot summary: ${path.relative(root, path.join(run_dir, 'summary.md'))}`); +} else { + process.exitCode = result.status || 1; +} + +/** @param {string} value */ +function safe(value) { + return value.replace(/[^a-z0-9._-]+/gi, '_'); +} + +/** @param {Record} manifest */ +function write_manifest(manifest) { + fs.writeFileSync(manifest_file, `${JSON.stringify(manifest, null, '\t')}\n`); +} diff --git a/benchmarking/run.js b/benchmarking/run.js index 80e40a5ff1..322960e866 100644 --- a/benchmarking/run.js +++ b/benchmarking/run.js @@ -1,94 +1,159 @@ +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import * as $ from '../packages/svelte/src/internal/client/index.js'; +import { compiler_benchmarks } from './benchmarks/compiler/index.js'; import { reactivity_benchmarks } from './benchmarks/reactivity/index.js'; import { ssr_benchmarks } from './benchmarks/ssr/index.js'; import { with_cpu_profile } from './utils.js'; -// e.g. `pnpm bench kairo` to only run the kairo benchmarks -const filters = process.argv.slice(2); - const PROFILE_DIR = './benchmarking/.profiles'; -const suites = [ - { - benchmarks: reactivity_benchmarks.filter( - (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) - ), - name: 'reactivity benchmarks' - }, - { - benchmarks: ssr_benchmarks.filter( - (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) - ), - name: 'server-side rendering benchmarks' - } -].filter((suite) => suite.benchmarks.length > 0); - -if (suites.length === 0) { - console.log('No benchmarks matched provided filters'); - process.exit(1); -} - -const COLUMN_WIDTHS = [25, 9, 9]; -const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b); - -const pad_right = (str, n) => str + ' '.repeat(n - str.length); -const pad_left = (str, n) => ' '.repeat(n - str.length) + str; +const single = process.env.BENCH_SINGLE; -let total_time = 0; -let total_gc_time = 0; +if (single) { + // child mode — run a single benchmark and report the result to the parent + const benchmark = [...compiler_benchmarks, ...reactivity_benchmarks, ...ssr_benchmarks].find( + (b) => b.label === single + ); -$.push({}, true); + if (!benchmark) { + throw new Error(`Unknown benchmark ${single}`); + } -try { - for (const { benchmarks, name } of suites) { - let suite_time = 0; - let suite_gc_time = 0; + $.push({}, true); + + const result = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); + + $.pop(); + + // exit via the callback so the message is guaranteed to be delivered + /** @type {NodeJS.Process} */ (process).send(result, () => process.exit(0)); +} else { + // parent mode — run every benchmark in its own child process, so that + // heap/GC/JIT state from one benchmark cannot contaminate the others + + // e.g. `pnpm bench kairo` to only run the kairo benchmarks + const filters = process.argv.slice(2); + + const suites = [ + // Commenting out because we rarely need to run it + // { + // benchmarks: compiler_benchmarks.filter( + // (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) + // ), + // name: 'compiler benchmarks' + // }, + { + benchmarks: reactivity_benchmarks.filter( + (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) + ), + name: 'reactivity benchmarks' + }, + { + benchmarks: ssr_benchmarks.filter( + (b) => filters.length === 0 || filters.some((f) => b.label.includes(f)) + ), + name: 'server-side rendering benchmarks' + } + ].filter((suite) => suite.benchmarks.length > 0); - console.log(`\nRunning ${name}...\n`); - console.log( - pad_right('Benchmark', COLUMN_WIDTHS[0]) + - pad_left('Time', COLUMN_WIDTHS[1]) + - pad_left('GC time', COLUMN_WIDTHS[2]) - ); - console.log('='.repeat(TOTAL_WIDTH)); + if (suites.length === 0) { + console.log('No benchmarks matched provided filters'); + process.exit(1); + } - for (const benchmark of benchmarks) { - const results = await with_cpu_profile(PROFILE_DIR, benchmark.label, () => benchmark.fn()); + const filename = fileURLToPath(import.meta.url); + + /** + * @param {string} label + * @returns {Promise<{ time: number, gc_time: number }>} + */ + const run_benchmark = (label) => { + return new Promise((fulfil, reject) => { + const child = fork(filename, [], { + env: { + ...process.env, + BENCH_SINGLE: label + } + }); + + /** @type {{ time: number, gc_time: number } | null} */ + let result = null; + + child.on('message', (message) => { + result = /** @type {{ time: number, gc_time: number }} */ (message); + }); + + child.on('error', reject); + + child.on('exit', (code) => { + if (result === null) { + reject(new Error(`benchmark ${label} exited with code ${code}`)); + } else { + fulfil(result); + } + }); + }); + }; + + const COLUMN_WIDTHS = [25, 9, 9]; + const TOTAL_WIDTH = COLUMN_WIDTHS.reduce((a, b) => a + b); + + /** @type {(str: string, n: number) => string} */ + const pad_right = (str, n) => str + ' '.repeat(n - str.length); + /** @type {(str: string, n: number) => string} */ + const pad_left = (str, n) => ' '.repeat(n - str.length) + str; + + let total_time = 0; + let total_gc_time = 0; + + try { + for (const { benchmarks, name } of suites) { + let suite_time = 0; + let suite_gc_time = 0; + + console.log(`\nRunning ${name}...\n`); + console.log( + pad_right('Benchmark', COLUMN_WIDTHS[0]) + + pad_left('Time', COLUMN_WIDTHS[1]) + + pad_left('GC time', COLUMN_WIDTHS[2]) + ); + console.log('='.repeat(TOTAL_WIDTH)); + + for (const benchmark of benchmarks) { + const results = await run_benchmark(benchmark.label); + console.log( + pad_right(benchmark.label, COLUMN_WIDTHS[0]) + + pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) + + pad_left(results.gc_time.toFixed(2), COLUMN_WIDTHS[2]) + ); + total_time += results.time; + total_gc_time += results.gc_time; + suite_time += results.time; + suite_gc_time += results.gc_time; + } + + console.log('='.repeat(TOTAL_WIDTH)); console.log( - pad_right(benchmark.label, COLUMN_WIDTHS[0]) + - pad_left(results.time.toFixed(2), COLUMN_WIDTHS[1]) + - pad_left(results.gc_time.toFixed(2), COLUMN_WIDTHS[2]) + pad_right('suite', COLUMN_WIDTHS[0]) + + pad_left(suite_time.toFixed(2), COLUMN_WIDTHS[1]) + + pad_left(suite_gc_time.toFixed(2), COLUMN_WIDTHS[2]) ); - total_time += results.time; - total_gc_time += results.gc_time; - suite_time += results.time; - suite_gc_time += results.gc_time; + console.log('='.repeat(TOTAL_WIDTH)); } - console.log('='.repeat(TOTAL_WIDTH)); - console.log( - pad_right('suite', COLUMN_WIDTHS[0]) + - pad_left(suite_time.toFixed(2), COLUMN_WIDTHS[1]) + - pad_left(suite_gc_time.toFixed(2), COLUMN_WIDTHS[2]) - ); - console.log('='.repeat(TOTAL_WIDTH)); - } - - if (PROFILE_DIR !== null) { console.log(`\nCPU profiles written to ${PROFILE_DIR}`); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + process.exit(1); } -} catch (e) { - // eslint-disable-next-line no-console - console.error(e); - process.exit(1); -} -$.pop(); + console.log(''); -console.log(''); - -console.log( - pad_right('total', COLUMN_WIDTHS[0]) + - pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) + - pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2]) -); + console.log( + pad_right('total', COLUMN_WIDTHS[0]) + + pad_left(total_time.toFixed(2), COLUMN_WIDTHS[1]) + + pad_left(total_gc_time.toFixed(2), COLUMN_WIDTHS[2]) + ); +} diff --git a/documentation/docs/02-runes/04-$effect.md b/documentation/docs/02-runes/04-$effect.md index a13fc7bc46..de18add08f 100644 --- a/documentation/docs/02-runes/04-$effect.md +++ b/documentation/docs/02-runes/04-$effect.md @@ -205,6 +205,8 @@ In rare cases, you may need to run code _before_ the DOM updates. For this we ca ``` +`$effect.pre` runs before DOM updates that are scheduled after it, not before every DOM mutation in the flush - DOM of parent components may already be updated. When using [await expressions](await-expressions), block updates like `{#if ...}` and `{#each ...}` in the same component also run before `$effect.pre`. + Apart from the timing, `$effect.pre` works exactly like `$effect`. ## `$effect.tracking` diff --git a/documentation/docs/02-runes/06-$bindable.md b/documentation/docs/02-runes/06-$bindable.md index 3675a56b16..a3be662785 100644 --- a/documentation/docs/02-runes/06-$bindable.md +++ b/documentation/docs/02-runes/06-$bindable.md @@ -52,3 +52,5 @@ In this case, you can specify a fallback value for when no prop is passed at all /// file: FancyInput.svelte let { value = $bindable('fallback'), ...props } = $props(); ``` + +When a bindable prop has a fallback value, the parent must pass a value other than `undefined` if it uses `bind:`. This avoids ambiguity about which value should apply, since the parent and child should share the same value for a binding. diff --git a/documentation/docs/03-template-syntax/01-basic-markup.md b/documentation/docs/03-template-syntax/01-basic-markup.md index feecfe033e..89e01cf1ed 100644 --- a/documentation/docs/03-template-syntax/01-basic-markup.md +++ b/documentation/docs/03-template-syntax/01-basic-markup.md @@ -202,7 +202,7 @@ You can add a special comment starting with `@component` that will show up when - You can also use code blocks here. - Usage: ```html -
+
``` --> + +{#each boxes as box} + {const area = box.width * box.height} + {const label = `${box.width} × ${box.height} = ${area}`} + +

{label}

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

Hello {user.name}

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

{greeting}

+ + +{/if} +``` + + +Declaration tags can be used anywhere inside the component. They can reference values declared outside themselves (for example in the ``, + '$state.raw': ``, + '$state.eager': ``, + '$state.snapshot': ``, + $derived: ``, + '$derived.by': ``, + $props: ``, + '$props.id': ``, + $bindable: ``, + $effect: ``, + '$effect.pre': ``, + '$effect.tracking': ``, + '$effect.root': ``, + '$effect.pending': ``, + $inspect: ``, + '$inspect().with': ``, + '$inspect.trace': ``, + $host: `\n` +}; + +function rune_fixture(rune: (typeof RUNES)[number]): string { + if (!Object.hasOwn(rune_fixtures, rune)) { + throw new Error(`Fixture missing for ${rune}`); + } + + return rune_fixtures[rune]; +} + +/** + * Compiled-fixture sources for directives. Bindings are covered by the + * `binding_properties` enumeration; transitions, animate, actions, and + * `@attach` need explicit fixtures because they require accompanying + * imports or surrounding markup. + */ +const TESTED_DIRECTIVES = [ + { + name: '`transition:` / `in:` / `out:`', + source: `{#if show}
{/if}` + }, + { + name: '`animate:`', + source: `{#each items as item (item)}
{item}
{/each}` + }, + { + name: '`use:` actions', + source: `
` + }, + { + name: '`@attach`', + source: `
` + }, + { + name: '`{@html ...}`', + source: `{@html html}` + }, + { + name: 'Custom elements (``)', + source: `\n
` + } +]; + +/** + * Filesystem-safe identifier for an importee like `svelte/internal/client`. + */ +function safe_name(importee: string): string { + return importee.replace(/[^a-z0-9]+/gi, '_'); +} + +/** + * Bundle an entry the way users receive it, so we scan the same code the + * browser does. Mirrors `check-treeshakeability.js`. + * + * `entry_code` is virtual module source: typically `export * from + * 'svelte/...'` for a runtime entry, or compiled fixture JS for a per-feature + * scan. `silent` suppresses rollup's circular-dependency warnings — used for + * fixture bundles where they're known and noisy. + */ +async function bundle(entry_code: string, options: { silent?: boolean } = {}): Promise { + const built = await rollup({ + input: '__entry__', + plugins: [ + virtual({ __entry__: entry_code }), + { + name: 'resolve-svelte', + resolveId(id: string) { + if (id.startsWith('svelte')) { + const entry = pkg.exports[id.replace('svelte', '.')]; + if (!entry) return; + if (typeof entry === 'string') return path.resolve(pkg_dir, entry); + const file = entry.browser ?? entry.default; + if (file) return path.resolve(pkg_dir, file); + } + } + }, + nodeResolve({ exportConditions: ['production', 'import', 'browser', 'default'] }) + ], + // Treat optional peers / Node-only branches as external so we only scan + // code that actually runs in the browser. + external: ['esm-env'], + onwarn: options.silent + ? () => {} + : (warning, handler) => { + if (warning.code === 'CIRCULAR_DEPENDENCY') return; + handler(warning); + } + }); + + const { output } = await built.generate({ format: 'esm' }); + await built.close(); + + return output + .filter((chunk): chunk is OutputChunk => chunk.type === 'chunk') + .map((chunk) => chunk.code) + .join('\n'); +} + +/** + * Read every compiler-emitted client file from the snapshot tests. These + * fixtures cover the full range of patterns the compiler emits — bindings, + * transitions, ``, async derived, hydration markers, etc. + */ +function load_compiler_output_fixtures(): CompilerFixture[] { + const fixtures: CompilerFixture[] = []; + + for (const sample of fs.readdirSync(snapshot_dir)) { + const client_dir = path.join(snapshot_dir, sample, '_expected/client'); + if (!fs.existsSync(client_dir)) continue; + + for (const file of fs.readdirSync(client_dir)) { + if (!file.endsWith('.js')) continue; + fixtures.push({ + filename: `${sample}/${file}`, + code: fs.readFileSync(path.join(client_dir, file), 'utf-8') + }); + } + } + + return fixtures; +} + +/** + * Combine per-feature version data into a single Record. Takes the max + * (strictest) version per browser across the input feature IDs. + * + * `null` propagates as "not supported" — if any contributing feature + * marks a browser unsupported, the merged record does too. + * + * Returns `null` if NONE of the IDs have versions in `web-features` or + * supplemental rules, so callers can fall back to year-based mapping. + */ +function versions_from_features(ids: Iterable): BrowserVersions | null { + const merged: BrowserVersions = {}; + let any_found = false; + + for (const id of ids) { + const support = versions_for_feature(id); + if (!support) continue; + any_found = true; + for (const [browser, version] of Object.entries(support)) { + const current = merged[browser]; + // `null` means "not supported"; propagate it directly. + if (version === null) { + merged[browser] = null; + continue; + } + if (current === null) continue; // already known unsupported + if (current === undefined || Number(version) > Number(current)) { + merged[browser] = version; + } + } + } + + return any_found ? merged : null; +} + +/** + * Highest baseline year among `detected`, and the set of feature IDs that + * drove it. Used both for the aggregate runtime floor and for per-fixture + * scans — the two differ only in their ignore set. + */ +function compute_floor( + detected: Iterable, + ignore: Set +): { year: number; drivers: Set } { + let year = 0; + const drivers = new Set(); + for (const id of detected) { + if (ignore.has(id)) continue; + const y = baseline_year_for_feature(id); + if (!y) continue; + if (y > year) { + year = y; + drivers.clear(); + } + if (y === year) drivers.add(id); + } + return { year, drivers }; +} + +/** + * Run the TS-based detector across the runtime bundles and the compiler- + * output fixtures, then compute the highest baseline year among the + * detected features (after subtracting `AGGREGATE_IGNORE`). + * + * `runtime_files` are absolute paths to runtime bundle files. Returns the + * minimum Baseline year the combined code satisfies. + */ +function find_minimum_target( + runtime_files: string[], + compiler_fixtures: CompilerFixture[] +): number { + // Type-aware walk over the runtime bundles. + const detected = detect_features(runtime_files); + + // Syntax-only walk over the compiler-output fixtures (text only, no + // program context; the bare TS source-file parser handles the syntax + // features the compiler emits). + for (const fixture of compiler_fixtures) { + for (const id of detect_features_in_text(fixture.code)) detected.add(id); + } + + const { year, drivers } = compute_floor(detected, AGGREGATE_IGNORE); + // Floor at 2015 so the docs never claim a pre-ES6 target if every + // detected feature happens to lack a Baseline year. + const final_year = Math.max(year, 2015); + + console.log(` → ${final_year} (features that drove the floor:)`); + for (const id of [...drivers].sort()) { + console.log(` - ${id}`); + } + + return final_year; +} + +/** + * Verify every entry in `BEHAVIORAL_IGNORE` is actually used by the + * runtime. Without this, a behavioural suppression can outlive the API + * it suppresses — the comment stays in the config pointing at code that + * no longer exists. + * + * `SAFE_TO_IGNORE` entries are exempt: they're safe to carry regardless + * of whether the runtime currently uses the API. + */ +function validate_ignore_features(runtime_files: string[]): void { + if (BEHAVIORAL_IGNORE.size === 0) return; + const detected = detect_features(runtime_files); + const stale = [...BEHAVIORAL_IGNORE].filter((id) => !detected.has(id)); + if (stale.length > 0) { + throw new Error( + `BEHAVIORAL_IGNORE contains entries that the detector does not flag — ` + + `they can be removed:\n` + + stale.map((id) => ` - ${id}`).join('\n') + + `\n\nEdit \`packages/svelte/scripts/generate-browser-support.js\` ` + + `and delete the stale entries. If the API was removed from the runtime ` + + `as part of this change, that is exactly the intended signal.` + ); + } +} + +/** + * Build the full list of user-facing features to test for conditional + * floor bumps. Each feature gets a self-contained fixture, compiled and + * bundled like real user code, then scanned. If the bundle's floor + * exceeds the runtime floor, a row is auto-emitted in the docs. + * + * `subpackage_exports` maps subpath → list of exported symbols, produced by + * `enumerate_subpackage_exports`. Passed in rather than computed here so the + * dynamic-import discovery can happen once in `main`. + */ +function enumerate_features(subpackage_exports: Record): Feature[] { + const features: Feature[] = []; + + // Every `bind:*` accepted by the compiler. Element selection respects + // the `valid_elements` constraint declared in `binding_properties`. + for (const [name, props] of Object.entries(binding_properties)) { + const fixture = binding_fixture(name, props); + if (fixture) { + features.push({ + name: `\`bind:${name}\``, + kind: 'svelte', + source: fixture + }); + } + } + + for (const [module, exports] of Object.entries(subpackage_exports)) { + for (const exp of exports) { + features.push({ + name: `\`${exp}\` from \`${module}\``, + kind: 'js', + source: `import { ${exp} } from '${module}'; export const _ = ${exp};` + }); + } + } + + for (const rune of RUNES) { + features.push({ + name: `\`${rune}\``, + kind: 'svelte', + source: rune_fixture(rune) + }); + } + + for (const directive of TESTED_DIRECTIVES) { + features.push({ name: directive.name, kind: 'svelte', source: directive.source }); + } + + return features; +} + +/** + * Produce the `.svelte` source for a single binding fixture. Returns + * `null` for bindings the compiler treats as elements rather than + * properties (none currently, but defensive). + */ +function binding_fixture(name: string, props: BindingProperty): string { + // Map declared `valid_elements` to a concrete element + minimal attrs + // so the compiler accepts the binding. + const tag = (props.valid_elements ?? ['div'])[0]; + + const reactive = `let v = $state();`; + + if (tag === 'svelte:window') { + return ``; + } + if (tag === 'svelte:document') { + return ``; + } + if (tag === 'input') { + // `bind:checked` and `bind:group` require type="checkbox" | "radio" + const type = + name === 'checked' || name === 'indeterminate' + ? ' type="checkbox"' + : name === 'group' + ? ' type="radio" value="a"' + : name === 'files' + ? ' type="file"' + : ''; + return ``; + } + if (tag === 'details') { + return `
x
`; + } + + return `<${tag} bind:${name}={v}>`; +} + +/** + * Compile a `.svelte` fixture to JS (no-op for `.js` fixtures), then + * bundle the result through the shared `bundle` helper. Fixtures are tiny + * so circular-dep warnings from the Svelte runtime are silenced. + */ +async function bundle_fixture(feature: Feature): Promise { + const entry_code = + feature.kind === 'svelte' + ? svelte_compile(feature.source, { + generate: 'client', + filename: 'Fixture.svelte', + dev: false + }).js.code + : feature.source; + return bundle(entry_code, { silent: true }); +} + +/** + * Detect features in a single fixture bundle and report the per-fixture + * floor year along with the IDs that drove it. Used for the per-feature + * conditional table. + * + * `fixture_file` is the absolute path to the `.ts` bundle. + */ +function scan_fixture(fixture_file: string): { + year: number; + driving_ids: string[]; +} { + const { year, drivers } = compute_floor(detect_features([fixture_file]), SAFE_TO_IGNORE); + + return { + year, + driving_ids: [...drivers] + }; +} + +/** + * Iterate every feature, bundle its fixture, scan it. Return the rows + * that need to appear in the conditional-features table. + */ +async function find_all_conditional_features( + runtime_floor: RuntimeFloor, + subpackage_exports: Record +): Promise { + const runtime_year = typeof runtime_floor === 'number' ? runtime_floor : Infinity; + const features = enumerate_features(subpackage_exports); + const rows: ConditionalRow[] = []; + + const missing_doc_links: string[] = []; + + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + process.stdout.write(`\r ${i + 1}/${features.length} ${feature.name}`.padEnd(80)); + + let bundle_code; + try { + bundle_code = await bundle_fixture(feature); + } catch { + continue; // some fixtures (rare element combos) may fail to compile + } + + // Write the bundle so the type-aware scanner can resolve its types. + const fixture_file = path.join(tmp_dir, `fixture_${i}.ts`); + fs.writeFileSync(fixture_file, bundle_code); + + const scanned = scan_fixture(fixture_file); + const final_year = scanned.year; + + // Skip features at or below the runtime floor — they don't need a row. + if (final_year <= runtime_year || final_year === 0) continue; + + // Use exact per-feature versions where available (from web-features + // or supplemental rules), falling back to the conservative year + // mapping only if no feature has explicit version data. + let versions = versions_from_features(scanned.driving_ids); + if (!versions) { + try { + versions = browser_versions_for(final_year); + } catch { + continue; + } + } + + let doc_link = doc_links[feature.name]; + if (doc_link === undefined) { + doc_link = null; + missing_doc_links.push(feature.name); + } + + rows.push({ + name: feature.name, + doc_link, + versions, + baseline_year: final_year + }); + } + process.stdout.write('\n'); + + if (missing_doc_links.length) { + throw new Error(`Missing documentation url for some features. +Add them to the \`doc_links\` map in \`scripts/generate-browser-support.ts\`, or add an explicit \`null\` if they don't have a documentation url. +${missing_doc_links.map((name) => ` - "${name}"`).join('\n')}`); + } + + return rows; +} + +function render_conditional_table(features: ConditionalRow[], runtime_floor: RuntimeFloor): string { + if (features.length === 0) { + return '_No features currently require browser versions newer than the runtime floor._'; + } + features.sort((a, b) => a.name.localeCompare(b.name)); + + const browsers = [ + ['chrome', 'Chrome/Edge'], + ['firefox', 'Firefox'], + ['safari', 'Safari'] + ] as const; + + const floor_versions = browser_versions_for(runtime_floor); + + const rows: string[][] = []; + for (const row of features) { + const name_cell = row.doc_link ? `[${row.name}](${row.doc_link})` : row.name; + const versions = browsers.map(([key]) => { + const v = row.versions[key]; + if (v === null) return 'not supported'; + if (v === undefined) return ''; + const floor_v = floor_versions[key]; + if (floor_v && Number(v) <= Number(floor_v)) + return ''; + + return v; + }); + rows.push([name_cell, ...versions]); + } + + return render_markdown_table(['Feature', ...browsers.map(([, label]) => `${label}`)], rows); +} + +function browser_versions_for(target: RuntimeFloor): Record { + // `targetYear` returns the minimum versions in which every feature that + // reached Baseline by the end of that year is supported. If the lint + // search fell through to `'newly'`, we use the current year — that gives + // the most recent Newly-available cutoff, which is the strongest + // statement `baseline-browser-mapping` is able to make. + const target_year = typeof target === 'number' ? target : new Date().getFullYear(); + + const versions = getCompatibleVersions({ + targetYear: target_year, + includeDownstreamBrowsers: true + }); + + // The core Baseline browsers plus the downstream browsers worth listing + // in the docs. Downstream browsers come from `baseline-browser-mapping`'s + // dataset and represent the highest-traffic Chromium derivatives; the + // long tail (UC, QQ, Yandex, in-app Facebook/Instagram browsers, etc.) + // is omitted to keep the table focused. + const visible_browsers = new Set([ + 'chrome', + 'chrome_android', + 'edge', + 'firefox', + 'firefox_android', + 'safari', + 'safari_ios', + 'opera', + 'opera_android', + 'samsunginternet_android', + 'webview_android' + ]); + + const suffixes = ['_android', '_ios']; + + const lookup: Record = {}; + outer: for (const { browser, version } of versions) { + if (visible_browsers.has(browser)) { + for (const suffix of suffixes) { + // skip e.g. 'Chrome (Android)' if it matches Chrome + if (browser.endsWith(suffix) && version === lookup[browser.replace(suffix, '')]) { + continue outer; + } + } + + lookup[browser] = version; + } + } + + return lookup; +} + +const BROWSER = { + chrome: 'Chrome', + edge: 'Edge', + firefox: 'Firefox', + safari: 'Safari', + opera: 'Opera', + samsung_internet: 'Samsung Internet', + webview_android: 'Android WebView', + internet_explorer: 'Internet Explorer' +}; + +function render_browser_table(versions: Record, target: RuntimeFloor): string { + const rows: Array<[string, string]> = [ + [BROWSER.chrome, versions.chrome], + [`${BROWSER.chrome} (Android)`, versions.chrome_android] + ]; + + if (versions.chrome === versions.edge) { + rows[0][0] += `/${BROWSER.edge}`; + } else { + rows.push([BROWSER.edge, versions.edge]); + } + + rows.push( + [BROWSER.firefox, versions.firefox], + [`${BROWSER.firefox} (Android)`, versions.firefox_android], + [BROWSER.safari, versions.safari], + [`${BROWSER.safari} (iOS)`, versions.safari_ios], + [BROWSER.opera, versions.opera], + [`${BROWSER.opera} (Android)`, versions.opera_android], + [BROWSER.samsung_internet, versions.samsunginternet_android], + [BROWSER.webview_android, versions.webview_android], + [BROWSER.internet_explorer, 'not supported'] + ); + + const target_label = target === 'newly' ? '"newly available"' : target; + + return ( + render_markdown_table( + ['Browser', 'Minimum version'], + rows.filter(([, version]) => version !== undefined) + ) + + `\n\n> [!NOTE] This equates to a Baseline target of ${target_label}.` + ); +} + +function render_markdown_table(headers: string[], rows: string[][]): string { + return `| ${headers.join(' | ')} | +| ${headers.map(() => '-').join(' | ')} | +${rows.map((row) => `| ${row.join(' | ')} |`).join('\n')} +`; +} + +async function main() { + console.log('Preparing scratch directory…'); + // Wipe and recreate so stale bundles can't leak into the next scan. + fs.rmSync(tmp_dir, { recursive: true, force: true }); + fs.mkdirSync(tmp_dir, { recursive: true }); + + try { + console.log('Bundling runtime entries…'); + const runtime_files: string[] = []; + for (const importee of browser_subpaths().map(importee_for)) { + // `import * as` + re-export keeps default and named exports + // alive, so flag modules (only a default export) don't produce + // empty chunks but their code still ends up in the scan. + const code = await bundle(`import * as __ns from '${importee}'; export default __ns;`); + const file = path.join(tmp_dir, `${safe_name(importee)}.ts`); + fs.writeFileSync(file, code); + runtime_files.push(file); + } + + console.log('Loading compiler-output fixtures…'); + const compiler_fixtures = load_compiler_output_fixtures(); + console.log(` (${compiler_fixtures.length} fixtures found)`); + + console.log('Searching for the minimum Baseline target (type-aware)…'); + const target = find_minimum_target(runtime_files, compiler_fixtures); + + console.log('Checking BEHAVIORAL_IGNORE for stale entries…'); + validate_ignore_features(runtime_files); + console.log(' no stale entries'); + + console.log('Enumerating subpackage exports…'); + const subpackage_exports = await enumerate_subpackage_exports(); + const total_exports = Object.values(subpackage_exports).reduce((n, list) => n + list.length, 0); + console.log( + ` ${total_exports} export(s) across ${Object.keys(subpackage_exports).length} subpackage(s)` + ); + + console.log('Scanning per-feature fixtures for conditional requirements…'); + const conditional_rows = await find_all_conditional_features(target, subpackage_exports); + console.log( + ` ${conditional_rows.length} feature(s) require browsers newer than the runtime floor` + ); + + console.log('Resolving browser versions…'); + const versions = browser_versions_for(target); + + console.log('Rewriting docs page…'); + generate('browser-support.md', render_browser_table(versions, target)); + generate('browser-support-features.md', render_conditional_table(conditional_rows, target)); + + console.log('Done.'); + } finally { + fs.rmSync(tmp_dir, { recursive: true, force: true }); + } +} + +function generate(file: string, content: string): void { + const filename = path.join(docs_dir, file); + + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } catch {} + + const backlink = path.relative(filename, fileURLToPath(import.meta.url)); + + fs.writeFileSync(filename, `\n\n${content}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/svelte/scripts/process-messages/index.js b/packages/svelte/scripts/process-messages/index.js index 1904f1dfb4..c8d7418db4 100644 --- a/packages/svelte/scripts/process-messages/index.js +++ b/packages/svelte/scripts/process-messages/index.js @@ -378,7 +378,6 @@ function run() { }; const block = esrap.print( - // @ts-expect-error some bullshit /** @type {ESTree.Program} */ ({ ...ast, body: [clone] }), ts({ comments: [jsdoc_clone] }) ).code; diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index bbbc86c997..a9b2cebe1a 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -24,7 +24,7 @@ declare function $state(initial: T): T; declare function $state(): T | undefined; declare namespace $state { - type Primitive = string | number | boolean | null | undefined; + type Primitive = string | number | bigint | boolean | null | undefined; type TypedArray = | Int8Array @@ -261,7 +261,7 @@ declare function $effect(fn: () => void | (() => void)): void; declare namespace $effect { /** * Runs code right before a component is mounted to the DOM, and then whenever its dependencies change, i.e. `$state` or `$derived` values. - * The timing of the execution is right before the DOM is updated. + * The timing of the execution is right before the DOM that comes after it is updated; parent DOM may already have been updated by the time it runs. * * Example: * ```ts diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 976925d181..ccb7fe833e 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1004,6 +1004,24 @@ export function debug_tag_invalid_arguments(node) { e(node, 'debug_tag_invalid_arguments', `{@debug ...} arguments must be identifiers, not arbitrary expressions\nhttps://svelte.dev/e/debug_tag_invalid_arguments`); } +/** + * Declaration tags must be `let` or `const` declarations + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function declaration_tag_invalid_type(node) { + e(node, 'declaration_tag_invalid_type', `Declaration tags must be \`let\` or \`const\` declarations\nhttps://svelte.dev/e/declaration_tag_invalid_type`); +} + +/** + * Declaration tags cannot be used in legacy mode + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function declaration_tag_no_legacy_mode(node) { + e(node, 'declaration_tag_no_legacy_mode', `Declaration tags cannot be used in legacy mode\nhttps://svelte.dev/e/declaration_tag_no_legacy_mode`); +} + /** * Directive value must be a JavaScript expression enclosed in curly braces * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index 1d822514b9..599206ac6a 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -141,7 +141,8 @@ export function parseCss(source) { type: 'StyleSheetFile', start: 0, end: source.length, - children + children, + comments: parser.css_comments }; } diff --git a/packages/svelte/src/compiler/legacy.js b/packages/svelte/src/compiler/legacy.js index 459858fe13..c51cd8434b 100644 --- a/packages/svelte/src/compiler/legacy.js +++ b/packages/svelte/src/compiler/legacy.js @@ -262,6 +262,10 @@ export function convert(source, ast) { }; }, // @ts-ignore + DeclarationTag(node) { + return node; + }, + // @ts-ignore KeyBlock(node, { visit }) { remove_surrounding_whitespace_nodes(node.fragment.nodes); return { diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js index 0370155c12..3baef3ecae 100644 --- a/packages/svelte/src/compiler/migrate/index.js +++ b/packages/svelte/src/compiler/migrate/index.js @@ -1590,7 +1590,12 @@ function migrate_slot_usage(node, path, state) { */ function extract_type_and_comment(declarator, state, path) { const str = state.str; - const parent = path.at(-1); + let parent = path.at(-1); + + if (parent?.type === 'Program') { + // We don't want comments from the program node + parent = undefined; + } // Try to find jsdoc above the declaration let comment_node = /** @type {Node} */ (parent)?.leadingComments?.at(-1); diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 45a7c2a58c..add61ea19f 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -1,10 +1,11 @@ -/** @import { Comment, Program } from 'estree' */ +/** @import { Comment, Program, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Parser } from './index.js' */ import * as acorn from 'acorn'; import { walk } from 'zimmerframe'; import { tsPlugin } from '@sveltejs/acorn-typescript'; import * as e from '../../errors.js'; +import { locator } from '../../state.js'; const JSParser = acorn.Parser; const TSParser = JSParser.extend(tsPlugin()); @@ -59,7 +60,7 @@ export function parse(source, comments, typescript, is_script) { return /** @type {Program} */ (ast); } catch (err) { - // TODO the `return` in necessary for TS<7 due to a bug; otherwise + // TODO the `return` is necessary for TS<7 due to a bug; otherwise // the `finally` block is regarded as unreachable return handle_parse_error(err); } finally { @@ -87,7 +88,8 @@ export function parse_expression_at(parser, source, index) { sourceType: 'module', ecmaVersion: 16, locations: true, - preserveParens: true + preserveParens: true, + startLocation: start_location(parser, index) }); add_comments(ast); @@ -98,6 +100,68 @@ export function parse_expression_at(parser, source, index) { } } +/** + * @param {Parser} parser + * @param {string} source + * @param {number} index + * @returns {Statement} + */ +export function parse_statement_at(parser, source, index) { + // cast to `any`: acorn's Parser constructor and parseStatement/nextToken aren't in its public types + const acorn = /** @type {any} */ (parser.ts ? TSParser : JSParser); + const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index); + + try { + // This is like parseExpressionAt but for statements + const p = new acorn( + { + onComment, + sourceType: 'module', + ecmaVersion: 16, + locations: true, + startLocation: start_location(parser, index) + }, + source, + index + ); + p.nextToken(); + const statement = /** @type {Statement} */ (p.parseStatement(null, true, Object.create(null))); + add_comments(/** @type {acorn.Node} */ (statement)); + return statement; + } catch (err) { + // A statement that runs to the end of the source (e.g. an unterminated declaration tag) + // is an EOF, not a stray token; preserve the friendlier `unexpected_eof` diagnostic. + if (/** @type {any} */ (err).pos === source.length) e.unexpected_eof(source.length); + handle_parse_error(err); + } +} + +const regex_non_lf_line_break = /\r(?!\n)|[\u2028\u2029]/; + +let last_template = ''; +let lf_only = true; + +/** + * Without `startLocation`, acorn counts the lines before `index` on every call + * @param {Parser} parser + * @param {number} index + */ +function start_location(parser, index) { + return has_lf_line_breaks_only(parser) ? locator(index) : undefined; +} + +/** + * acorn breaks lines on bare `\r`, `\u2028` and `\u2029`, which the locator doesn't + * @param {Parser} parser + */ +export function has_lf_line_breaks_only(parser) { + if (parser.template !== last_template) { + last_template = parser.template; + lf_only = !regex_non_lf_line_break.test(last_template); + } + return lf_only; +} + const regex_position_indicator = / \(\d+:\d+\)$/; /** diff --git a/packages/svelte/src/compiler/phases/1-parse/index.js b/packages/svelte/src/compiler/phases/1-parse/index.js index 5242cba31f..8edd403487 100644 --- a/packages/svelte/src/compiler/phases/1-parse/index.js +++ b/packages/svelte/src/compiler/phases/1-parse/index.js @@ -10,25 +10,7 @@ import read_options from './read/options.js'; import { is_reserved } from '../../../utils.js'; import { disallow_children } from '../2-analyze/visitors/shared/special-element.js'; import * as state from '../../state.js'; - -/** @param {number} cc */ -function is_whitespace(cc) { - // fast path for common whitespace - if (cc === 32 || (cc <= 13 && cc >= 9)) return true; - // rare whitespace — \u00a0, \u1680, \u2000-\u200a, \u2028, \u2029, \u202f, \u205f, \u3000, \ufeff - if (cc < 160) return false; - return ( - cc === 160 || - cc === 5760 || - (cc >= 8192 && cc <= 8202) || - cc === 8232 || - cc === 8233 || - cc === 8239 || - cc === 8287 || - cc === 12288 || - cc === 65279 - ); -} +import { is_whitespace } from './utils/whitespace.js'; const regex_lang_attribute = /|]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s]+)\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/g; @@ -50,6 +32,9 @@ export class Parser { /** */ index = 0; + /** @type {AST.CSS.CSSComment[]} */ + css_comments = []; + /** * Creates a minimal parser instance for CSS-only parsing. * Skips Svelte component parsing setup. @@ -61,6 +46,7 @@ export class Parser { parser.template = source; parser.index = 0; parser.loose = false; + parser.css_comments = []; return parser; } @@ -274,8 +260,27 @@ export class Parser { }; } + /** @param {string} delimiter */ + read_until(delimiter) { + if (this.index >= this.template.length) { + if (this.loose) return ''; + e.unexpected_eof(this.template.length); + } + + const start = this.index; + const index = this.template.indexOf(delimiter, start); + + if (index !== -1) { + this.index = index; + return this.template.slice(start, this.index); + } + + this.index = this.template.length; + return this.template.slice(start); + } + /** @param {RegExp} pattern */ - read_until(pattern) { + read_until_regex(pattern) { if (this.index >= this.template.length) { if (this.loose) return ''; e.unexpected_eof(this.template.length); @@ -298,11 +303,15 @@ export class Parser { e.expected_whitespace(this.index); } + this.index++; this.allow_whitespace(); } pop() { - this.fragments.pop(); + const fragment = this.fragments.pop(); + if (fragment?.metadata.transparent && fragment.nodes.some((n) => n.type === 'DeclarationTag')) { + fragment.metadata.transparent = false; + } return this.stack.pop(); } diff --git a/packages/svelte/src/compiler/phases/1-parse/read/context.js b/packages/svelte/src/compiler/phases/1-parse/read/context.js index cdb239ef5b..821fdcf338 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/context.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/context.js @@ -2,7 +2,6 @@ /** @import { Parser } from '../index.js' */ import { match_bracket } from '../utils/bracket.js'; import { parse_expression_at, remove_parens } from '../acorn.js'; -import { regex_not_newline_characters } from '../../patterns.js'; import * as e from '../../../errors.js'; /** @@ -33,24 +32,10 @@ export default function read_pattern(parser) { i = match_bracket(parser, start); parser.index = i; - const pattern_string = parser.template.slice(start, i); - - // the length of the `space_with_newline` has to be start - 1 - // because we added a `(` in front of the pattern_string, - // which shifted the entire string to right by 1 - // so we offset it by removing 1 character in the `space_with_newline` - // to achieve that, we remove the 1st space encountered, - // so it will not affect the `column` of the node - let space_with_newline = parser.template - .slice(0, start) - .replace(regex_not_newline_characters, ' '); - const first_space = space_with_newline.indexOf(' '); - space_with_newline = - space_with_newline.slice(0, first_space) + space_with_newline.slice(first_space + 1); - + // acorn never reads before `start`, so the template itself can serve as the prefix /** @type {any} */ let expression = remove_parens( - parse_expression_at(parser, `${space_with_newline}(${pattern_string} = 1)`, start - 1) + parse_expression_at(parser, parser.template.slice(0, i) + ' = 1', start) ); expression = expression.left; @@ -80,7 +65,7 @@ function read_type_annotation(parser) { const insert = '_ as '; let a = parser.index - insert.length; const template = - parser.template.slice(0, a).replace(/[^\n]/g, ' ') + + parser.template.slice(0, a) + insert + // If this is a type annotation for a function parameter, Acorn-TS will treat subsequent // parameters as part of a sequence expression instead, and will then error on optional diff --git a/packages/svelte/src/compiler/phases/1-parse/read/expression.js b/packages/svelte/src/compiler/phases/1-parse/read/expression.js index 1c8f097c2f..b63ad443a8 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/expression.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/expression.js @@ -1,9 +1,13 @@ -/** @import { Expression } from 'estree' */ +/** @import { Expression, Identifier } from 'estree' */ /** @import { Parser } from '../index.js' */ -import { parse_expression_at, remove_parens } from '../acorn.js'; +// @ts-expect-error acorn type definitions are borked in the release we use +import { isIdentifierStart, isIdentifierChar } from 'acorn'; +import { has_lf_line_breaks_only, parse_expression_at, remove_parens } from '../acorn.js'; import { regex_whitespace } from '../../patterns.js'; import * as e from '../../../errors.js'; import { find_matching_bracket } from '../utils/bracket.js'; +import { is_reserved } from '../../../../utils.js'; +import { locator } from '../../../state.js'; /** * @param {Parser} parser @@ -33,6 +37,9 @@ export function get_loose_identifier(parser, opening_token) { * @returns {Expression} */ export default function read_expression(parser, opening_token, disallow_loose) { + const simple = read_simple_expression(parser); + if (simple) return simple; + try { const node = parse_expression_at(parser, parser.template, parser.index); @@ -57,3 +64,104 @@ export default function read_expression(parser, opening_token, disallow_loose) { throw err; } } + +/** + * Most template expressions are an identifier or a `a.b.c` member chain followed by `}`. + * Those are built directly for better parse performance, with the same shape acorn would produce; anything else goes to acorn + * @param {Parser} parser + * @returns {Expression | null} + */ +function read_simple_expression(parser) { + if (!has_lf_line_breaks_only(parser)) return null; + + const template = parser.template; + const index = parser.index; + + parser.allow_whitespace(); + const start = parser.index; + + let end = read_word(template, start); + if (end === -1 || is_reserved(template.slice(start, end))) { + parser.index = index; + return null; + } + + /** @type {Expression} */ + let node = identifier(template, start, end); + + while (template[end] === '.') { + const property_end = read_word(template, end + 1); + if (property_end === -1) { + parser.index = index; + return null; + } + + node = { + type: 'MemberExpression', + start, + end: property_end, + loc: { start: position(start), end: position(property_end) }, + object: node, + property: identifier(template, end + 1, property_end), + computed: false, + optional: false + }; + + end = property_end; + } + + parser.index = end; + parser.allow_whitespace(); + + if (!parser.match('}')) { + parser.index = index; + return null; + } + + parser.index = end; + return node; +} + +/** + * @param {string} template + * @param {number} start + * @returns {number} the end of the identifier starting at `start`, or -1 + */ +function read_word(template, start) { + if (start >= template.length) return -1; + + const code = /** @type {number} */ (template.codePointAt(start)); + if (!isIdentifierStart(code, true)) return -1; + + let end = start + (code <= 0xffff ? 1 : 2); + + while (end < template.length) { + const code = /** @type {number} */ (template.codePointAt(end)); + if (!isIdentifierChar(code, true)) break; + end += code <= 0xffff ? 1 : 2; + } + + return end; +} + +/** + * @param {string} template + * @param {number} start + * @param {number} end + * @returns {Identifier} + */ +function identifier(template, start, end) { + return { + type: 'Identifier', + start, + end, + loc: { start: position(start), end: position(end) }, + name: template.slice(start, end) + }; +} + +/** @param {number} index */ +function position(index) { + const { line, column } = locator(index); + return { line, column }; +} diff --git a/packages/svelte/src/compiler/phases/1-parse/read/script.js b/packages/svelte/src/compiler/phases/1-parse/read/script.js index 4472ce61c3..48fb540f9c 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/script.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/script.js @@ -22,7 +22,7 @@ const ALLOWED_ATTRIBUTES = ['context', 'generics', 'lang', 'module']; */ export function read_script(parser, start, attributes) { const script_start = parser.index; - const data = parser.read_until(regex_closing_script_tag); + const data = parser.read_until_regex(regex_closing_script_tag); if (parser.index >= parser.template.length) { e.element_unclosed(parser.template.length, 'script'); } diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js index 160e5da277..574c353adb 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/style.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js @@ -7,14 +7,15 @@ const REGEX_CLOSING_BRACKET = /[\s\]]/; const REGEX_ATTRIBUTE_FLAGS = /[a-zA-Z]+/y; // only `i` and `s` are valid today, but make it future-proof const REGEX_COMBINATOR = /(\+|~|>|\|\|)/y; const REGEX_PERCENTAGE = /\d+(\.\d+)?%/y; +// `of` must be preceded by whitespace, otherwise it would be part of the `` token +// (`2nof` is a single dimension token). It does not need to be followed by whitespace, +// because a `.`, `#`, `[`, `*`, `:` or `&` already ends the `of` identifier — minifiers rely on that const REGEX_NTH_OF = - /(even|odd|\+?(\d+|\d*n(\s*[+-]\s*\d+)?)|-\d*n(\s*\+\s*\d+))((?=\s*[,)])|\s+of\s+)/y; + /(even|odd|\+?(\d+|\d*n(\s*[+-]\s*\d+)?)|-\d*n(\s*\+\s*\d+))((?=\s*[,)])|\s+of(\s+|(?=[.#[*:&])))/y; const REGEX_WHITESPACE_OR_COLON = /[\s:]/; const REGEX_LEADING_HYPHEN_OR_DIGIT = /-?\d/y; const REGEX_VALID_IDENTIFIER_CHAR = /[a-zA-Z0-9_-]/; const REGEX_UNICODE_SEQUENCE = /\\[0-9a-fA-F]{1,6}(\r\n|\s)?/y; -const REGEX_COMMENT_CLOSE = /\*\//; -const REGEX_HTML_COMMENT_CLOSE = /-->/; /** * @param {Parser} parser @@ -24,6 +25,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/; */ export default function read_style(parser, start, attributes) { const content_start = parser.index; + parser.css_comments = []; const children = read_body(parser, (p) => p.match('= p.template.length); const content_end = parser.index; @@ -36,6 +38,7 @@ export default function read_style(parser, start, attributes) { end: parser.index, attributes, children, + comments: parser.css_comments, content: { start: content_start, end: content_end, @@ -202,15 +205,18 @@ function read_selector(parser, inside_pseudo_class = false) { }); } else if (parser.eat('*')) { let name = '*'; + /** @type {string | undefined} */ + let namespace; if (parser.eat('|')) { - // * is the namespace (which we ignore) - name = read_identifier(parser); + namespace = name; + name = parser.eat('*') ? '*' : read_identifier(parser); } relative_selector.selectors.push({ type: 'TypeSelector', name, + ...(namespace !== undefined && { namespace }), start, end: parser.index }); @@ -229,18 +235,22 @@ function read_selector(parser, inside_pseudo_class = false) { end: parser.index }); } else if (parser.eat('::')) { + const name = read_identifier(parser); + /** @type {AST.CSS.SelectorList | null} */ + let args = null; + + if (parser.eat('(')) { + args = read_selector_list(parser, true); + parser.eat(')', true); + } + relative_selector.selectors.push({ type: 'PseudoElementSelector', - name: read_identifier(parser), + name, start, - end: parser.index + end: parser.index, + ...(args && { args }) }); - // We read the inner selectors of a pseudo element to ensure it parses correctly, - // but we don't do anything with the result. - if (parser.eat('(')) { - read_selector_list(parser, true); - parser.eat(')', true); - } } else if (parser.eat(':')) { const name = read_identifier(parser); @@ -308,22 +318,25 @@ function read_selector(parser, inside_pseudo_class = false) { }); } else if (!parser.match_regex(REGEX_COMBINATOR)) { let name = read_identifier(parser); + /** @type {string | undefined} */ + let namespace; if (parser.eat('|')) { - // we ignore the namespace when trying to find matching element classes - name = read_identifier(parser); + namespace = name; + name = parser.eat('*') ? '*' : read_identifier(parser); } relative_selector.selectors.push({ type: 'TypeSelector', name, + ...(namespace !== undefined && { namespace }), start, end: parser.index }); } const index = parser.index; - allow_comment_or_whitespace(parser); + allow_comment_or_whitespace(parser, false); if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) { // rewind, so we know whether to continue building the selector list @@ -449,7 +462,7 @@ function read_block_item(parser) { // read ahead to understand whether we're dealing with a declaration or a nested rule. // this involves some duplicated work, but avoids a try-catch that would disguise errors const start = parser.index; - read_value(parser); + read_value(parser, false); const char = parser.template[parser.index]; parser.index = start; @@ -463,7 +476,7 @@ function read_block_item(parser) { function read_declaration(parser) { const start = parser.index; - const property = parser.read_until(REGEX_WHITESPACE_OR_COLON); + const property = parser.read_until_regex(REGEX_WHITESPACE_OR_COLON); parser.allow_whitespace(); parser.eat(':'); let index = parser.index; @@ -492,10 +505,13 @@ function read_declaration(parser) { /** * @param {Parser} parser + * @param {boolean} [capture_comments] * @returns {string} */ -function read_value(parser) { +function read_value(parser, capture_comments = true) { let value = ''; + /** @type {AST.CSS.CSSComment[]} */ + const value_comments = []; let escaped = false; let in_url = false; @@ -523,6 +539,13 @@ function read_value(parser) { } else if (char === '(' && value.slice(-3) === 'url') { in_url = true; } else if ((char === ';' || char === '{' || char === '}') && !in_url && !quote_mark) { + const leading_whitespace = value.length - value.trimStart().length; + for (const comment of value_comments) { + comment.position = Math.max( + 0, + /** @type {number} */ (comment.position) - leading_whitespace + ); + } return value.trim(); } else if ( char === '/' && @@ -530,13 +553,11 @@ function read_value(parser) { !quote_mark && parser.template[parser.index + 1] === '*' ) { - parser.index += 2; - while (parser.index < parser.template.length) { - if (parser.template[parser.index] === '*' && parser.template[parser.index + 1] === '/') { - parser.index += 2; - break; - } - parser.index++; + const comment = read_comment(parser); + if (capture_comments) { + comment.position = value.length; + parser.css_comments.push(comment); + value_comments.push(comment); } continue; } @@ -600,7 +621,8 @@ function read_identifier(parser) { if (char === '\\') { const sequence = parser.match_regex(REGEX_UNICODE_SEQUENCE); if (sequence) { - identifier += String.fromCodePoint(parseInt(sequence.slice(1), 16)); + const character = String.fromCodePoint(parseInt(sequence.slice(1), 16)); + identifier += character === '\\' ? '\\\\' : character; parser.index += sequence.length; } else { identifier += '\\' + parser.template[parser.index + 1]; @@ -624,17 +646,20 @@ function read_identifier(parser) { return identifier; } -/** @param {Parser} parser */ -function allow_comment_or_whitespace(parser) { +/** + * @param {Parser} parser + * @param {boolean} [capture_comments] + */ +function allow_comment_or_whitespace(parser, capture_comments = true) { parser.allow_whitespace(); while (parser.match('/*') || parser.match(''); parser.eat('-->', true); } @@ -642,6 +667,25 @@ function allow_comment_or_whitespace(parser) { } } +/** + * @param {Parser} parser + * @returns {AST.CSS.CSSComment} + */ +function read_comment(parser) { + const start = parser.index; + parser.eat('/*', true); + const value = parser.read_until('*/'); + parser.eat('*/', true); + const end = parser.index; + + return { + type: 'CSSComment', + value, + start, + end + }; +} + /** * Parse standalone CSS content (not wrapped in `'); + }, - TypeSelector(node, context) { - context.write(node.name); - } -}; + TypeSelector(node, context) { + if (node.namespace !== undefined) { + context.write(node.namespace === '*' ? '*' : escape_identifier(node.namespace)); + context.write('|'); + } + context.write(node.name === '*' ? node.name : escape_identifier(node.name)); + } + }; +} /** * @param {AST.JSComment[]} comments + * @param {{ preserve_whitespace: number }} state * @returns {Visitors} */ -const svelte_visitors = (comments) => ({ +const svelte_visitors = (comments, state) => ({ Root(node, context) { if (node.options) { context.write(' ({ context.write(''); - block(context, node.content); + block(context, node.content, state.preserve_whitespace > 0); context.write(''); }, Fragment(node, context) { - /** @type {AST.SvelteNode[][]} */ + if (state.preserve_whitespace > 0) { + for (const child of node.nodes) { + context.visit(child); + context.multiline ||= child.type === 'Text' && /[\r\n]/.test(child.data); + } + return; + } + + const first = node.nodes[0]; + const last = node.nodes.at(-1); + const has_surrounding_whitespace = + first?.type === 'Text' && + /^\s/.test(first.data) && + last?.type === 'Text' && + /\s$/.test(last.data); + + /** @type {{ nodes: AST.SvelteNode[]; leading_whitespace: boolean }[]} */ const items = []; /** @type {AST.SvelteNode[]} */ let sequence = []; + let leading_whitespace = false; const flush = () => { - items.push(sequence); - sequence = []; + if (sequence.length > 0) { + items.push({ nodes: sequence, leading_whitespace }); + sequence = []; + leading_whitespace = false; + } }; for (let i = 0; i < node.nodes.length; i += 1) { @@ -402,6 +628,7 @@ const svelte_visitors = (comments) => ({ if (child_node.data.startsWith(' ') && prev && prev.type !== 'ExpressionTag') { flush(); + leading_whitespace = true; child_node.data = child_node.data.trimStart(); } @@ -417,6 +644,7 @@ const svelte_visitors = (comments) => ({ const is_block_element = child_node.type === 'RegularElement' || child_node.type === 'Component' || + child_node.type === 'SvelteBody' || child_node.type === 'SvelteHead' || child_node.type === 'SvelteFragment' || child_node.type === 'SvelteBoundary' || @@ -439,35 +667,39 @@ const svelte_visitors = (comments) => ({ let multiline = false; let width = 0; - const child_contexts = items - .filter((x) => x.length > 0) - .map((sequence) => { - const child_context = context.new(); + const child_contexts = items.map(({ nodes, leading_whitespace }) => { + const child_context = context.new(); - for (const node of sequence) { - child_context.visit(node); - multiline ||= child_context.multiline; - } + for (const node of nodes) { + child_context.visit(node); + multiline ||= child_context.multiline; + } - width += child_context.measure(); + width += child_context.measure() + (leading_whitespace ? 1 : 0); - return child_context; - }); + return { context: child_context, leading_whitespace }; + }); multiline ||= width > LINE_BREAK_THRESHOLD; + // Normally context.newline() also makes context.multiline true, but the below loop only + // does that if we have more than one child context. If there's one long text block inside + // with whitespace at the edges we wanna split that up, too. + context.multiline ||= has_surrounding_whitespace && width > LINE_BREAK_THRESHOLD * 2; for (let i = 0; i < child_contexts.length; i += 1) { const prev = child_contexts[i]; const next = child_contexts[i + 1]; - context.append(prev); + context.append(prev.context); if (next) { - if (prev.multiline || next.multiline) { + if (prev.context.multiline || next.context.multiline) { context.margin(); context.newline(); } else if (multiline) { context.newline(); + } else if (next.leading_whitespace) { + context.write(' '); } } } @@ -521,7 +753,7 @@ const svelte_visitors = (comments) => ({ if (node.pending) { context.write('}'); - block(context, node.pending); + block(context, node.pending, state.preserve_whitespace > 0); context.write('{:'); } else { context.write(' '); @@ -532,7 +764,7 @@ const svelte_visitors = (comments) => ({ if (node.value) context.visit(node.value); context.write('}'); - block(context, node.then); + block(context, node.then, state.preserve_whitespace > 0); if (node.catch) { context.write('{:'); @@ -540,11 +772,11 @@ const svelte_visitors = (comments) => ({ } if (node.catch) { - context.write(node.value ? 'catch ' : 'catch'); + context.write(node.error ? 'catch ' : 'catch'); if (node.error) context.visit(node.error); context.write('}'); - block(context, node.catch); + block(context, node.catch, state.preserve_whitespace > 0); } context.write('{/await}'); @@ -588,7 +820,7 @@ const svelte_visitors = (comments) => ({ }, Component(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, ConstTag(node, context) { @@ -602,6 +834,48 @@ const svelte_visitors = (comments) => ({ context.write('}'); }, + DeclarationTag(node, context) { + context.write('{'); + + // This is duplicated from esrap's handling of VariableDeclaration, + // which we need to do in order to omit the trailing semicolon that esrap would add. + const open = context.new(); + const join = context.new(); + const child_context = context.new(); + + context.append(child_context); + + child_context.write(`${node.declaration.kind} `); + child_context.append(open); + + const declarations = node.declaration.declarations; + let first = true; + + for (const d of declarations) { + if (!first) child_context.append(join); + first = false; + + child_context.visit(d); + } + + const length = child_context.measure() + 2 * (declarations.length - 1); + + const multiline = child_context.multiline || (declarations.length > 1 && length > 50); + + if (multiline) { + context.multiline = true; + + if (declarations.length > 1) open.indent(); + join.write(','); + join.newline(); + if (declarations.length > 1) context.dedent(); + } else { + join.write(', '); + } + + context.write('}'); + }, + DebugTag(node, context) { context.write('{@debug '); let started = false; @@ -636,11 +910,11 @@ const svelte_visitors = (comments) => ({ context.write('}'); - block(context, node.body); + block(context, node.body, state.preserve_whitespace > 0); if (node.fallback) { context.write('{:else}'); - block(context, node.fallback); + block(context, node.fallback, state.preserve_whitespace > 0); } context.write('{/each}'); @@ -664,13 +938,13 @@ const svelte_visitors = (comments) => ({ context.visit(node.test); context.write('}'); - block(context, node.consequent); + block(context, node.consequent, state.preserve_whitespace > 0); } else { context.write('{#if '); context.visit(node.test); context.write('}'); - block(context, node.consequent); + block(context, node.consequent, state.preserve_whitespace > 0); } if (node.alternate !== null) { @@ -682,7 +956,7 @@ const svelte_visitors = (comments) => ({ ) ) { context.write('{:else}'); - block(context, node.alternate); + block(context, node.alternate, state.preserve_whitespace > 0); } else { context.visit(node.alternate); } @@ -697,7 +971,7 @@ const svelte_visitors = (comments) => ({ context.write('{#key '); context.visit(node.expression); context.write('}'); - block(context, node.fragment); + block(context, node.fragment, state.preserve_whitespace > 0); context.write('{/key}'); }, @@ -729,7 +1003,7 @@ const svelte_visitors = (comments) => ({ }, RegularElement(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, RenderTag(node, context) { @@ -739,7 +1013,7 @@ const svelte_visitors = (comments) => ({ }, SlotElement(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SnippetBlock(node, context) { @@ -758,7 +1032,7 @@ const svelte_visitors = (comments) => ({ } context.write(')}'); - block(context, node.body); + block(context, node.body, state.preserve_whitespace > 0); context.write('{/snippet}'); }, @@ -793,36 +1067,12 @@ const svelte_visitors = (comments) => ({ } }, - StyleSheet(node, context) { - context.write(''); - - if (node.children.length > 0) { - context.indent(); - context.newline(); - - let started = false; - - for (const child of node.children) { - if (started) { - context.margin(); - context.newline(); - } - - context.visit(child); - started = true; - } - - context.dedent(); - context.newline(); - } - - context.write(''); + SvelteBody(node, context) { + print_element(node, context, comments, state); }, SvelteBoundary(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteComponent(node, context) { @@ -834,7 +1084,7 @@ const svelte_visitors = (comments) => ({ attributes(node, node.attributes, context, comments); if (node.fragment && node.fragment.nodes.length > 0) { context.write('>'); - block(context, node.fragment, true); + block(context, node.fragment, state.preserve_whitespace > 0, true); context.write(``); } else { context.write(' />'); @@ -842,7 +1092,7 @@ const svelte_visitors = (comments) => ({ }, SvelteDocument(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteElement(node, context) { @@ -855,7 +1105,7 @@ const svelte_visitors = (comments) => ({ if (node.fragment && node.fragment.nodes.length > 0) { context.write('>'); - block(context, node.fragment); + block(context, node.fragment, state.preserve_whitespace > 0); context.write(`
`); } else { context.write(' />'); @@ -863,19 +1113,19 @@ const svelte_visitors = (comments) => ({ }, SvelteFragment(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteHead(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteSelf(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, SvelteWindow(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, Text(node, context) { @@ -883,7 +1133,7 @@ const svelte_visitors = (comments) => ({ }, TitleElement(node, context) { - base_element(node, context, comments); + print_element(node, context, comments, state); }, TransitionDirective(node, context) { diff --git a/packages/svelte/src/compiler/print/types.d.ts b/packages/svelte/src/compiler/print/types.d.ts index d0ff909525..9eccebb6f3 100644 --- a/packages/svelte/src/compiler/print/types.d.ts +++ b/packages/svelte/src/compiler/print/types.d.ts @@ -4,4 +4,5 @@ import type ts from 'esrap/languages/ts'; export type Options = { getLeadingComments?: NonNullable[0]>['getLeadingComments'] | undefined; getTrailingComments?: NonNullable[0]>['getTrailingComments'] | undefined; + indent?: string; // default tab }; diff --git a/packages/svelte/src/compiler/state.js b/packages/svelte/src/compiler/state.js index 5ae001ec50..51a02a61e1 100644 --- a/packages/svelte/src/compiler/state.js +++ b/packages/svelte/src/compiler/state.js @@ -33,10 +33,14 @@ export let component_name = ''; export let source; /** - * The source code split into lines (set by `set_source`) - * @type {string[]} + * The source code split into lines, initialized when a diagnostic needs a code frame + * @type {string[] | undefined} */ -export let source_lines = []; +let source_lines; + +export function get_source_lines() { + return (source_lines ??= source.split('\n')); +} /** * True if compiling with `dev: true` @@ -52,7 +56,7 @@ export let locator; /** @param {string} value */ export function set_source(value) { source = value; - source_lines = source.split('\n'); + source_lines = undefined; const l = getLocator(source, { offsetLine: 1 }); @@ -141,7 +145,7 @@ export function reset(state) { runes = false; component_name = UNKNOWN_FILENAME; source = ''; - source_lines = []; + source_lines = undefined; filename = (state.filename ?? UNKNOWN_FILENAME).replace(/\\/g, '/'); warning_filter = state.warning ?? (() => true); warnings = []; diff --git a/packages/svelte/src/compiler/types/css.d.ts b/packages/svelte/src/compiler/types/css.d.ts index 13eb880d83..96b2bc3a7d 100644 --- a/packages/svelte/src/compiler/types/css.d.ts +++ b/packages/svelte/src/compiler/types/css.d.ts @@ -8,6 +8,15 @@ export namespace _CSS { export interface StyleSheetBase extends BaseNode { children: Array; + /** CSS comments in source order */ + comments: CSSComment[]; + } + + export interface CSSComment extends BaseNode { + type: 'CSSComment'; + value: string; + /** Character offset in a containing declaration value or at-rule prelude */ + position?: number; } export interface StyleSheetFile extends StyleSheetBase { @@ -112,6 +121,7 @@ export namespace _CSS { export interface TypeSelector extends BaseNode { type: 'TypeSelector'; name: string; + namespace?: string; } export interface IdSelector extends BaseNode { @@ -135,6 +145,7 @@ export namespace _CSS { export interface PseudoElementSelector extends BaseNode { type: 'PseudoElementSelector'; name: string; + args?: SelectorList; } export interface PseudoClassSelector extends BaseNode { diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 2964e46761..4d4b6fc21f 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -160,6 +160,18 @@ export namespace AST { }; } + /** A `{let ...}` or `{const ...}` tag */ + export interface DeclarationTag extends BaseNode { + type: 'DeclarationTag'; + declaration: VariableDeclaration; + /** @internal */ + metadata: { + expression: ExpressionMetadata; + /** If this declaration tag contains an await expression, or needs to wait on other async, this is set */ + promises_id?: Identifier; + }; + } + /** A `{@debug ...}` tag */ export interface DebugTag extends BaseNode { type: 'DebugTag'; @@ -622,6 +634,7 @@ export namespace AST { export type Tag = | AST.AttachTag | AST.ConstTag + | AST.DeclarationTag | AST.DebugTag | AST.ExpressionTag | AST.HtmlTag diff --git a/packages/svelte/src/compiler/utils/ast.js b/packages/svelte/src/compiler/utils/ast.js index 75aadd905b..927674bd18 100644 --- a/packages/svelte/src/compiler/utils/ast.js +++ b/packages/svelte/src/compiler/utils/ast.js @@ -286,7 +286,7 @@ function _extract_paths(paths, inserts, param, expression, update_expression, ha const props = []; for (const p of param.properties) { - if (p.type === 'Property' && p.key.type !== 'PrivateIdentifier') { + if (p.type === 'Property') { if (p.key.type === 'Identifier' && !p.computed) { props.push(b.literal(p.key.name)); } else if (p.key.type === 'Literal') { @@ -548,10 +548,7 @@ export function is_expression_async(expression) { if (property.type === 'SpreadElement') { return is_expression_async(property.argument); } else if (property.type === 'Property') { - return ( - (property.key.type !== 'PrivateIdentifier' && is_expression_async(property.key)) || - is_expression_async(property.value) - ); + return is_expression_async(property.key) || is_expression_async(property.value); } }); } diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 7508caf3e7..1f48f7fd8b 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -686,7 +686,8 @@ export { if_builder as if, this_instance as this, null_instance as null, - debugger_builder as debugger + debugger_builder as debugger, + new_builder as new }; /** diff --git a/packages/svelte/src/compiler/utils/compile_diagnostic.js b/packages/svelte/src/compiler/utils/compile_diagnostic.js index 95d028ee35..ab0732c958 100644 --- a/packages/svelte/src/compiler/utils/compile_diagnostic.js +++ b/packages/svelte/src/compiler/utils/compile_diagnostic.js @@ -15,7 +15,7 @@ function tabs_to_spaces(str) { * @param {number} column */ function get_code_frame(line, column) { - const lines = state.source_lines; + const lines = state.get_source_lines(); const frame_start = Math.max(0, line - 2); const frame_end = Math.min(line + 3, lines.length); const digits = String(frame_end + 1).length; diff --git a/packages/svelte/src/compiler/utils/mapped_code.js b/packages/svelte/src/compiler/utils/mapped_code.js index 7686ba59c6..635743c0c4 100644 --- a/packages/svelte/src/compiler/utils/mapped_code.js +++ b/packages/svelte/src/compiler/utils/mapped_code.js @@ -311,6 +311,13 @@ function apply_preprocessor_sourcemap(filename, svelte_map, preprocessor_map_inp typeof preprocessor_map_input === 'string' ? JSON.parse(preprocessor_map_input) : preprocessor_map_input; + // A preprocessor map with a missing/empty `sources[0]` (e.g. from a MagicString transform + // created without a `source` option) can't be matched against `filename` during combination, + // which silently drops the chain instead of erroring. Normalize it to `filename` first, the + // same way Vite treats an empty `sources[0]` as referring to the file being transformed. + if (preprocessor_map.sources?.length === 1 && !preprocessor_map.sources[0]) { + preprocessor_map.sources = [filename]; + } const result_map = combine_sourcemaps(filename, [svelte_map, preprocessor_map]); // Svelte expects a SourceMap which includes toUrl and toString. Instead of wrapping our output in a class, // we just tack on the extra properties. diff --git a/packages/svelte/src/compiler/warnings.js b/packages/svelte/src/compiler/warnings.js index 089cb1e118..98f407671d 100644 --- a/packages/svelte/src/compiler/warnings.js +++ b/packages/svelte/src/compiler/warnings.js @@ -166,11 +166,12 @@ export function a11y_autofocus(node) { } /** - * Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as ` + +hello very long liiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiine diff --git a/packages/svelte/tests/print/samples/element-content-wrapping/output.svelte b/packages/svelte/tests/print/samples/element-content-wrapping/output.svelte new file mode 100644 index 0000000000..15622e960e --- /dev/null +++ b/packages/svelte/tests/print/samples/element-content-wrapping/output.svelte @@ -0,0 +1,5 @@ + + +hello very long liiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiine diff --git a/packages/svelte/tests/print/samples/expression-tag/output.svelte b/packages/svelte/tests/print/samples/expression-tag/output.svelte index 9142a59631..5af5746d87 100644 --- a/packages/svelte/tests/print/samples/expression-tag/output.svelte +++ b/packages/svelte/tests/print/samples/expression-tag/output.svelte @@ -1 +1 @@ -{name}{count + 1} +{name} {count + 1} diff --git a/packages/svelte/tests/print/samples/inline-element-whitespace/input.svelte b/packages/svelte/tests/print/samples/inline-element-whitespace/input.svelte new file mode 100644 index 0000000000..e442ae954c --- /dev/null +++ b/packages/svelte/tests/print/samples/inline-element-whitespace/input.svelte @@ -0,0 +1 @@ +

Hello bold world

diff --git a/packages/svelte/tests/print/samples/inline-element-whitespace/output.svelte b/packages/svelte/tests/print/samples/inline-element-whitespace/output.svelte new file mode 100644 index 0000000000..e442ae954c --- /dev/null +++ b/packages/svelte/tests/print/samples/inline-element-whitespace/output.svelte @@ -0,0 +1 @@ +

Hello bold world

diff --git a/packages/svelte/tests/print/samples/preserve-whitespace-elements/input.svelte b/packages/svelte/tests/print/samples/preserve-whitespace-elements/input.svelte new file mode 100644 index 0000000000..0736fd6bf5 --- /dev/null +++ b/packages/svelte/tests/print/samples/preserve-whitespace-elements/input.svelte @@ -0,0 +1,19 @@ +
+
+    hel
+
+    lo
+      .
+
+ +
  before
+  nested
+ text  
+{#if visible}  conditional
+ value{/if}
+ + + + hello + world diff --git a/packages/svelte/tests/print/samples/preserve-whitespace-elements/output.svelte b/packages/svelte/tests/print/samples/preserve-whitespace-elements/output.svelte new file mode 100644 index 0000000000..345a099dfc --- /dev/null +++ b/packages/svelte/tests/print/samples/preserve-whitespace-elements/output.svelte @@ -0,0 +1,21 @@ +
+
+    hel
+
+    lo
+      .
+
+ +
  before
+  nested
+ text  
+{#if visible}  conditional
+ value{/if}
+ + + + + hello + world + diff --git a/packages/svelte/tests/print/samples/regular-element/output.svelte b/packages/svelte/tests/print/samples/regular-element/output.svelte index 0cf7c2472f..f5fd3c8f42 100644 --- a/packages/svelte/tests/print/samples/regular-element/output.svelte +++ b/packages/svelte/tests/print/samples/regular-element/output.svelte @@ -1 +1 @@ -

+
diff --git a/packages/svelte/tests/print/samples/style-comments/input.svelte b/packages/svelte/tests/print/samples/style-comments/input.svelte new file mode 100644 index 0000000000..71b2505891 --- /dev/null +++ b/packages/svelte/tests/print/samples/style-comments/input.svelte @@ -0,0 +1,26 @@ + diff --git a/packages/svelte/tests/print/samples/style-comments/output.svelte b/packages/svelte/tests/print/samples/style-comments/output.svelte new file mode 100644 index 0000000000..cb913cc0b5 --- /dev/null +++ b/packages/svelte/tests/print/samples/style-comments/output.svelte @@ -0,0 +1,28 @@ + diff --git a/packages/svelte/tests/print/samples/style/output.svelte b/packages/svelte/tests/print/samples/style/output.svelte index 03168cccae..9fc5e3fbba 100644 --- a/packages/svelte/tests/print/samples/style/output.svelte +++ b/packages/svelte/tests/print/samples/style/output.svelte @@ -19,7 +19,7 @@ from { opacity: 0; } - 50%% { + 50% { opacity: 0.5; } to { diff --git a/packages/svelte/tests/print/samples/svelte-body/input.svelte b/packages/svelte/tests/print/samples/svelte-body/input.svelte new file mode 100644 index 0000000000..a4c26db5ce --- /dev/null +++ b/packages/svelte/tests/print/samples/svelte-body/input.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/print/samples/svelte-body/output.svelte b/packages/svelte/tests/print/samples/svelte-body/output.svelte new file mode 100644 index 0000000000..e13eaae979 --- /dev/null +++ b/packages/svelte/tests/print/samples/svelte-body/output.svelte @@ -0,0 +1 @@ + diff --git a/packages/svelte/tests/print/test.ts b/packages/svelte/tests/print/test.ts index aa007a7a54..4caecf90ae 100644 --- a/packages/svelte/tests/print/test.ts +++ b/packages/svelte/tests/print/test.ts @@ -12,6 +12,10 @@ const { test, run } = suite(async (config, cwd) => { const output = print(ast); const outputCode = output.code.endsWith('\n') ? output.code : output.code + '\n'; + // the printed output must itself be valid Svelte — `print` should never emit + // code that `parse` cannot read back (e.g. CSS escape sequences must round-trip) + parse(outputCode, { modern: true }); + // run `UPDATE_SNAPSHOTS=true pnpm test print` to update print tests if (process.env.UPDATE_SNAPSHOTS) { fs.writeFileSync(`${cwd}/output.svelte`, outputCode); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js index 8ade347a79..3fd5a09888 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/$$slot-dynamic-content/_config.js @@ -14,8 +14,8 @@ export default test({ assert.htmlEqual( ce.shadowRoot.innerHTML, ` - -

named fallback

+ fallback +

named fallback

` ); @@ -23,8 +23,8 @@ export default test({ assert.htmlEqual( ce.shadowRoot.innerHTML, ` - -

named fallback

+ fallback +

named fallback

` ); } diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/Child.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/Child.svelte new file mode 100644 index 0000000000..86064ab3b1 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/Child.svelte @@ -0,0 +1,7 @@ +

child

+ + diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/_config.js new file mode 100644 index 0000000000..739921ebf5 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/_config.js @@ -0,0 +1,24 @@ +import { assert_ok, test } from '../../assert'; + +const tick = () => Promise.resolve(); + +export default test({ + async test({ assert, target }) { + target.innerHTML = ''; + + // wait for the initial mount, the `onMount` reveal and the deferred re-render + await tick(); + await tick(); + await tick(); + await tick(); + + /** @type {any} */ + const el = target.querySelector('my-app'); + const p = el.shadowRoot.querySelector('p'); + assert_ok(p); + + // The child's scoped styles must be injected into the shadow root, not `document.head` + assert_ok(el.shadowRoot.querySelector('style')); + assert.equal(getComputedStyle(p).color, 'rgb(255, 0, 0)'); + } +}); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/main.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/main.svelte new file mode 100644 index 0000000000..2bd0456fc1 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/deferred-nested-styles/main.svelte @@ -0,0 +1,18 @@ + + + + +{#each items as item (item)}{/each} diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/html-slots/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/html-slots/_config.js index 287e2987b8..e3e8ce072b 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/html-slots/_config.js +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/html-slots/_config.js @@ -3,10 +3,7 @@ const tick = () => Promise.resolve(); export default test({ async test({ assert, target }) { - target.innerHTML = ` - - slotted - `; + target.innerHTML = ''; await tick(); await tick(); @@ -16,7 +13,26 @@ export default test({ const div = el.shadowRoot.children[0]; const [slot0, slot1] = div.children; - assert.equal(slot0.assignedNodes()[1], target.querySelector('strong')); - assert.equal(slot1.innerHTML, 'foo fallback content'); + assert.equal(slot0.localName, 'slot'); + assert.equal(slot0.assignedNodes().length, 0); + assert.equal(slot0.innerHTML, '

default fallback content

'); + assert.equal(slot1.localName, 'slot'); + assert.equal(slot1.name, 'foo'); + assert.equal(slot1.assignedNodes().length, 0); + assert.equal(slot1.innerHTML, '

foo fallback content

'); + + const default_content = document.createElement('strong'); + default_content.textContent = 'default content'; + el.append(default_content); + + const named_content = document.createElement('strong'); + named_content.slot = 'foo'; + named_content.textContent = 'named content'; + el.append(named_content); + + assert.equal(slot0.assignedNodes().length, 1); + assert.equal(slot0.assignedNodes()[0], default_content); + assert.equal(slot1.assignedNodes().length, 1); + assert.equal(slot1.assignedNodes()[0], named_content); } }); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/_config.js index cd8550120a..e83491418a 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/_config.js +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/_config.js @@ -3,7 +3,7 @@ const tick = () => Promise.resolve(); export default test({ async test({ assert, target }) { - target.innerHTML = ''; + target.innerHTML = 'slotted'; await tick(); await tick(); @@ -15,5 +15,6 @@ export default test({ assert.equal(el.shadowRoot, null); assert.equal(h1.innerHTML, 'Hello world!'); assert.equal(getComputedStyle(h1).color, 'rgb(255, 0, 0)'); + assert.equal(el.querySelector('slot').innerHTML, ''); } }); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/main.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/main.svelte index 36fea3c43e..1c14fb98d8 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/main.svelte +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/no-shadow-dom/main.svelte @@ -5,6 +5,7 @@

Hello {name}!

+fallback \ No newline at end of file diff --git a/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/Nested.svelte b/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/Nested.svelte new file mode 100644 index 0000000000..eeb325059f --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/Nested.svelte @@ -0,0 +1,14 @@ + + +
+ {#if depth > 0} + + {:else} +
leaf
+ {/if} +
diff --git a/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/_config.js b/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/_config.js new file mode 100644 index 0000000000..00e5cb02a2 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/_config.js @@ -0,0 +1,36 @@ +import { test } from '../../assert'; + +export default test({ + async test({ assert, target }) { + const button = target.querySelector('button'); + button?.click(); + + // wait for the transition's keyframes to be created + const animation = await new Promise((resolve, reject) => { + const start = performance.now(); + + function check() { + const outer = target.querySelector('.level-2'); + const animation = outer + ?.getAnimations() + .find((a) => a.effect?.getTiming().duration === 100); + + if (animation) { + resolve(animation); + } else if (performance.now() - start > 2000) { + reject(new Error('timed out waiting for the transition to start')); + } else { + requestAnimationFrame(check); + } + } + + check(); + }); + + // the outermost `slide` must have measured the element with its + // descendants at their natural size, not collapsed to zero by their + // own starting styles (#18421) + const keyframes = animation.effect?.getKeyframes() ?? []; + assert.equal(keyframes[keyframes.length - 1].height, '100px'); + } +}); diff --git a/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/main.svelte b/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/main.svelte new file mode 100644 index 0000000000..a64a0cd2f4 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/main.svelte @@ -0,0 +1,11 @@ + + + + +{#if visible} + +{/if} diff --git a/packages/svelte/tests/runtime-browser/samples/transition-slide-hidden-parent/_config.js b/packages/svelte/tests/runtime-browser/samples/transition-slide-hidden-parent/_config.js new file mode 100644 index 0000000000..a8c1e07111 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/transition-slide-hidden-parent/_config.js @@ -0,0 +1,27 @@ +import { ok, test } from '../../assert'; + +export default test({ + async test({ assert, window }) { + window.document.querySelector('button')?.click(); + await new Promise((r) => setTimeout(r, 100)); + + const p = window.document.querySelector('p'); + const animations = /** @type {HTMLElement} */ (p).getAnimations(); + assert.equal(animations.length, 1); + + // when the element has no layout box, computed dimensions resolve to 'auto', + // which must not end up as NaN values that the browser rejects (#14205) + const effect = /** @type {KeyframeEffect} */ (animations[0].effect); + const keyframes = effect.getKeyframes(); + ok(keyframes.length > 0); + assert.equal(effect.getTiming().duration, 400); + + for (const keyframe of keyframes) { + ok(!('height' in keyframe), 'unresolved height should be omitted'); + + for (const value of Object.values(keyframe)) { + ok(!String(value).includes('NaN'), `unexpected NaN in keyframe: ${value}`); + } + } + } +}); diff --git a/packages/svelte/tests/runtime-browser/samples/transition-slide-hidden-parent/main.svelte b/packages/svelte/tests/runtime-browser/samples/transition-slide-hidden-parent/main.svelte new file mode 100644 index 0000000000..e1a7c54bdb --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/transition-slide-hidden-parent/main.svelte @@ -0,0 +1,13 @@ + + + + +
+ {#if visible} +

hello

+ {/if} +
diff --git a/packages/svelte/tests/runtime-browser/test.ts b/packages/svelte/tests/runtime-browser/test.ts index 54cdc0f8be..22e8ad6489 100644 --- a/packages/svelte/tests/runtime-browser/test.ts +++ b/packages/svelte/tests/runtime-browser/test.ts @@ -42,9 +42,9 @@ const { run: run_browser_tests } = suite_with_variants< describe.concurrent( 'runtime-browser', - () => run_browser_tests(__dirname), // Browser tests are brittle and slow on CI - { timeout: 20000, retry: process.env.CI ? 1 : 0 } + { timeout: 20000, retry: process.env.CI ? 1 : 0 }, + () => run_browser_tests(__dirname) ); const { run: run_ce_tests } = suite>( @@ -55,9 +55,9 @@ const { run: run_ce_tests } = suite>( describe.concurrent( 'custom-elements', - () => run_ce_tests(__dirname, 'custom-elements-samples'), // Browser tests are brittle and slow on CI - { timeout: 20000, retry: process.env.CI ? 1 : 0 } + { timeout: 20000, retry: process.env.CI ? 1 : 0 }, + () => run_ce_tests(__dirname, 'custom-elements-samples') ); async function run_test( diff --git a/packages/svelte/tests/runtime-legacy/samples/binding-select-multiple/_config.js b/packages/svelte/tests/runtime-legacy/samples/binding-select-multiple/_config.js index 42c0e018e5..d7c1b8d339 100644 --- a/packages/svelte/tests/runtime-legacy/samples/binding-select-multiple/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/binding-select-multiple/_config.js @@ -6,17 +6,30 @@ export default test({ return { selected: ['two', 'three'] }; }, - html: ` + ssrHtml: `

selected: two, three

`, - test({ assert, component, target, window }) { + test({ assert, component, target, window, variant }) { + const selected = variant === 'hydrate' ? ' selected' : ''; + assert.htmlEqual( + target.innerHTML, + ` + + +

selected: two, three

+ ` + ); const select = target.querySelector('select'); ok(select); const options = [...target.querySelectorAll('option')]; @@ -33,8 +46,8 @@ export default test({ `

selected: three

@@ -51,8 +64,8 @@ export default test({ `

selected: one, three

@@ -70,8 +83,8 @@ export default test({ `

selected: one, two

diff --git a/packages/svelte/tests/runtime-legacy/samples/html-entities/_config.js b/packages/svelte/tests/runtime-legacy/samples/html-entities/_config.js index add763989e..82c87495c4 100644 --- a/packages/svelte/tests/runtime-legacy/samples/html-entities/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/html-entities/_config.js @@ -7,6 +7,7 @@ export default test({ * * * + * A diff --git a/packages/svelte/tests/runtime-legacy/samples/html-entities/main.svelte b/packages/svelte/tests/runtime-legacy/samples/html-entities/main.svelte index 2d0d66c939..25a60cb930 100644 --- a/packages/svelte/tests/runtime-legacy/samples/html-entities/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/html-entities/main.svelte @@ -2,6 +2,7 @@ * * * +* * diff --git a/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/Nested.svelte b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/Nested.svelte new file mode 100644 index 0000000000..417e539554 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/Nested.svelte @@ -0,0 +1,7 @@ + + +{#each things as thing} + +{/each} diff --git a/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/_config.js b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/_config.js new file mode 100644 index 0000000000..bbbcd9ad12 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/_config.js @@ -0,0 +1,15 @@ +import { test } from '../../test'; + +// `let:` directives on a slotted element must be declared before sibling `{@const}` +// declarations that capture them. In dev mode the `{@const}` derived is read eagerly, +// so a wrong declaration order throws "Cannot access '...' before initialization". +export default test({ + compileOptions: { + dev: true + }, + + html: ` +
1
+
2
+ ` +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/main.svelte b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/main.svelte new file mode 100644 index 0000000000..e2530ef015 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag-slotted/main.svelte @@ -0,0 +1,10 @@ + + + +
+ {@const props = { thing }} + {props.thing} +
+
diff --git a/packages/svelte/tests/runtime-legacy/samples/select-spread-preserve-selection/_config.js b/packages/svelte/tests/runtime-legacy/samples/select-spread-preserve-selection/_config.js new file mode 100644 index 0000000000..8bc2f11e26 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/select-spread-preserve-selection/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + mode: ['client', 'hydrate'], + + async test({ assert, component, target }) { + const select = target.querySelector('select'); + ok(select); + + assert.equal(select.selectedIndex, 0); + + component.toggle(); + flushSync(); + await Promise.resolve(); + + assert.equal(select.selectedIndex, 0); + + component.toggle(); + flushSync(); + await Promise.resolve(); + + assert.equal(select.selectedIndex, 0); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/select-spread-preserve-selection/main.svelte b/packages/svelte/tests/runtime-legacy/samples/select-spread-preserve-selection/main.svelte new file mode 100644 index 0000000000..229ce4fb73 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/select-spread-preserve-selection/main.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/svelte/tests/runtime-legacy/samples/textarea-value/_config.js b/packages/svelte/tests/runtime-legacy/samples/textarea-value/_config.js index 06264b653b..30e2633154 100644 --- a/packages/svelte/tests/runtime-legacy/samples/textarea-value/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/textarea-value/_config.js @@ -5,14 +5,19 @@ export default test({ return { foo: 42 }; }, - html: '', - ssrHtml: '', + ssrHtml: ' ', - test({ assert, component, target }) { - const textarea = /** @type {HTMLTextAreaElement} */ (target.querySelector('textarea')); - assert.strictEqual(textarea.value, '42'); + test({ assert, component, target, variant }) { + assert.htmlEqual( + target.innerHTML, + ` ` + ); + + const [textarea1, textarea2] = target.querySelectorAll('textarea'); + assert.strictEqual(textarea1.value, '42'); + assert.strictEqual(textarea2.value, 'static'); component.foo = 43; - assert.strictEqual(textarea.value, '43'); + assert.strictEqual(textarea1.value, '43'); } }); diff --git a/packages/svelte/tests/runtime-legacy/samples/textarea-value/main.svelte b/packages/svelte/tests/runtime-legacy/samples/textarea-value/main.svelte index 7eaf783f7f..203a3ed039 100644 --- a/packages/svelte/tests/runtime-legacy/samples/textarea-value/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/textarea-value/main.svelte @@ -2,4 +2,5 @@ export let foo; - diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 6f30fb5d98..de6749c0e7 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -15,19 +15,35 @@ import { clear } from '../../src/internal/client/reactivity/batch.js'; import { hydrating } from '../../src/internal/client/dom/hydration.js'; import { ssr_context } from '../../src/internal/server/context.js'; -type Assert = typeof import('vitest').assert & { - htmlEqual(a: string, b: string, description?: string): void; - htmlEqualWithOptions( - a: string, - b: string, - opts: { - preserveComments: boolean; - withoutNormalizeHtml: boolean; - }, - description?: string - ): void; +// `_config.js` files call `assert.ok` etc. with `assert` typed via parameter +// inference, which TypeScript treats as non-explicit. chai 5 (pulled in by +// vitest 4) declares these as assertion functions (`asserts value`), so TS2775 +// fires on every call. Override the affected methods with non-assertion +// signatures — the runtime behavior is unchanged. +type NonAssertingMethods = { + ok(value: unknown, message?: string): void; + isOk(value: unknown, message?: string): void; + isTrue(value: unknown, message?: string): void; + isFalse(value: unknown, message?: string): void; + exists(value: unknown, message?: string): void; + notExists(value: unknown, message?: string): void; + instanceOf(value: unknown, type: Function, message?: string): void; }; +type Assert = Omit & + NonAssertingMethods & { + htmlEqual(a: string, b: string, description?: string): void; + htmlEqualWithOptions( + a: string, + b: string, + opts: { + preserveComments: boolean; + withoutNormalizeHtml: boolean; + }, + description?: string + ): void; + }; + // TODO remove this shim when we can // @ts-expect-error Promise.withResolvers = () => { @@ -75,6 +91,7 @@ export interface RuntimeTest = Record void; }; + snapshot: any; target: HTMLElement; window: Window & { Event: typeof Event; @@ -126,6 +143,16 @@ const listeners = process.rawListeners('unhandledRejection'); beforeAll(() => { // @ts-expect-error TODO huh? process.prependListener('unhandledRejection', unhandled_rejection_handler); + + // Route inline-` + +{der} diff --git a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-destroy/_config.js b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-destroy/_config.js new file mode 100644 index 0000000000..3c6ec98fe6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-destroy/_config.js @@ -0,0 +1,14 @@ +import { ok, test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + async test({ assert, target, errors }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, '1 '); + assert.deepEqual(errors, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-destroy/main.svelte b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-destroy/main.svelte new file mode 100644 index 0000000000..57c467a80c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-destroy/main.svelte @@ -0,0 +1,14 @@ + + +{aborted} + + + +{#if count % 2 === 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-rerun-on-reconnect/_config.js b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-rerun-on-reconnect/_config.js new file mode 100644 index 0000000000..7caf765e3b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-rerun-on-reconnect/_config.js @@ -0,0 +1,32 @@ +import { test } from '../../test'; +import { tick } from 'svelte'; + +export default test({ + async test({ assert, target }) { + const [increment, toggle, resolve] = target.querySelectorAll('button'); + const [div] = target.querySelectorAll('div'); + + assert.htmlEqual(div.innerHTML, 'loading'); + resolve.click(); + await tick(); + assert.htmlEqual(div.innerHTML, '0'); + + increment.click(); + await tick(); + assert.htmlEqual(div.innerHTML, 'loading'); + + toggle.click(); + await tick(); + assert.htmlEqual(div.innerHTML, ''); + + toggle.click(); + await tick(); + assert.htmlEqual(div.innerHTML, 'loading'); + + resolve.click(); // this one's for clearing the obsolete/aborted one from the queue + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual(div.innerHTML, '2'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-rerun-on-reconnect/main.svelte b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-rerun-on-reconnect/main.svelte new file mode 100644 index 0000000000..a047afdd44 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-rerun-on-reconnect/main.svelte @@ -0,0 +1,32 @@ + + + + + + +
+ {#if show} + {#await double} + loading + {:then value} + {value} + {:catch} + error + {/await} + {/if} +
diff --git a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/_config.js b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/_config.js index 2dacf188d7..aa38dd8280 100644 --- a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/_config.js @@ -4,9 +4,11 @@ import { flushSync } from 'svelte'; export default test({ async test({ assert, target, errors }) { const btn = target.querySelector('button'); + flushSync(() => { btn?.click(); }); + assert.htmlEqual(target.innerHTML, '1:1 '); assert.deepEqual(errors, []); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/main.svelte b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/main.svelte index ebefe38fb2..e76cecbac9 100644 --- a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/main.svelte @@ -9,9 +9,9 @@ const signal = getAbortSignal(); signal.addEventListener("abort", () => { - try{ + try { aborted++; - }catch(e){ + } catch(e) { console.error(e); } }); @@ -19,6 +19,6 @@ }) -{der} +{der}:{aborted} - \ No newline at end of file + diff --git a/packages/svelte/tests/runtime-runes/samples/async-await-block-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-await-block-2/_config.js new file mode 100644 index 0000000000..0a927e64f6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-await-block-2/_config.js @@ -0,0 +1,24 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [incremet, resolve] = target.querySelectorAll('button'); + + incremet.click(); + await tick(); + incremet.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 4 4 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-await-block-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-await-block-2/main.svelte new file mode 100644 index 0000000000..b619c6a949 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-await-block-2/main.svelte @@ -0,0 +1,22 @@ + + + + +{#await request(count) then result}{result}{/await} +{#await await push(count) + count then result}{result}{/await} +{#await await 1 then result}{result}{/await} diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-derived/_config.js new file mode 100644 index 0000000000..d5286d9d95 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-derived/_config.js @@ -0,0 +1,28 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

Loading...

` + ); + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

Loading...

` + ); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-derived/main.svelte new file mode 100644 index 0000000000..af7f0f468c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-derived/main.svelte @@ -0,0 +1,29 @@ + + + + +{#snippet defaultPending()} +

Loading...

+{/snippet} + +{#if count > 0} + + {await push(count)} {count} {other} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js new file mode 100644 index 0000000000..c2be623de2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [x, x_y, pop] = target.querySelectorAll('button'); + + x.click(); + await tick(); + x_y.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ' 2 1 1' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte new file mode 100644 index 0000000000..61efd4fca0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-merge-effect/main.svelte @@ -0,0 +1,25 @@ + + + + + + +{await push(x)} {await push(y)} + +{#if true} + {y} +{/if} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-bind-factory-function-remote/_config.js b/packages/svelte/tests/runtime-runes/samples/async-bind-factory-function-remote/_config.js index 080e2d278c..60e58b27a1 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-bind-factory-function-remote/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-bind-factory-function-remote/_config.js @@ -3,12 +3,12 @@ import { test } from '../../test'; export default test({ mode: ['async-server', 'client', 'hydrate'], - ssrHtml: 'true true true true true', + ssrHtml: 'true true true true true true', async test({ assert, target }) { await new Promise((resolve) => setTimeout(resolve, 10)); await tick(); - assert.htmlEqual(target.innerHTML, 'true true true true true'); + assert.htmlEqual(target.innerHTML, 'true true true true true true'); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-bind-factory-function-remote/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-bind-factory-function-remote/main.svelte index 5f79a14830..4f8f2f97b8 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-bind-factory-function-remote/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-bind-factory-function-remote/main.svelte @@ -21,6 +21,8 @@ const indirect = () => checkedFactory()(); return indirect; } + + const arrow = () => () => checked; @@ -39,3 +41,6 @@ {#if true} {indirectChecked2()()} {/if} +{#if true} + {arrow()()} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js new file mode 100644 index 0000000000..faf1ff7f6b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/_config.js @@ -0,0 +1,17 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' done'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte new file mode 100644 index 0000000000..4780442293 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-merge-obsolete/main.svelte @@ -0,0 +1,21 @@ + + + + +{#if count < 3} + {await push(count)} +{:else} + done +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js new file mode 100644 index 0000000000..928db008e6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js @@ -0,0 +1,58 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [inc_count, inc_both, shift] = target.querySelectorAll('button'); + + inc_both.click(); + await tick(); + inc_count.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 0 + 0 + + ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 1 + 2 + + ` + ); + + const button = /** @type {HTMLButtonElement} */ (target.querySelector('button:last-child')); + button.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 2 + 2 + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte new file mode 100644 index 0000000000..92b7669fa9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte @@ -0,0 +1,23 @@ + + + + + + +{await push(other)} +{#if count % 2 === 0} + {await push(count)} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-const-closure-read/_config.js b/packages/svelte/tests/runtime-runes/samples/async-const-closure-read/_config.js new file mode 100644 index 0000000000..be0e3c7f40 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-const-closure-read/_config.js @@ -0,0 +1,10 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// #18469 — a @const in a nested snippet reading an async declaration through a closure must block on it +export default test({ + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, '

true

false

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-const-closure-read/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-const-closure-read/main.svelte new file mode 100644 index 0000000000..881455ec27 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-const-closure-read/main.svelte @@ -0,0 +1,12 @@ + + +{#each [['a', 'b'], ['a', 'x']] as keys} + {@const all_present = keys.every((k) => value.has(k))} +

{all_present}

+{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/async-custom-element-attribute/_config.js b/packages/svelte/tests/runtime-runes/samples/async-custom-element-attribute/_config.js new file mode 100644 index 0000000000..788573d7cf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-custom-element-attribute/_config.js @@ -0,0 +1,11 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ target, assert }) { + await tick(); + const [element] = target.querySelectorAll('async-custom-element'); + + assert.htmlEqual(element.innerHTML, `Hello foobar!`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-custom-element-attribute/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-custom-element-attribute/main.svelte new file mode 100644 index 0000000000..a047054069 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-custom-element-attribute/main.svelte @@ -0,0 +1,41 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-closure-read/_config.js b/packages/svelte/tests/runtime-runes/samples/async-declaration-closure-read/_config.js new file mode 100644 index 0000000000..6a10f6928a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-closure-read/_config.js @@ -0,0 +1,11 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// #18469 — a sync $derived in a nested snippet reading an async declaration through a closure must block on it +export default test({ + ssrHtml: '

true

false

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

true

false

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-closure-read/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-declaration-closure-read/main.svelte new file mode 100644 index 0000000000..ddd9a10b04 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-closure-read/main.svelte @@ -0,0 +1,17 @@ + + +{#snippet outer()} + {const value = $derived(await getValue())} + {#snippet inner(keys)} + {const all_present = $derived(keys.every((k) => value.has(k)))} +

{all_present}

+ {/snippet} + {@render inner(['a', 'b'])} + {@render inner(['a', 'x'])} +{/snippet} + +{@render outer()} diff --git a/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js new file mode 100644 index 0000000000..0dd4b870d5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-declaration-tag-2/_config.js @@ -0,0 +1,13 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['async-server', 'client', 'hydrate'], + ssrHtml: `

Hello, world!

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

Hello, world!

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

{greeting}

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

Hello name

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

Hello name

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

Hello other

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

{greeting}

+
+ {const nested = 'nested'} + {const greeting2 = $derived(await `Hi ${name}`)} + {nested} {greeting2} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/_config.js new file mode 100644 index 0000000000..1a7ed0eb92 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/_config.js @@ -0,0 +1,42 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO fix + async test({ assert, target, logs }) { + await tick(); + + const [a, b, log, resolve] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + a.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0 0 0'); + + b.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0 0 0'); + + log.click(); + await tick(); + assert.deepEqual(logs, [0, 2]); + + resolve.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '1 0 1'); + assert.deepEqual(logs, [0, 2, 1]); + + log.click(); + await tick(); + assert.deepEqual(logs, [0, 2, 1, 2]); + + resolve.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '1 1 2'); + assert.deepEqual(logs, [0, 2, 1, 2, 2]); + + log.click(); + await tick(); + assert.deepEqual(logs, [0, 2, 1, 2, 2, 2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/main.svelte new file mode 100644 index 0000000000..bf543ba595 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/main.svelte @@ -0,0 +1,23 @@ + + + + + + + +

{await push(a)} {await push(b)} {d}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/_config.js new file mode 100644 index 0000000000..b959ba5407 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/_config.js @@ -0,0 +1,35 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO fix + async test({ assert, target, logs }) { + await tick(); + + const [a, b, log, resolve] = target.querySelectorAll('button'); + const [div] = target.querySelectorAll('div'); + + assert.deepEqual(logs, ['e1 0', 'e2 0']); + logs.length = 0; + + a.click(); + await tick(); + assert.htmlEqual(div.innerHTML, '

0

0

0

'); + + b.click(); + await tick(); + assert.htmlEqual(div.innerHTML, '

0

1

1

'); + + log.click(); + await tick(); + assert.deepEqual(logs, ['e1 1', 'e2 1', 'runs 2']); // ideally it's only 2 runs, one or two more would also be acceptable but not the 8 that it's today + logs.length = 0; + + resolve.click(); + await tick(); + log.click(); + await tick(); + assert.htmlEqual(div.innerHTML, '

1

2

2

'); + assert.deepEqual(logs, ['e1 2', 'e2 2', 'runs 3']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/main.svelte new file mode 100644 index 0000000000..784932ab5d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/main.svelte @@ -0,0 +1,29 @@ + + + + + + + +
+

{await push(a)}

+

{d}

+

{d}

+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/_config.js new file mode 100644 index 0000000000..7d31d30b46 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/_config.js @@ -0,0 +1,28 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // TODO fix + async test({ assert, target, logs }) { + await tick(); + + const [a, b, resolve] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + assert.deepEqual(logs, ['b: 0, d: 0']); + logs.length = 0; + + a.click(); + await tick(); + b.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0 1'); + assert.deepEqual(logs, ['b: 1, d: 0']); + logs.length = 0; + + resolve.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '2 1'); + assert.deepEqual(logs, ['b: 1, d: 2']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/main.svelte new file mode 100644 index 0000000000..97d91ae994 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/main.svelte @@ -0,0 +1,22 @@ + + + + + + +

{await push(d)} {b}

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

example.com

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

example.com

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

{(() => host)()}

+{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js new file mode 100644 index 0000000000..29d374deec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js @@ -0,0 +1,50 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Regression for #18610: emptying a controlled keyed {#each} while another +// batch is still pending must not take the fast path that clears state.items +// before destroy_effects walks pending keys. +export default test({ + mode: ['client'], + + async test({ assert, target }) { + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

A0/B0

+
12
+ ` + ); + + const [startA, startB, settleB] = target.querySelectorAll('button'); + + // Batch A: add key 9, then block forever on gate A. + startA.click(); + await tick(); + + // Batch B: empty the collection, then block on gate B. + startB.click(); + await tick(); + + // Settle B first so B commits while A is still pending. + // Without the fix this throws reading `.e` of undefined and leaves a/b stuck. + settleB.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

A0/B1

+
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte new file mode 100644 index 0000000000..68c3937c25 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte @@ -0,0 +1,54 @@ + + + + + + +

{a}/{b}

+ + +
+ {#each items as item (item)} + {item} + {/each} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-block/_config.js new file mode 100644 index 0000000000..b6d283c8a5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-block/_config.js @@ -0,0 +1,23 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0

loading...

' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 1

1

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-block/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-block/main.svelte new file mode 100644 index 0000000000..61f4705d06 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-block/main.svelte @@ -0,0 +1,20 @@ + + + + + +{await delay(count)} +{#if $state.eager(count) !== count} +

loading...

+{:else} +

{count}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/_config.js new file mode 100644 index 0000000000..a4921a5115 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + logs.length = 0; + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` +
  • 0 / 0
  • 0 / loading...
  • 0 / 0
` + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` +
  • 0 / 0
  • 1 / 1
  • 0 / 0
` + ); + + assert.equal( + logs.some((l) => l.toString().includes('0 ') || l.toString().includes('2')), + false, + 'only the second $state.eager should have been evaluated' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/main.svelte new file mode 100644 index 0000000000..f27e256e9f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-each-block/main.svelte @@ -0,0 +1,24 @@ + + + + + +
    + {#each counts as count, i} +
  • + {await delay(count)} / + {#if console.log(i) || $state.eager(count) !== count} + loading... + {:else} + {count} + {/if} +
  • + {/each} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte new file mode 100644 index 0000000000..18856d71e1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/Child.svelte @@ -0,0 +1,6 @@ + + +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js new file mode 100644 index 0000000000..7a6e436825 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/_config.js @@ -0,0 +1,10 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // Test that $effect/onMount etc at the top level of components are correctly deferred/coordinated if inside an async block + async test({ assert, logs }) { + await tick(); + assert.deepEqual(logs, [true]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte new file mode 100644 index 0000000000..934d9d7f5b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-mount-timing/main.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/_config.js new file mode 100644 index 0000000000..b76ea85bfd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/_config.js @@ -0,0 +1,12 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, logs }) { + await tick(); + + assert.deepEqual(logs, [ + `effect_orphan\n\`$effect\` can only be used inside an effect (e.g. during component initialisation)\nhttps://svelte.dev/e/effect_orphan` + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/main.svelte new file mode 100644 index 0000000000..086a048025 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-orphan/main.svelte @@ -0,0 +1,14 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/Child.svelte new file mode 100644 index 0000000000..8bfc51bb7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-4/Child.svelte @@ -0,0 +1,6 @@ + + +

{model.title}

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

error was contained

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

loading…

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

error was contained

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

error escaped containment

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/Child.svelte new file mode 100644 index 0000000000..a49033a8cc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/Child.svelte @@ -0,0 +1,7 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/_config.js new file mode 100644 index 0000000000..29e470e46f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/_config.js @@ -0,0 +1,14 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['hydrate'], + server_props: { environment: 'server' }, + props: { environment: 'client' }, + ssrHtml: 'loading inner loading nested', + + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, 'inner failed: oops outer failed: oops'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/main.svelte new file mode 100644 index 0000000000..16a54630d0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-boundary-5/main.svelte @@ -0,0 +1,24 @@ + + + + + {await new Promise(() => {})} + + {#snippet pending()}loading inner{/snippet} + {#snippet failed(error)}inner failed: {error.message}{/snippet} + + + + + + {await new Promise(() => {})} + + {#snippet pending()}loading nested{/snippet} + + + {#snippet failed(error)}outer failed: {error.message}{/snippet} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js new file mode 100644 index 0000000000..e127fd03ec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/_config.js @@ -0,0 +1,39 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [fork, real, resolve] = target.querySelectorAll('button'); + + fork.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + 0 + + + + ` + ); + assert.deepEqual(logs, [0]); + + real.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + 1 + + + + ` + ); + assert.deepEqual(logs, [0, 1]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte new file mode 100644 index 0000000000..85056e02b8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-ignore/main.svelte @@ -0,0 +1,23 @@ + + +{await push(count)} + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js new file mode 100644 index 0000000000..5e18c953c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' failed'); + + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' failed'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte new file mode 100644 index 0000000000..3293e4ab88 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-promise-fails-first/main.svelte @@ -0,0 +1,22 @@ + + + + + + + {await push(count)} + + {#snippet failed()}failed{/snippet} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-parallel-derived-template-mutation/_config.js b/packages/svelte/tests/runtime-runes/samples/async-parallel-derived-template-mutation/_config.js new file mode 100644 index 0000000000..9650e88993 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-parallel-derived-template-mutation/_config.js @@ -0,0 +1,44 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + + compileOptions: { + dev: true + }, + + async test({ assert, target, errors }) { + await tick(); + const [increment, update, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + + resolve.click(); + await tick(); + + update.click(); + await tick(); + + resolve.click(); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual( + target.innerHTML, + ` + + + +

count: 1

+

submits: 1

+

pending: 0

+

2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-parallel-derived-template-mutation/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-parallel-derived-template-mutation/main.svelte new file mode 100644 index 0000000000..d9c804b314 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-parallel-derived-template-mutation/main.svelte @@ -0,0 +1,28 @@ + + + + + + +

count: {count}

+

submits: {submits}

+

pending: {$effect.pending()}

+

{await a + await b}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js new file mode 100644 index 0000000000..e7359efa2d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // This test mainly checks that we don't run into the 'Batch has scheduled roots' invariant wrongly. + // It is crafted such that two batches are scheduled to run in the same microtask, and the first + // tries to rebase the second. + async test({ assert, target }) { + await tick(); + const [run] = target.querySelectorAll('button'); + + run.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' none none 0'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte new file mode 100644 index 0000000000..19305cfb76 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-batch/main.svelte @@ -0,0 +1,8 @@ + + + + +{selectedId ?? "none"} {selectedOption ?? "none"} {$effect.pending()} diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/_config.js new file mode 100644 index 0000000000..4bd8adc9c5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/_config.js @@ -0,0 +1,15 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { dev: true }, + async test({ assert, target, warnings }) { + await tick(); + const [increment] = target.querySelectorAll('button'); + + increment.click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.htmlEqual(target.innerHTML, ' 1 1'); + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/main.svelte new file mode 100644 index 0000000000..d47d99e652 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-no-false-positive-4/main.svelte @@ -0,0 +1,16 @@ + + + + +{await x} +{y} diff --git a/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/Child.svelte new file mode 100644 index 0000000000..6f84dc319e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/Child.svelte @@ -0,0 +1,7 @@ + + +{x} {y} diff --git a/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/_config.js new file mode 100644 index 0000000000..a0dc38c660 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/_config.js @@ -0,0 +1,26 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [show, resolve, count] = target.querySelectorAll('button'); + + show.click(); + await tick(); + resolve.click(); + await tick(); + count.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + '1 2 ' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/main.svelte new file mode 100644 index 0000000000..0a5346cc47 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-run-isolated-batch/main.svelte @@ -0,0 +1,23 @@ + + + + +{#if show} + +{/if} + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/_config.js new file mode 100644 index 0000000000..9608e294e7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + compileOptions: { + dev: true + }, + + async test({ assert, target, errors }) { + await new Promise((resolve) => setTimeout(resolve, 20)); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual(target.innerHTML, '

pending

1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/main.svelte new file mode 100644 index 0000000000..3545c69451 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-await-block-pending/main.svelte @@ -0,0 +1,14 @@ + + +{#await (await input).pending} +

pending

+{/await} + +

{foreign}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/_config.js b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/_config.js new file mode 100644 index 0000000000..050ee60e09 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/_config.js @@ -0,0 +1,22 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + + compileOptions: { + dev: false + }, + + async test({ assert, target, errors }) { + await tick(); + await tick(); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual(target.innerHTML, '

4 1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/main.svelte new file mode 100644 index 0000000000..ef69eb1154 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation-prod/main.svelte @@ -0,0 +1,20 @@ + + +

{total} {foreign}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/_config.js b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/_config.js new file mode 100644 index 0000000000..371cca3ff3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/_config.js @@ -0,0 +1,21 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + + compileOptions: { + dev: true + }, + + async test({ assert, target, errors }) { + await tick(); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual(target.innerHTML, '

3 1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/main.svelte new file mode 100644 index 0000000000..c85206e7f1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-foreign-microtask-mutation/main.svelte @@ -0,0 +1,21 @@ + + +

{length} {foreign}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/_config.js b/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/_config.js new file mode 100644 index 0000000000..5999daa5d2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + compileOptions: { + dev: true + }, + + async test({ assert, target, errors }) { + await tick(); + await tick(); + + assert.deepEqual( + errors.filter((error) => error.includes('state_unsafe_mutation')), + [] + ); + assert.htmlEqual(target.innerHTML, '

failed

1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/main.svelte new file mode 100644 index 0000000000..e94bf86568 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-save-throwing-expression/main.svelte @@ -0,0 +1,26 @@ + + + {}}> + {#snippet failed()} +

failed

+ {/snippet} + +

{(await input).value}

+
+ +

{foreign}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js new file mode 100644 index 0000000000..29ed72d90a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.deepEqual(logs, ['settled 2', 'settled 2']); + assert.htmlEqual( + target.innerHTML, + ` + 2 + + + ` + ); + + pop.click(); + await tick(); + assert.deepEqual(logs, ['settled 2', 'settled 2']); + assert.htmlEqual( + target.innerHTML, + ` + 2 + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte new file mode 100644 index 0000000000..c3675f8add --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte @@ -0,0 +1,22 @@ + + +{await push(count)} + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-sole-if-child/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-sole-if-child/Child.svelte new file mode 100644 index 0000000000..304b7bd8a9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-sole-if-child/Child.svelte @@ -0,0 +1,7 @@ + + +
+