diff --git a/.changeset/orange-wasps-visit.md b/.changeset/orange-wasps-visit.md deleted file mode 100644 index 972ff636b8..0000000000 --- a/.changeset/orange-wasps-visit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: don't swallow `DOMException` when `media.play()` fails in `bind:paused` diff --git a/.changeset/wild-dolls-hang.md b/.changeset/wild-dolls-hang.md deleted file mode 100644 index a7b3436d69..0000000000 --- a/.changeset/wild-dolls-hang.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: reduce if block nesting 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/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 8601a728a7..7fccac5808 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -62,6 +62,14 @@ Keyed each block has duplicate key at indexes %a% and %b% Keyed each block has duplicate key `%value%` at indexes %a% and %b% ``` +### each_key_volatile + +``` +Keyed each block has key that is not idempotent — the key for item at index %index% was `%a%` but is now `%b%`. Keys must be the same each time for a given item +``` + +The key expression in a keyed each block must return the same value when called multiple times for the same item. Using expressions like `[item.a, item.b]` creates a new array each time, which will never be equal to itself. Instead, use a primitive value or create a stable key like `item.a + '-' + item.b`. + ### effect_in_teardown ``` diff --git a/documentation/docs/98-reference/.generated/server-errors.md b/documentation/docs/98-reference/.generated/server-errors.md index c98756afec..0ee6fd0614 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -16,6 +16,14 @@ Encountered asynchronous work while rendering synchronously. You (or the framework you're using) called [`render(...)`](svelte-server#render) with a component containing an `await` expression. Either `await` the result of `render` or wrap the `await` (or the component containing it) in a [``](svelte-boundary) with a `pending` snippet. +### dynamic_element_invalid_tag + +``` +`` is not a valid element name — the element will not be rendered +``` + +The value passed to the `this` prop of `` must be a valid HTML element, SVG element, MathML element, or custom element name. A value containing invalid characters (such as whitespace or special characters) was provided, which could be a security risk. Ensure only valid tag names are passed. + ### html_deprecated ``` diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 3fcc8edfc1..fa0cc5a775 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,99 @@ # svelte +## 5.51.5 + +### Patch Changes + +- fix: check to make sure `svelte:element` tags are valid during SSR ([`73098bb26c6f06e7fd1b0746d817d2c5ee90755f`](https://github.com/sveltejs/svelte/commit/73098bb26c6f06e7fd1b0746d817d2c5ee90755f)) + +- fix: misc option escaping and backwards compatibility ([#17741](https://github.com/sveltejs/svelte/pull/17741)) + +- fix: strip event handlers during SSR ([`a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5`](https://github.com/sveltejs/svelte/commit/a0c7f289156e9fafaeaf5ca14af6c06fe9b9eae5)) + +- fix: replace usage of `for in` with `for of Object.keys` ([`f89c7ddd7eebaa1ef3cc540400bec2c9140b330c`](https://github.com/sveltejs/svelte/commit/f89c7ddd7eebaa1ef3cc540400bec2c9140b330c)) + +- fix: always escape option body in SSR ([`f7c80da18c215e3727c2a611b0b8744cc6e504c5`](https://github.com/sveltejs/svelte/commit/f7c80da18c215e3727c2a611b0b8744cc6e504c5)) + +- chore: upgrade `devalue` ([#17739](https://github.com/sveltejs/svelte/pull/17739)) + +## 5.51.4 + +### Patch Changes + +- chore: proactively defer effects in pending boundary ([#17734](https://github.com/sveltejs/svelte/pull/17734)) + +- fix: detect and error on non-idempotent each block keys in dev mode ([#17732](https://github.com/sveltejs/svelte/pull/17732)) + +## 5.51.3 + +### Patch Changes + +- fix: prevent event delegation logic conflicting between svelte instances ([#17728](https://github.com/sveltejs/svelte/pull/17728)) + +- fix: treat CSS attribute selectors as case-insensitive for HTML enumerated attributes ([#17712](https://github.com/sveltejs/svelte/pull/17712)) + +- fix: locate Rollup annontaion friendly to JS downgraders ([#17724](https://github.com/sveltejs/svelte/pull/17724)) + +- fix: run effects in pending snippets ([#17719](https://github.com/sveltejs/svelte/pull/17719)) + +## 5.51.2 + +### Patch Changes + +- fix: take async into consideration for dev delegated handlers ([#17710](https://github.com/sveltejs/svelte/pull/17710)) + +- fix: emit state_referenced_locally warning for non-destructured props ([#17708](https://github.com/sveltejs/svelte/pull/17708)) + +## 5.51.1 + +### Patch Changes + +- fix: don't crash on undefined `document.contentType` ([#17707](https://github.com/sveltejs/svelte/pull/17707)) + +- fix: use symbols for encapsulated event delegation ([#17703](https://github.com/sveltejs/svelte/pull/17703)) + +## 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/OnDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/OnDirective.js index 0ee3b0fb10..011d851023 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/OnDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/OnDirective.js @@ -34,5 +34,5 @@ export function OnDirective(node, context) { node.modifiers.includes('passive') || (node.modifiers.includes('nonpassive') ? false : undefined); - return build_event(node.name, context.state.node, handler, capture, passive); + return build_event(context, node.name, handler, capture, passive, false); } 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 `