diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dcf1e45dc..23d814d527 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 24 cache: pnpm - name: install run: pnpm install --frozen-lockfile diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index 0f0c433361..e7cc1facad 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -4,47 +4,32 @@ on: types: [opened, synchronize] push: branches: [main] + workflow_dispatch: + inputs: + sha: + description: 'Commit SHA to build' + required: true + type: string permissions: {} jobs: - # This job determines the environment to use for the build job. It ensures that: - # - For pushes to main, we use the "Publish pkg.pr.new (maintainers)" environment. - # - For PRs from the same repository, we also use the "Publish pkg.pr.new (maintainers)" environment, since these are trusted. - # - For PRs from forks, we use the "Publish pkg.pr.new (external contributors)" environment, which requires manual approval by a maintainer before the build job can run. - # This protects us from running untrusted code while still allowing external contributors to use pkg.pr.new. - resolve-env: - runs-on: ubuntu-latest - outputs: - environment: ${{ steps.resolve.outputs.environment }} - steps: - - name: Determine environment - id: resolve - run: | - if [[ "${{ github.event_name }}" == "push" ]]; then - echo "environment=Publish pkg.pr.new (maintainers)" >> "$GITHUB_OUTPUT" - elif [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then - echo "environment=Publish pkg.pr.new (maintainers)" >> "$GITHUB_OUTPUT" - else - echo "environment=Publish pkg.pr.new (external contributors)" >> "$GITHUB_OUTPUT" - fi - build: - needs: resolve-env + # 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 - # This is the line that ensures forks require manual approval before running the build job - environment: ${{ needs.resolve-env.outputs.environment }} - # No permissions — this job runs user-controlled code permissions: {} steps: - uses: actions/checkout@v6 with: - # For pull_request_target, we must explicitly check out the PR head. - # This is safe because the environment gate above has already fired — - # an org member has approved this specific commit for external PRs. - ref: ${{ github.event.pull_request.head.sha || github.sha }} + # 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@v4 - uses: actions/setup-node@v6 @@ -119,10 +104,11 @@ jobs: comment: needs: sanitize - if: github.event_name == 'pull_request_target' + if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: @@ -131,6 +117,37 @@ jobs: 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; + } + + const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: '${{ inputs.sha }}', + }); + + const open = pulls.filter(p => p.state === 'open'); + + if (open.length === 0) { + core.setFailed(`No open PR found for commit ${{ inputs.sha }}`); + return; + } + + if (open.length > 1) { + const nums = open.map(p => `#${p.number}`).join(', '); + core.setFailed(`Multiple open PRs found for commit ${{ inputs.sha }}: ${nums}`); + return; + } + + core.setOutput('number', open[0].number); + - name: Post or update comment uses: actions/github-script@v8 with: @@ -144,8 +161,7 @@ jobs: return; } - // Issue number from trusted event context, never from the artifact - const issue_number = context.issue.number; + const issue_number = parseInt('${{ steps.pr.outputs.number }}', 10); const bot_comment_identifier = ``; diff --git a/documentation/docs/03-template-syntax/03-each.md b/documentation/docs/03-template-syntax/03-each.md index 424e16a18a..2fe7f28bf2 100644 --- a/documentation/docs/03-template-syntax/03-each.md +++ b/documentation/docs/03-template-syntax/03-each.md @@ -13,7 +13,7 @@ tags: template-each {#each expression as name, index}...{/each} ``` -Iterating over values can be done with an each block. The values in question can be arrays, array-like objects (i.e. anything with a `length` property), or iterables like `Map` and `Set`— in other words, anything that can be used with `Array.from`. +Iterating over values can be done with an each block. The values in question can be arrays, array-like objects (i.e. anything with a `length` property), or iterables like `Map` and `Set`. (Internally, they are converted to arrays with [`Array.from`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from).) If the value is `null` or `undefined`, it is treated the same as an empty array (which will cause [else blocks](#Else-blocks) to be rendered, where applicable). diff --git a/eslint.config.js b/eslint.config.js index 04d9294394..0e8382427b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -33,7 +33,7 @@ const no_compiler_imports = { } }; -/** @type {import('eslint').Linter.FlatConfig[]} */ +/** @type {import('eslint').Linter.Config[]} */ export default [ ...svelte_config, { diff --git a/package.json b/package.json index e7faf26c13..b305ce9cd1 100644 --- a/package.json +++ b/package.json @@ -27,22 +27,30 @@ }, "devDependencies": { "@changesets/cli": "^2.29.8", - "@sveltejs/eslint-config": "^8.3.3", + "@sveltejs/eslint-config": "^8.3.5", "@svitejs/changesets-changelog-github-compact": "^1.1.0", "@types/node": "^20.11.5", "@types/picomatch": "^4.0.2", "@vitest/coverage-v8": "^2.1.9", - "eslint": "^9.9.1", - "eslint-plugin-lube": "^0.4.3", - "eslint-plugin-svelte": "^3.11.0", + "@eslint/js": "^10.0.0", + "eslint": "^10.0.0", + "eslint-plugin-lube": "^0.5.1", + "eslint-plugin-svelte": "^3.15.0", "jsdom": "25.0.1", "playwright": "^1.58.0", "prettier": "^3.2.4", "prettier-plugin-svelte": "^3.4.0", "svelte": "workspace:^", "typescript": "^5.5.4", - "typescript-eslint": "^8.48.1", + "typescript-eslint": "^8.55.0", "v8-natives": "^1.2.5", "vitest": "^2.1.9" + }, + "pnpm": { + "peerDependencyRules": { + "allowedVersions": { + "eslint": "10" + } + } } } diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 3fcc8edfc1..542ae4c8f6 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,47 @@ # svelte +## 5.51.0 + +### Minor Changes + +- feat: Use `TrustedTypes` for HTML handling where supported ([#16271](https://github.com/sveltejs/svelte/pull/16271)) + +### Patch Changes + +- fix: sanitize template-literal-special-characters in SSR attribute values ([#17692](https://github.com/sveltejs/svelte/pull/17692)) + +- fix: follow-up formatting in `print()` — flush block-level elements into separate sequences ([#17699](https://github.com/sveltejs/svelte/pull/17699)) + +- fix: preserve delegated event handlers as long as one or more root components are using them ([#17695](https://github.com/sveltejs/svelte/pull/17695)) + +## 5.50.3 + +### Patch Changes + +- fix: take into account `nodeName` case sensitivity on XHTML pages ([#17689](https://github.com/sveltejs/svelte/pull/17689)) + +- fix: render `multiple` and `selected` attributes as empty strings for XHTML compliance ([#17689](https://github.com/sveltejs/svelte/pull/17689)) + +- fix: always lowercase HTML elements, for XHTML compliance ([#17664](https://github.com/sveltejs/svelte/pull/17664)) + +- fix: freeze effects-inside-deriveds when disconnecting, unfreeze on reconnect ([#17682](https://github.com/sveltejs/svelte/pull/17682)) + +- fix: propagate `$effect` errors to `` ([#17684](https://github.com/sveltejs/svelte/pull/17684)) + +## 5.50.2 + +### Patch Changes + +- fix: resolve `effect_update_depth_exceeded` when using `bind:value` on `` + // means we need to invalidate `bar` whenever `foo` is mutated + if (node.name === 'select' && !runes) { + for (const attribute of node.attributes) { + if ( + attribute.type === 'BindDirective' && + attribute.name === 'value' && + attribute.expression.type !== 'SequenceExpression' + ) { + const identifier = object(attribute.expression); + const binding = identifier && context.state.scope.get(identifier.name); + + if (binding) { + for (const name of context.state.scope.references.keys()) { + if (name === binding.node.name) continue; + const indirect = context.state.scope.get(name); + + if (indirect) { + binding.legacy_indirect_bindings.add(indirect); + } + } + } + + break; + } + } + } + // Special case: single expression tag child of option element -> add "fake" attribute // to ensure that value types are the same (else for example numbers would be strings) if ( diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/template.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/template.js index 8f7f8a1f43..f546ce4962 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/template.js @@ -30,13 +30,15 @@ export class Template { /** * @param {string} name * @param {number} start + * @param {boolean} is_html */ - push_element(name, start) { + push_element(name, start, is_html) { this.#element = { type: 'element', name, attributes: {}, children: [], + is_html, start }; @@ -100,7 +102,7 @@ function stringify(item) { for (const key in item.attributes) { const value = item.attributes[key]; - str += ` ${key}`; + str += ` ${item.is_html ? key.toLowerCase() : key}`; if (value !== undefined) str += `="${escape_html(value, true)}"`; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/types.d.ts index 1555e10ef0..2ccb4dc5c7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/types.d.ts @@ -5,6 +5,7 @@ export interface Element { name: string; attributes: Record; children: Node[]; + is_html: boolean; /** used for populating __svelte_meta */ start: number; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index 0f6a619357..1379669e77 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -8,7 +8,7 @@ import { is_event_attribute } from '../../../../utils/ast.js'; import { dev, locate_node } from '../../../../state.js'; -import { should_proxy } from '../utils.js'; +import { build_getter, should_proxy } from '../utils.js'; import { visit_assignment_expression } from '../../shared/assignments.js'; import { validate_mutation } from './shared/utils.js'; import { get_rune } from '../../../scope.js'; @@ -147,7 +147,7 @@ function build_assignment(operator, left, right, context) { // mutation if (transform?.mutate) { - return transform.mutate( + let mutation = transform.mutate( object, b.assignment( operator, @@ -155,6 +155,25 @@ function build_assignment(operator, left, right, context) { /** @type {Expression} */ (context.visit(right)) ) ); + + if (binding.legacy_indirect_bindings.size > 0) { + mutation = b.sequence([ + mutation, + b.call( + '$.invalidate_inner_signals', + b.arrow( + [], + b.block( + Array.from(binding.legacy_indirect_bindings).map((binding) => + b.stmt(build_getter({ ...binding.node }, context.state)) + ) + ) + ) + ) + ]); + } + + return mutation; } // in cases like `(object.items ??= []).push(value)`, we may need to warn diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index 00b0cfaa2e..be919d380c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -47,7 +47,11 @@ export function Fragment(node, context) { const is_single_element = trimmed.length === 1 && trimmed[0].type === 'RegularElement'; const is_single_child_not_needing_template = trimmed.length === 1 && - (trimmed[0].type === 'SvelteFragment' || trimmed[0].type === 'TitleElement'); + (trimmed[0].type === 'SvelteFragment' || + trimmed[0].type === 'TitleElement' || + (trimmed[0].type === 'IfBlock' && + trimmed[0].elseif && + /** @type {AST.IfBlock} */ (parent).metadata.flattened?.includes(trimmed[0]))); const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent /** @type {Statement[]} */ diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js index c0e66635df..0d31c42d11 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js @@ -1,4 +1,4 @@ -/** @import { BlockStatement, Expression } from 'estree' */ +/** @import { BlockStatement, Expression, IfStatement, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import * as b from '#compiler/builders'; @@ -10,40 +10,75 @@ import { build_expression, add_svelte_meta } from './shared/utils.js'; */ export function IfBlock(node, context) { context.state.template.push_comment(); + + /** @type {Statement[]} */ const statements = []; - const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent)); - const consequent_id = b.id(context.state.scope.generate('consequent')); + const has_await = node.metadata.expression.has_await; + const has_blockers = node.metadata.expression.has_blockers(); + const expression = build_expression(context, node.test, node.metadata.expression); + + // Build the if/else-if/else chain + let index = 0; + /** @type {IfStatement | undefined} */ + let first_if; + /** @type {IfStatement | undefined} */ + let last_if; + /** @type {AST.IfBlock | undefined} */ + let last_alt; - statements.push(b.var(consequent_id, b.arrow([b.id('$$anchor')], consequent))); + for (const branch of [node, ...(node.metadata.flattened ?? [])]) { + const consequent = /** @type {BlockStatement} */ (context.visit(branch.consequent)); + const consequent_id = b.id(context.state.scope.generate('consequent')); + statements.push(b.var(consequent_id, b.arrow([b.id('$$anchor')], consequent))); - let alternate_id; + // Build the test expression for this branch + /** @type {Expression} */ + let test; - if (node.alternate) { - const alternate = /** @type {BlockStatement} */ (context.visit(node.alternate)); - alternate_id = b.id(context.state.scope.generate('alternate')); - statements.push(b.var(alternate_id, b.arrow([b.id('$$anchor')], alternate))); + if (branch.metadata.expression.has_await) { + // Top-level condition with await: already resolved by $.async wrapper + test = b.call('$.get', b.id('$$condition')); + } else { + const expression = build_expression(context, branch.test, branch.metadata.expression); + + if (branch.metadata.expression.has_call) { + const derived_id = b.id(context.state.scope.generate('d')); + statements.push(b.var(derived_id, b.call('$.derived', b.arrow([], expression)))); + test = b.call('$.get', derived_id); + } else { + test = expression; + } + } + + const render_call = b.stmt(b.call('$$render', consequent_id, index > 0 && b.literal(index))); + const new_if = b.if(test, render_call); + + if (last_if) { + last_if.alternate = new_if; + } else { + first_if = new_if; + } + + last_alt = branch; + last_if = new_if; + index++; } - const has_await = node.metadata.expression.has_await; - const has_blockers = node.metadata.expression.has_blockers(); + // Handle final alternate (else branch, remaining async chain, or nothing) + if (last_if && last_alt?.alternate) { + const alternate = /** @type {BlockStatement} */ (context.visit(last_alt.alternate)); + const alternate_id = b.id(context.state.scope.generate('alternate')); + statements.push(b.var(alternate_id, b.arrow([b.id('$$anchor')], alternate))); - const expression = build_expression(context, node.test, node.metadata.expression); - const test = has_await ? b.call('$.get', b.id('$$condition')) : expression; + last_if.alternate = b.stmt(b.call('$$render', alternate_id, b.literal(false))); + } + // Build $.if() arguments /** @type {Expression[]} */ const args = [ context.state.node, - b.arrow( - [b.id('$$render')], - b.block([ - b.if( - test, - b.stmt(b.call('$$render', consequent_id)), - alternate_id && b.stmt(b.call('$$render', alternate_id, b.literal(false))) - ) - ]) - ) + b.arrow([b.id('$$render')], first_if ? b.block([first_if]) : b.block([])) ]; if (node.elseif) { @@ -67,7 +102,9 @@ export function IfBlock(node, context) { // // ...even though they're logically equivalent. In the first case, the // transition will only play when `y` changes, but in the second it - // should play when `x` or `y` change — both are considered 'local' + // should play when `x` or `y` change — both are considered 'local'. + // This could also be a non-flattened elseif (because it has an async expression). + // In both cases mark as elseif so the runtime uses EFFECT_TRANSPARENT for transitions. args.push(b.true); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index f651586dd8..0579d80b74 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -38,9 +38,11 @@ import { TEMPLATE_FRAGMENT } from '../../../../../constants.js'; * @param {ComponentContext} context */ export function RegularElement(node, context) { - context.state.template.push_element(node.name, node.start); + const is_html = context.state.metadata.namespace === 'html' && node.name !== 'svg'; + const name = is_html ? node.name.toLowerCase() : node.name; + context.state.template.push_element(name, node.start, is_html); - if (node.name === 'noscript') { + if (name === 'noscript') { context.state.template.pop_element(); return; } @@ -53,9 +55,9 @@ export function RegularElement(node, context) { // Therefore we need to use importNode instead, which doesn't have this caveat. // Additionally, Webkit browsers need importNode for video elements for autoplay // to work correctly. - context.state.template.needs_import_node ||= node.name === 'video' || is_custom_element; + context.state.template.needs_import_node ||= name === 'video' || is_custom_element; - context.state.template.contains_script_tag ||= node.name === 'script'; + context.state.template.contains_script_tag ||= name === 'script'; /** @type {Array} */ const attributes = []; @@ -161,7 +163,7 @@ export function RegularElement(node, context) { } } - if (node.name === 'input') { + if (name === 'input') { const has_value_attribute = attributes.some( (attribute) => attribute.type === 'Attribute' && @@ -190,7 +192,7 @@ export function RegularElement(node, context) { } } - if (node.name === 'textarea') { + if (name === 'textarea') { const attribute = lookup.get('value') ?? lookup.get('checked'); const needs_content_reset = attribute && !is_text_attribute(attribute); @@ -199,10 +201,6 @@ export function RegularElement(node, context) { } } - if (node.name === 'select' && bindings.has('value')) { - setup_select_synchronization(/** @type {AST.BindDirective} */ (bindings.get('value')), context); - } - // Let bindings first, they can be used on attributes context.state.init.push(...lets); @@ -210,10 +208,7 @@ export function RegularElement(node, context) { /** If true, needs `__value` for inputs */ const needs_special_value_handling = - node.name === 'option' || - node.name === 'select' || - bindings.has('group') || - bindings.has('checked'); + name === 'option' || name === 'select' || bindings.has('group') || bindings.has('checked'); if (has_spread) { build_attribute_effect( @@ -262,7 +257,6 @@ export function RegularElement(node, context) { let { value } = build_attribute_value(attribute.value, context); context.state.init.push(b.stmt(b.call('$.autofocus', node_id, value))); } else if (name === 'class') { - const is_html = context.state.metadata.namespace === 'html' && node.name !== 'svg'; build_set_class(node, node_id, attribute, class_directives, context, is_html); } else if (name === 'style') { build_set_style(node_id, attribute, style_directives, context); @@ -283,7 +277,7 @@ export function RegularElement(node, context) { } if ( - is_load_error_element(node.name) && + is_load_error_element(name) && (has_spread || has_use || lookup.has('onload') || lookup.has('onerror')) ) { context.state.after_update.push(b.stmt(b.call('$.replay_events', node_id))); @@ -311,8 +305,7 @@ export function RegularElement(node, context) { ...context.state, metadata, scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)), - preserve_whitespace: - context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea' + preserve_whitespace: context.state.preserve_whitespace || name === 'pre' || name === 'textarea' }; const { hoisted, trimmed } = clean_nodes( @@ -321,7 +314,7 @@ export function RegularElement(node, context) { context.path, state.metadata.namespace, state, - node.name === 'script' || state.preserve_whitespace, + name === 'script' || state.preserve_whitespace, state.options.preserveComments ); @@ -367,7 +360,7 @@ export function RegularElement(node, context) { context.state.template.push_comment(); // Create a separate template for the rich content - const template_name = context.state.scope.root.unique(`${node.name}_content`); + const template_name = context.state.scope.root.unique(`${name}_content`); const fragment_id = b.id(context.state.scope.generate('fragment')); const anchor_id = b.id(context.state.scope.generate('anchor')); @@ -418,7 +411,7 @@ export function RegularElement(node, context) { // The same applies if it's a `