Merge branch 'main' into each-block-pending

async-svelte-map
Rich Harris 2 months ago
commit db97c85769

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: optimize parser hot paths for faster compilation

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: use symbols for encapsulated event delegation

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: more efficient effect scheduling

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: null out current_batch before committing branches

@ -10,6 +10,10 @@ on:
description: 'Commit SHA to build'
required: true
type: string
pr:
description: 'PR number to comment on'
required: true
type: number
permissions: {}
@ -127,26 +131,16 @@ jobs:
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}`);
// 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', open[0].number);
core.setOutput('number', pr);
- name: Post or update comment
uses: actions/github-script@v8

@ -102,3 +102,40 @@ If an `onerror` function is provided, it will be called with the same two `error
```
If an error occurs inside the `onerror` function (or if you rethrow the error), it will be handled by a parent boundary if such exists.
## Using `transformError`
By default, error boundaries have no effect on the server — if an error occurs during rendering, the render as a whole will fail.
Since 5.51 you can control this behaviour for boundaries with a `failed` snippet, by calling [`render(...)`](imperative-component-api#render) with a `transformError` function.
> [!NOTE] If you're using Svelte via a framework such as SvelteKit, you most likely don't have direct access to the `render(...)` call — the framework must configure `transformError` on your behalf. SvelteKit will add support for this in the near future, via the [`handleError`](../kit/hooks#Shared-hooks-handleError) hook.
The `transformError` function must return a JSON-stringifiable object which will be used to render the `failed` snippet. This object will be serialized and used to hydrate the snippet in the browser:
```js
// @errors: 1005
import { render } from 'svelte/server';
import App from './App.svelte';
const { head, body } = await render(App, {
transformError: (error) => {
// log the original error, with the stack trace...
console.error(error);
// ...and return a sanitized user-friendly error
// to display in the `failed` snippet
return {
message: 'An error occurred!'
};
};
});
```
If `transformError` throws (or rethrows) an error, `render(...)` as a whole will fail with that error.
> [!NOTE] Errors that occur during server-side rendering can contain sensitive information in the `message` and `stack`. It's recommended to redact these rather than sending them unaltered to the browser.
If the boundary has an `onerror` handler, it will be called upon hydration with the deserialized error object.
The [`mount`](imperative-component-api#mount) and [`hydrate`](imperative-component-api#hydrate) functions also accept a `transformError` option, which defaults to the identity function. As with `render`, this function transforms a render-time error before it is passed to a `failed` snippet or `onerror` handler.

@ -10,12 +10,12 @@ title: <svelte:document>
<svelte:document bind:prop={value} />
```
Similarly to `<svelte:window>`, this element allows you to add listeners to events on `document`, such as `visibilitychange`, which don't fire on `window`. It also lets you use [actions](use) on `document`.
Similarly to `<svelte:window>`, this element allows you to add listeners to events on `document`, such as `visibilitychange`, which don't fire on `window`. It also lets you use [attachments](@attach) on `document`.
As with `<svelte:window>`, this element may only appear the top level of your component and must never be inside a block or element.
```svelte
<svelte:document onvisibilitychange={handleVisibilityChange} use:someAction />
<svelte:document onvisibilitychange={handleVisibilityChange} {@attach someAttachment} />
```
You can also bind to the following properties:

@ -75,7 +75,7 @@ In both cases, a `svelte.config.js` with `vitePreprocess` will be added. Vite/Sv
### Other build tools
If you're using tools like Rollup or Webpack instead, install their respective Svelte plugins. For Rollup that's [rollup-plugin-svelte](https://github.com/sveltejs/rollup-plugin-svelte) and for Webpack that's [svelte-loader](https://github.com/sveltejs/svelte-loader). For both, you need to install `typescript` and `svelte-preprocess` and add the preprocessor to the plugin config (see the respective READMEs for more info). If you're starting a new project, you can also use the [rollup](https://github.com/sveltejs/template) or [webpack](https://github.com/sveltejs/template-webpack) template to scaffold the setup from a script.
If you're using tools like Rollup or Webpack instead, install their respective Svelte plugins. For Rollup that's [rollup-plugin-svelte](https://github.com/sveltejs/rollup-plugin-svelte) and for Webpack that's [svelte-loader](https://github.com/sveltejs/svelte-loader). For both, you need to install `typescript` and `svelte-preprocess` and add the preprocessor to the plugin config (see the respective READMEs for more info).
> [!NOTE] If you're starting a new project, we recommend using SvelteKit or Vite instead

@ -682,6 +682,24 @@ Previously, Svelte employed a very complicated algorithm to determine if whitesp
- Whitespace between nodes is collapsed to one whitespace
- Whitespace at the start and end of a tag is removed completely
This new behavior is slightly different from native HTML rendering. For example, `<p>foo<span> - bar</span></p>` will render:
- `foo - bar` in HTML
- `foo- bar` in Svelte 5
You can reintroduce the missing space by moving it outside the `<span>`...
```svelte
<p>foo <span>- bar</span></p>
```
...or, if necessary for styling reasons, including it as an expression:
```svelte
<p>foo<span>{' '}- bar</span></p>
```
- Certain exceptions apply such as keeping whitespace inside `pre` tags
As before, you can disable whitespace trimming by setting the `preserveWhitespace` option in your compiler settings or on a per-component basis in `<svelte:options>`.

@ -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
```

@ -63,9 +63,29 @@ Event attribute must be a JavaScript expression, not a string
### attribute_invalid_sequence_expression
```
Sequence expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses
Comma-separated expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses
```
An attribute value cannot be a comma-separated sequence of expressions — in other words this is disallowed:
```svelte
<div class={size, color}>...</div>
```
Instead, make sure that the attribute value contains a single expression. In the example above it's likely that this was intended (see the [class documentation](class) for more details):
```svelte
<div class={[size, color]}>...</div>
```
If you _do_ need to use the comma operator for some reason, wrap the sequence in parentheses:
```svelte
<div class={(size, color)}>...</div>
```
Note that this will evaluate to `color`, ignoring `size`.
### attribute_invalid_type
```

@ -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>`](svelte-boundary) with a `pending` snippet.
### dynamic_element_invalid_tag
```
`<svelte:element this="%tag%">` is not a valid element name — the element will not be rendered
```
The value passed to the `this` prop of `<svelte:element>` 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
```

@ -27,12 +27,12 @@
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
"@sveltejs/eslint-config": "^8.3.5",
"@eslint/js": "^10.0.0",
"@sveltejs/eslint-config": "^9.0.0",
"@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/js": "^10.0.0",
"eslint": "^10.0.0",
"eslint-plugin-lube": "^0.5.1",
"eslint-plugin-svelte": "^3.15.0",
@ -42,15 +42,8 @@
"prettier-plugin-svelte": "^3.4.0",
"svelte": "workspace:^",
"typescript": "^5.5.4",
"typescript-eslint": "^8.55.0",
"typescript-eslint": "^8.56.0",
"v8-natives": "^1.2.5",
"vitest": "^2.1.9"
},
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"eslint": "10"
}
}
}
}

@ -1,5 +1,129 @@
# svelte
## 5.53.5
### Patch Changes
- fix: escape `innerText` and `textContent` bindings of `contenteditable` ([`0df5abcae223058ceb95491470372065fb87951d`](https://github.com/sveltejs/svelte/commit/0df5abcae223058ceb95491470372065fb87951d))
- fix: sanitize `transformError` values prior to embedding in HTML comments ([`0298e979371bb583855c9810db79a70a551d22b9`](https://github.com/sveltejs/svelte/commit/0298e979371bb583855c9810db79a70a551d22b9))
## 5.53.4
### Patch Changes
- fix: set server context after async transformError ([#17799](https://github.com/sveltejs/svelte/pull/17799))
- fix: hydrate if blocks correctly ([#17784](https://github.com/sveltejs/svelte/pull/17784))
- fix: handle default parameters scope leaks ([#17788](https://github.com/sveltejs/svelte/pull/17788))
- fix: prevent flushed effects from running again ([#17787](https://github.com/sveltejs/svelte/pull/17787))
## 5.53.3
### Patch Changes
- fix: render `:catch` of `#await` block with correct key ([#17769](https://github.com/sveltejs/svelte/pull/17769))
- chore: pin aria-query@5.3.1 ([#17772](https://github.com/sveltejs/svelte/pull/17772))
- fix: make string coercion consistent to `toString` ([#17774](https://github.com/sveltejs/svelte/pull/17774))
## 5.53.2
### Patch Changes
- fix: update expressions on server deriveds ([#17767](https://github.com/sveltejs/svelte/pull/17767))
- fix: further obfuscate `node:crypto` import from overzealous static analysis ([#17763](https://github.com/sveltejs/svelte/pull/17763))
## 5.53.1
### Patch Changes
- fix: handle shadowed function names correctly ([#17753](https://github.com/sveltejs/svelte/pull/17753))
## 5.53.0
### Minor Changes
- feat: allow comments in tags ([#17671](https://github.com/sveltejs/svelte/pull/17671))
- feat: allow error boundaries to work on the server ([#17672](https://github.com/sveltejs/svelte/pull/17672))
### Patch Changes
- fix: use TrustedHTML to test for customizable `<select>` support, where necessary ([#17743](https://github.com/sveltejs/svelte/pull/17743))
- fix: ensure head effects are kept in the effect tree ([#17746](https://github.com/sveltejs/svelte/pull/17746))
- chore: deactivate current_batch by default in unset_context ([#17738](https://github.com/sveltejs/svelte/pull/17738))
## 5.52.0
### Minor Changes
- feat: support TrustedHTML in `{@html}` expressions ([#17701](https://github.com/sveltejs/svelte/pull/17701))
### Patch Changes
- fix: repair dynamic component truthy/falsy hydration mismatches ([#17737](https://github.com/sveltejs/svelte/pull/17737))
- fix: re-run non-render-bound deriveds on the server ([#17674](https://github.com/sveltejs/svelte/pull/17674))
## 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

@ -42,6 +42,12 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long
> 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
> `%rune%` cannot be used inside an effect cleanup function

@ -40,7 +40,27 @@
## attribute_invalid_sequence_expression
> Sequence expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses
> Comma-separated expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses
An attribute value cannot be a comma-separated sequence of expressions — in other words this is disallowed:
```svelte
<div class={size, color}>...</div>
```
Instead, make sure that the attribute value contains a single expression. In the example above it's likely that this was intended (see the [class documentation](class) for more details):
```svelte
<div class={[size, color]}>...</div>
```
If you _do_ need to use the comma operator for some reason, wrap the sequence in parentheses:
```svelte
<div class={(size, color)}>...</div>
```
Note that this will evaluate to `color`, ignoring `size`.
## attribute_invalid_type

@ -10,6 +10,12 @@ Some platforms require configuration flags to enable this API. Consult your plat
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>`](svelte-boundary) with a `pending` snippet.
## dynamic_element_invalid_tag
> `<svelte:element this="%tag%">` is not a valid element name — the element will not be rendered
The value passed to the `this` prop of `<svelte:element>` 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
> The `html` property of server render results has been deprecated. Use `body` instead.

@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
"version": "5.51.0",
"version": "5.53.5",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
@ -159,7 +159,7 @@
"@types/node": "^20.11.5",
"dts-buddy": "^0.5.5",
"esbuild": "^0.25.10",
"rollup": "^4.22.4",
"rollup": "^4.59.0",
"source-map": "^0.7.4",
"tinyglobby": "^0.2.12",
"typescript": "^5.5.4",
@ -172,10 +172,10 @@
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.2",
"devalue": "^5.6.3",
"esm-env": "^1.2.1",
"esrap": "^2.2.2",
"is-reference": "^3.0.3",

@ -785,12 +785,12 @@ export function attribute_invalid_name(node, name) {
}
/**
* Sequence expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses
* Comma-separated expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function attribute_invalid_sequence_expression(node) {
e(node, 'attribute_invalid_sequence_expression', `Sequence expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses\nhttps://svelte.dev/e/attribute_invalid_sequence_expression`);
e(node, 'attribute_invalid_sequence_expression', `Comma-separated expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses\nhttps://svelte.dev/e/attribute_invalid_sequence_expression`);
}
/**

@ -101,7 +101,12 @@ export function convert(source, ast) {
},
instance,
module,
css: ast.css ? visit(ast.css) : undefined
css: ast.css ? visit(ast.css) : undefined,
// put it on _comments not comments because the latter is checked by prettier and then fails
// if we don't adjust stuff accordingly in our prettier plugin, and so it would be kind of an
// indirect breaking change for people updating their Svelte version but not their prettier plugin version.
// We can keep it as comments for the modern AST because the modern AST is not used in the plugin yet.
_comments: ast.comments?.length > 0 ? ast.comments : undefined
};
},
AnimateDirective(node) {

@ -4,7 +4,6 @@
// @ts-expect-error acorn type definitions are borked in the release we use
import { isIdentifierStart, isIdentifierChar } from 'acorn';
import fragment from './state/fragment.js';
import { regex_whitespace } from '../patterns.js';
import * as e from '../../errors.js';
import { create_fragment } from './utils/create.js';
import read_options from './read/options.js';
@ -14,6 +13,25 @@ import * as state from '../../state.js';
const regex_position_indicator = / \(\d+:\d+\)$/;
/** @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
);
}
const regex_lang_attribute =
/<!--[^]*?-->|<script\s+(?:[^>]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s]+)\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/g;
@ -191,22 +209,26 @@ export class Parser {
return this.template[this.index] === str;
}
return this.template.slice(this.index, this.index + length) === str;
return this.template.startsWith(str, this.index);
}
/**
* Match a regex at the current index
* @param {RegExp} pattern Should have a ^ anchor at the start so the regex doesn't search past the beginning, resulting in worse performance
* @param {RegExp} pattern Should have the sticky (`y`) flag so that it only matches at the current index
*/
match_regex(pattern) {
const match = pattern.exec(this.template.slice(this.index));
if (!match || match.index !== 0) return null;
pattern.lastIndex = this.index;
const match = pattern.exec(this.template);
if (!match || match.index !== this.index) return null;
return match[0];
}
allow_whitespace() {
while (this.index < this.template.length && regex_whitespace.test(this.template[this.index])) {
while (
this.index < this.template.length &&
is_whitespace(this.template.charCodeAt(this.index))
) {
this.index++;
}
}
@ -282,7 +304,7 @@ export class Parser {
}
require_whitespace() {
if (!regex_whitespace.test(this.template[this.index])) {
if (!is_whitespace(this.template.charCodeAt(this.index))) {
e.expected_whitespace(this.index);
}

@ -9,7 +9,7 @@ import { is_text_attribute } from '../../../utils/ast.js';
import { locator } from '../../../state.js';
const regex_closing_script_tag = /<\/script\s*>/;
const regex_starts_with_closing_script_tag = /^<\/script\s*>/;
const regex_starts_with_closing_script_tag = /<\/script\s*>/y;
const RESERVED_ATTRIBUTES = ['server', 'client', 'worker', 'test', 'default'];
const ALLOWED_ATTRIBUTES = ['context', 'generics', 'lang', 'module'];

@ -2,17 +2,17 @@
/** @import { Parser } from '../index.js' */
import * as e from '../../../errors.js';
const REGEX_MATCHER = /^[~^$*|]?=/;
const REGEX_MATCHER = /[~^$*|]?=/y;
const REGEX_CLOSING_BRACKET = /[\s\]]/;
const REGEX_ATTRIBUTE_FLAGS = /^[a-zA-Z]+/; // only `i` and `s` are valid today, but make it future-proof
const REGEX_COMBINATOR = /^(\+|~|>|\|\|)/;
const REGEX_PERCENTAGE = /^\d+(\.\d+)?%/;
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;
const REGEX_NTH_OF =
/^(even|odd|\+?(\d+|\d*n(\s*[+-]\s*\d+)?)|-\d*n(\s*\+\s*\d+))((?=\s*[,)])|\s+of\s+)/;
/(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/;
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)?/;
const REGEX_UNICODE_SEQUENCE = /\\[0-9a-fA-F]{1,6}(\r\n|\s)?/y;
const REGEX_COMMENT_CLOSE = /\*\//;
const REGEX_HTML_COMMENT_CLOSE = /-->/;
@ -28,7 +28,7 @@ export default function read_style(parser, start, attributes) {
const content_end = parser.index;
parser.eat('</style', true);
parser.read(/^\s*>/);
parser.read(/\s*>/y);
return {
type: 'StyleSheet',

@ -2,7 +2,7 @@
/** @import { Location } from 'locate-character' */
/** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */
import { is_void } from '../../../../utils.js';
import { is_void, REGEX_VALID_TAG_NAME } from '../../../../utils.js';
import read_expression from '../read/expression.js';
import { read_script } from '../read/script.js';
import read_style from '../read/style.js';
@ -17,15 +17,25 @@ import { list } from '../../../utils/string.js';
import { locator } from '../../../state.js';
import * as b from '#compiler/builders';
const regex_invalid_unquoted_attribute_value = /^(\/>|[\s"'=<>`])/;
const regex_closing_textarea_tag = /^<\/textarea(\s[^>]*)?>/i;
const regex_invalid_unquoted_attribute_value = /(\/>|[\s"'=<>`])/y;
const regex_closing_textarea_tag = /<\/textarea(\s[^>]*)?>/iy;
const regex_closing_comment = /-->/;
const regex_whitespace_or_slash_or_closing_tag = /(\s|\/|>)/;
const regex_token_ending_character = /[\s=/>"']/;
const regex_starts_with_quote_characters = /^["']/;
const regex_attribute_value = /^(?:"([^"]*)"|'([^'])*'|([^>\s]+))/;
const regex_valid_element_name =
/^(?:![a-zA-Z]+|[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?|[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9])$/;
const regex_starts_with_quote_characters = /["']/y;
const regex_attribute_value = /(?:"([^"]*)"|'([^'])*'|([^>\s]+))/y;
const regex_doctype_name = /^![a-zA-Z]+$/;
const regex_namespaced_name = /^[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$/;
/** @param {string} name */
function is_valid_element_name(name) {
// DOCTYPE (e.g. !DOCTYPE)
if (regex_doctype_name.test(name)) return true;
// svelte:* meta tags (e.g. svelte:element, svelte:head)
if (regex_namespaced_name.test(name)) return true;
// standard HTML/SVG/MathML elements and custom elements
return REGEX_VALID_TAG_NAME.test(name);
}
export const regex_valid_component_name =
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers adjusted for our needs
// (must start with uppercase letter if no dots, can contain dots)
@ -134,7 +144,7 @@ export default function element(parser) {
e.svelte_meta_invalid_tag(bounds, list(Array.from(meta_tags.keys())));
}
if (!regex_valid_element_name.test(tag.name) && !regex_valid_component_name.test(tag.name)) {
if (!is_valid_element_name(tag.name) && !regex_valid_component_name.test(tag.name)) {
// <div. -> in the middle of typing -> allow in loose mode
if (!parser.loose || !tag.name.endsWith('.')) {
const bounds = { start: start + 1, end: start + 1 + tag.name.length };
@ -392,7 +402,10 @@ export default function element(parser) {
// special case
element.fragment.nodes = read_sequence(
parser,
() => regex_closing_textarea_tag.test(parser.template.slice(parser.index)),
() => {
regex_closing_textarea_tag.lastIndex = parser.index;
return regex_closing_textarea_tag.test(parser.template);
},
'inside <textarea>'
);
parser.read(regex_closing_textarea_tag);
@ -400,7 +413,13 @@ export default function element(parser) {
} else if (tag.name === 'script' || tag.name === 'style') {
// special case
const start = parser.index;
const data = parser.read_until(new RegExp(`</${tag.name}>`));
const close_tag = `</${tag.name}>`;
const close_index = parser.template.indexOf(close_tag, parser.index);
const data = parser.template.slice(
parser.index,
close_index === -1 ? parser.template.length : close_index
);
parser.index = close_index === -1 ? parser.template.length : close_index;
const end = parser.index;
/** @type {AST.Text} */
@ -499,6 +518,15 @@ function read_static_attribute(parser) {
* @returns {AST.Attribute | AST.SpreadAttribute | AST.Directive | AST.AttachTag | null}
*/
function read_attribute(parser) {
/** @type {AST.JSComment | null} */
// eslint-disable-next-line no-useless-assignment -- it is, in fact, eslint that is useless
let comment = null;
while ((comment = read_comment(parser))) {
parser.root.comments.push(comment);
parser.allow_whitespace();
}
const start = parser.index;
if (parser.eat('{')) {
@ -695,6 +723,50 @@ function read_attribute(parser) {
return create_attribute(tag.name, tag.loc, start, end, value);
}
/**
* @param {Parser} parser
* @returns {AST.JSComment | null}
*/
function read_comment(parser) {
const start = parser.index;
if (parser.eat('//')) {
const value = parser.read_until(/\n/);
const end = parser.index;
return {
type: 'Line',
start,
end,
value,
loc: {
start: locator(start),
end: locator(end)
}
};
}
if (parser.eat('/*')) {
const value = parser.read_until(/\*\//);
parser.eat('*/');
const end = parser.index;
return {
type: 'Block',
start,
end,
value,
loc: {
start: locator(start),
end: locator(end)
}
};
}
return null;
}
/**
* @param {string} name
* @returns {any}
@ -789,7 +861,8 @@ function read_sequence(parser, done, location) {
/** @param {number} end */
function flush(end) {
if (current_chunk.raw) {
if (end > current_chunk.start) {
current_chunk.raw = parser.template.slice(current_chunk.start, end);
current_chunk.data = decode_character_references(current_chunk.raw, true);
current_chunk.end = end;
chunks.push(current_chunk);
@ -843,7 +916,7 @@ function read_sequence(parser, done, location) {
data: ''
};
} else {
current_chunk.raw += parser.template[parser.index++];
parser.index++;
}
}

@ -10,7 +10,7 @@ import read_expression, { get_loose_identifier } from '../read/expression.js';
import { create_fragment } from '../utils/create.js';
import { match_bracket } from '../utils/bracket.js';
const regex_whitespace_with_closing_curly_brace = /^\s*}/;
const regex_whitespace_with_closing_curly_brace = /\s*}/y;
const pointy_bois = { '<': '>' };

@ -6,12 +6,12 @@ import { decode_character_references } from '../utils/html.js';
export default function text(parser) {
const start = parser.index;
let data = '';
while (parser.index < parser.template.length && !parser.match('<') && !parser.match('{')) {
data += parser.template[parser.index++];
parser.index++;
}
const data = parser.template.slice(start, parser.index);
/** @type {AST.Text} */
parser.append({
type: 'Text',

@ -141,13 +141,15 @@ const default_brackets = {
'[': ']'
};
const default_close = new Set(Object.values(default_brackets));
/**
* @param {Parser} parser
* @param {number} start
* @param {Record<string, string>} brackets
*/
export function match_bracket(parser, start, brackets = default_brackets) {
const close = Object.values(brackets);
const close = brackets === default_brackets ? default_close : new Set(Object.values(brackets));
const bracket_stack = [];
let i = start;
@ -162,7 +164,7 @@ export function match_bracket(parser, start, brackets = default_brackets) {
if (char in brackets) {
bracket_stack.push(char);
} else if (close.includes(char)) {
} else if (close.has(char)) {
const popped = /** @type {string} */ (bracket_stack.pop());
const expected = /** @type {string} */ (brackets[popped]);

@ -22,6 +22,50 @@ const whitelist_attribute_selector = new Map([
['dialog', ['open']]
]);
/**
* HTML attributes whose enumerated values are case-insensitive per the HTML spec.
* CSS attribute selectors match these values case-insensitively in HTML documents.
* @see {@link https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors HTML spec}
*/
const case_insensitive_attributes = new Set([
'accept-charset',
'autocapitalize',
'autocomplete',
'behavior',
'charset',
'crossorigin',
'decoding',
'dir',
'direction',
'draggable',
'enctype',
'enterkeyhint',
'fetchpriority',
'formenctype',
'formmethod',
'formtarget',
'hidden',
'http-equiv',
'inputmode',
'kind',
'loading',
'method',
'preload',
'referrerpolicy',
'rel',
'rev',
'role',
'rules',
'scope',
'shape',
'spellcheck',
'target',
'translate',
'type',
'valign',
'wrap'
]);
/** @type {Compiler.AST.CSS.Combinator} */
const descendant_combinator = {
type: 'Combinator',
@ -523,7 +567,9 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
selector.name,
selector.value && unquote(selector.value),
selector.matcher,
selector.flags?.includes('i') ?? false
(selector.flags?.includes('i') ?? false) ||
(!selector.flags?.includes('s') &&
case_insensitive_attributes.has(selector.name.toLowerCase()))
)
) {
return false;

@ -115,7 +115,8 @@ export function Identifier(node, context) {
!should_proxy(binding.initial.arguments[0], context.state.scope)))) ||
binding.kind === 'raw_state' ||
binding.kind === 'derived' ||
binding.kind === 'prop') &&
binding.kind === 'prop' ||
binding.kind === 'rest_prop') &&
// We're only concerned with reads here
(parent.type !== 'AssignmentExpression' || parent.left !== node) &&
parent.type !== 'UpdateExpression'

@ -70,7 +70,8 @@ export function build_event(context, event_name, handler, capture, passive, dele
fn = b.function(
b.id(name),
handler.params,
handler.body.type === 'BlockStatement' ? handler.body : b.block([b.return(handler.body)])
handler.body.type === 'BlockStatement' ? handler.body : b.block([b.return(handler.body)]),
handler.async
);
}

@ -68,35 +68,52 @@ function build_assignment(operator, left, right, context) {
object = object.object;
}
if (object.type !== 'Identifier' || !is_store_name(object.name)) {
if (object.type !== 'Identifier') {
return null;
}
const name = object.name.slice(1);
if (is_store_name(object.name)) {
const name = object.name.slice(1);
if (!context.state.scope.get(name)) {
return null;
if (!context.state.scope.get(name)) {
return null;
}
if (object === left) {
let value = /** @type {Expression} */ (
context.visit(build_assignment_value(operator, left, right))
);
return b.call('$.store_set', b.id(name), value);
}
return b.call(
'$.store_mutate',
b.assignment('??=', b.id('$$store_subs'), b.object([])),
b.literal(object.name),
b.id(name),
b.assignment(
operator,
/** @type {Pattern} */ (context.visit(left)),
/** @type {Expression} */ (context.visit(right))
)
);
}
if (object === left) {
const binding = context.state.scope.get(object.name);
// TODO 6.0 this won't work perfectly: once a derived is written to, it will
// no longer recompute. It might be better to disallow writing to deriveds
// on the server, to prevent this bug occurring
if (binding?.kind === 'derived' && object === left) {
let value = /** @type {Expression} */ (
context.visit(build_assignment_value(operator, left, right))
);
return b.call('$.store_set', b.id(name), value);
return b.call(binding.node, value);
}
return b.call(
'$.store_mutate',
b.assignment('??=', b.id('$$store_subs'), b.object([])),
b.literal(object.name),
b.id(name),
b.assignment(
operator,
/** @type {Pattern} */ (context.visit(left)),
/** @type {Expression} */ (context.visit(right))
)
);
return null;
}
/**

@ -1,3 +1,4 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
@ -8,5 +9,5 @@ import { build_inline_component } from './shared/component.js';
* @param {ComponentContext} context
*/
export function Component(node, context) {
build_inline_component(node, b.id(node.name), context);
build_inline_component(node, /** @type {Expression} */ (context.visit(b.id(node.name))), context);
}

@ -14,6 +14,11 @@ export function Identifier(node, context) {
return b.id('$$sanitized_props');
}
if (node.name.startsWith('$$derived_array')) {
// terrible hack, but easier than adding new stuff to `context.state` for now
return b.call(node);
}
return build_getter(node, context.state);
}
}

@ -15,7 +15,17 @@ import {
* @param {ComponentContext} context
*/
export function SvelteBoundary(node, context) {
// if this has a `pending` snippet, render it
// Extract the `failed` snippet/attribute
const failed_snippet = /** @type {AST.SnippetBlock | undefined} */ (
node.fragment.nodes.find(
(node) => node.type === 'SnippetBlock' && node.expression.name === 'failed'
)
);
const failed_attribute = /** @type {AST.Attribute} */ (
node.attributes.find((node) => node.type === 'Attribute' && node.name === 'failed')
);
// Extract the `pending` snippet/attribute
const pending_attribute = /** @type {AST.Attribute} */ (
node.attributes.find((node) => node.type === 'Attribute' && node.name === 'pending')
);
@ -24,48 +34,106 @@ export function SvelteBoundary(node, context) {
typeof pending_attribute.value === 'object' &&
!Array.isArray(pending_attribute.value) &&
!context.state.scope.evaluate(pending_attribute.value.expression).is_defined;
const pending_snippet = /** @type {AST.SnippetBlock} */ (
const pending_snippet = /** @type {AST.SnippetBlock | undefined} */ (
node.fragment.nodes.find(
(node) => node.type === 'SnippetBlock' && node.expression.name === 'pending'
)
);
const children_nodes = node.fragment.nodes.filter(
(child) =>
!(child.type === 'SnippetBlock' && ['failed', 'pending'].includes(child.expression.name))
);
const children_fragment = { ...node.fragment, nodes: children_nodes };
const children_block = /** @type {BlockStatement} */ (
context.visit(children_fragment, {
...context.state,
scope: context.state.scopes.get(node.fragment) ?? context.state.scope
})
);
/** @type {BlockStatement} */
let children_body;
if (pending_attribute || pending_snippet) {
if (pending_attribute && is_pending_attr_nullish && !pending_snippet) {
const callee = build_attribute_value(
pending_attribute.value,
context,
(expression) => expression,
false,
true
);
const pending = b.call(callee, b.id('$$renderer'));
const block = /** @type {BlockStatement} */ (context.visit(node.fragment));
context.state.template.push(
const { callee, pending_block } = build_pending_attribute_block(pending_attribute, context);
children_body = b.block([
b.if(
callee,
b.block(build_template([block_open_else, b.stmt(pending), block_close])),
b.block(build_template([block_open, block, block_close]))
pending_block,
b.block(build_template([block_open, children_block, block_close]))
)
);
]);
} else {
const pending = pending_attribute
? b.call(
build_attribute_value(
pending_attribute.value,
context,
(expression) => expression,
false,
true
),
b.id('$$renderer')
)
: /** @type {BlockStatement} */ (context.visit(pending_snippet.body));
context.state.template.push(block_open_else, pending, block_close);
children_body = pending_attribute
? build_pending_attribute_block(pending_attribute, context).pending_block
: build_pending_snippet_block(/** @type {AST.SnippetBlock} */ (pending_snippet), context);
}
} else {
const block = /** @type {BlockStatement} */ (context.visit(node.fragment));
context.state.template.push(block_open, block, block_close);
children_body = b.block(build_template([block_open, children_block, block_close]));
}
// When there's no `failed` snippet/attribute, skip the boundary wrapper entirely
// (saves bytes / more performant at runtime)
if (!failed_snippet && !failed_attribute) {
context.state.template.push(...children_body.body);
return;
}
const props = b.object([]);
if (failed_attribute && !failed_snippet) {
const failed_callee = build_attribute_value(
failed_attribute.value,
context,
(expression) => expression,
false,
true
);
props.properties.push(b.init('failed', failed_callee));
} else if (failed_snippet) {
context.visit(failed_snippet, context.state);
props.properties.push(b.init('failed', failed_snippet.expression));
}
context.state.template.push(
b.stmt(b.call('$$renderer.boundary', props, b.arrow([b.id('$$renderer')], children_body)))
);
}
/**
* @param {AST.Attribute} attribute
* @param {ComponentContext} context
*/
function build_pending_attribute_block(attribute, context) {
const callee = build_attribute_value(
attribute.value,
context,
(expression) => expression,
false,
true
);
const pending = b.call(callee, b.id('$$renderer'));
return {
callee,
pending_block: b.block(build_template([block_open_else, b.stmt(pending), block_close]))
};
}
/**
* @param {AST.SnippetBlock} snippet
* @param {ComponentContext} context
*/
function build_pending_snippet_block(snippet, context) {
return b.block(
build_template([
block_open_else,
/** @type {BlockStatement} */ (context.visit(snippet.body)),
block_close
])
);
}

@ -9,17 +9,26 @@ import * as b from '#compiler/builders';
export function UpdateExpression(node, context) {
const argument = node.argument;
if (
argument.type === 'Identifier' &&
context.state.scope.get(argument.name)?.kind === 'store_sub'
) {
return b.call(
node.prefix ? '$.update_store_pre' : '$.update_store',
b.assignment('??=', b.id('$$store_subs'), b.object([])),
b.literal(argument.name),
b.id(argument.name.slice(1)),
node.operator === '--' && b.literal(-1)
);
if (argument.type === 'Identifier') {
const binding = context.state.scope.get(argument.name);
if (binding?.kind === 'store_sub') {
return b.call(
node.prefix ? '$.update_store_pre' : '$.update_store',
b.assignment('??=', b.id('$$store_subs'), b.object([])),
b.literal(argument.name),
b.id(argument.name.slice(1)),
node.operator === '--' && b.literal(-1)
);
}
if (binding?.kind === 'derived') {
return b.call(
node.prefix ? '$.update_derived_pre' : '$.update_derived',
binding.node,
node.operator === '--' && b.literal(-1)
);
}
}
return context.next();

@ -84,10 +84,52 @@ export function VariableDeclaration(node, context) {
const args = /** @type {CallExpression} */ (init).arguments;
const value = args.length > 0 ? /** @type {Expression} */ (context.visit(args[0])) : b.void0;
if (rune === '$derived.by') {
declarations.push(
b.declarator(/** @type {Pattern} */ (context.visit(declarator.id)), b.call(value))
);
if (rune === '$derived' || rune === '$derived.by') {
const is_async =
rune === '$derived' &&
context.state.analysis.async_deriveds.has(
/** @type {CallExpression} */ (declarator.init)
);
let init = is_async
? b.await(b.call('$.async_derived', b.thunk(value, true)))
: b.call('$.derived', rune === '$derived' ? b.thunk(value) : value);
if (declarator.id.type === 'Identifier') {
declarations.push(
b.declarator(/** @type {Pattern} */ (context.visit(declarator.id)), init)
);
} else {
const call = /** @type {CallExpression} */ (declarator.init);
let rhs = value;
if (rune !== '$derived' || call.arguments[0].type !== 'Identifier') {
const id = b.id(context.state.scope.generate('$$d'));
rhs = b.call(id);
declarations.push(b.declarator(id, init));
}
const { inserts, paths } = extract_paths(declarator.id, rhs);
for (const { id, value } of inserts) {
id.name = context.state.scope.generate('$$derived_array');
const expression = /** @type {Expression} */ (context.visit(b.thunk(value)));
const call = b.call('$.derived', expression);
declarations.push(b.declarator(id, call));
}
for (const path of paths) {
const expression = /** @type {Expression} */ (context.visit(path.expression));
const call = b.call('$.derived', b.thunk(expression));
declarations.push(b.declarator(path.node, call));
}
}
continue;
}
@ -96,13 +138,6 @@ export function VariableDeclaration(node, context) {
continue;
}
if (rune === '$derived') {
declarations.push(
b.declarator(/** @type {Pattern} */ (context.visit(declarator.id)), value)
);
continue;
}
declarations.push(...create_state_declarators(declarator, context.state.scope, value));
}
} else {

@ -1,7 +1,14 @@
/** @import { BlockStatement, Expression, Pattern, Property, SequenceExpression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../../types.js' */
import { empty_comment, build_attribute_value, PromiseOptimiser } from './utils.js';
import {
empty_comment,
build_attribute_value,
PromiseOptimiser,
block_open_else,
block_open,
block_close
} from './utils.js';
import * as b from '#compiler/builders';
import { is_element_node } from '../../../../nodes.js';
import { dev } from '../../../../../state.js';
@ -300,9 +307,22 @@ export function build_inline_component(node, expression, context) {
node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic);
/** @type {Statement} */
let statement = b.stmt(
(dynamic ? b.maybe_call : b.call)(expression, b.id('$$renderer'), props_expression)
);
let statement = b.stmt(b.call(expression, b.id('$$renderer'), props_expression));
if (dynamic) {
statement = b.if(
expression,
b.block([
b.stmt(b.call('$$renderer.push', block_open)),
statement,
b.stmt(b.call('$$renderer.push', block_close))
]),
b.block([
b.stmt(b.call('$$renderer.push', block_open_else)),
b.stmt(b.call('$$renderer.push', block_close))
])
);
}
if (snippet_declarations.length > 0) {
statement = b.block([...snippet_declarations, statement]);
@ -326,16 +346,14 @@ export function build_inline_component(node, expression, context) {
optimiser.check_blockers(node.metadata.expression);
}
context.state.template.push(
...optimiser.render_block([
dynamic && custom_css_props.length === 0
? b.stmt(b.call('$$renderer.push', empty_comment))
: b.empty,
statement
])
);
context.state.template.push(...optimiser.render_block([statement]));
if (!optimiser.is_async() && !context.state.is_standalone && custom_css_props.length === 0) {
if (
!dynamic &&
!optimiser.is_async() &&
!context.state.is_standalone &&
custom_css_props.length === 0
) {
context.state.template.push(empty_comment);
}
}

@ -123,9 +123,13 @@ export function build_element_attributes(node, context, transform) {
expression = transform(expression, attribute.metadata.expression);
if (is_content_editable_binding(attribute.name)) {
if (attribute.name === 'innerHTML') {
// innerHTML is the only binding we don't escape
content = expression;
} else if (attribute.name === 'value' && node.name === 'textarea') {
} else if (
is_content_editable_binding(attribute.name) ||
(attribute.name === 'value' && node.name === 'textarea')
) {
content = b.call('$.escape', expression);
} else if (attribute.name === 'group' && attribute.expression.type !== 'SequenceExpression') {
const value_attribute = /** @type {AST.Attribute | undefined} */ (

@ -274,6 +274,10 @@ export function build_getter(node, state) {
);
}
if (binding.kind === 'derived') {
return (binding.declaration_kind === 'var' ? b.maybe_call : b.call)(binding.node);
}
return node;
}

@ -3,7 +3,6 @@
/** @import { Context as ServerContext } from '../server/types.js' */
import { extract_paths, is_expression_async } from '../../../utils/ast.js';
import * as b from '#compiler/builders';
import { get_value } from '../client/visitors/shared/declarations.js';
/**
* @template {ClientContext | ServerContext} Context

@ -1098,7 +1098,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
},
FunctionExpression(node, { state, next }) {
const scope = state.scope.child();
const scope = state.scope.child(true);
scopes.set(node, scope);
if (node.id) scope.declare(node.id, 'normal', 'function');
@ -1110,7 +1110,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
FunctionDeclaration(node, { state, next }) {
if (node.id) state.scope.declare(node.id, 'normal', 'function', node);
const scope = state.scope.child();
const scope = state.scope.child(true);
scopes.set(node, scope);
add_params(scope, node.params);
@ -1118,7 +1118,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
},
ArrowFunctionExpression(node, { state, next }) {
const scope = state.scope.child();
const scope = state.scope.child(true);
scopes.set(node, scope);
add_params(scope, node.params);
@ -1136,8 +1136,11 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
parent?.type === 'FunctionExpression' ||
parent?.type === 'ArrowFunctionExpression'
) {
// We already created a new scope for the function
context.next();
// The scopes created for the function nodes above handle the function identifier and
// parameters, but the block statement itself holds the non-porous function scope
const scope = context.state.scope.child();
scopes.set(node, scope);
context.next({ scope });
} else {
create_block_scope(node, context);
}

@ -18,15 +18,17 @@ const LINE_BREAK_THRESHOLD = 50;
* @param {import('./types.js').Options | undefined} options
*/
export function print(ast, options = undefined) {
const comments = (ast.type === 'Root' && ast.comments) || [];
return esrap.print(
ast,
/** @type {Visitors<AST.SvelteNode>} */ ({
...ts({
comments: ast.type === 'Root' ? ast.comments : [],
comments,
getLeadingComments: options?.getLeadingComments,
getTrailingComments: options?.getTrailingComments
}),
...svelte_visitors,
...svelte_visitors(comments),
...css_visitors
})
);
@ -57,35 +59,72 @@ function block(context, node, allow_inline = false) {
}
/**
* @param {AST.BaseNode} node
* @param {AST.BaseElement['attributes']} attributes
* @param {Context} context
* @param {AST.JSComment[]} comments
* @returns {boolean} true if attributes were formatted on multiple lines
*/
function attributes(attributes, context) {
function attributes(node, attributes, context, comments) {
if (attributes.length === 0) {
return false;
}
// Measure total width of all attributes when rendered inline
const child_context = context.new();
let length = -1;
for (const attribute of attributes) {
child_context.write(' ');
child_context.visit(attribute);
let comment_index = comments.findIndex((comment) => comment.start > node.start);
if (comment_index === -1) {
comment_index = comments.length;
}
const multiline = child_context.measure() > LINE_BREAK_THRESHOLD;
const separator = context.new();
const children = attributes.map((attribute) => {
const child_context = context.new();
while (comment_index < comments.length) {
const comment = comments[comment_index];
if (comment.start < attribute.start) {
if (comment.type === 'Line') {
child_context.write('//' + comment.value);
child_context.newline();
} else {
child_context.write('/*' + comment.value + '*/'); // TODO match indentation?
child_context.append(separator);
}
comment_index += 1;
} else {
break;
}
}
child_context.visit(attribute);
length += child_context.measure() + 1;
return child_context;
});
let multiline = context.multiline || length > LINE_BREAK_THRESHOLD;
if (multiline) {
separator.newline();
context.indent();
for (const attribute of attributes) {
for (const child of children) {
context.newline();
context.visit(attribute);
context.append(child);
}
context.dedent();
context.newline();
} else {
context.append(child_context);
separator.write(' ');
for (const child of children) {
context.write(' ');
context.append(child);
}
}
return multiline;
@ -94,8 +133,9 @@ function attributes(attributes, context) {
/**
* @param {AST.BaseElement} node
* @param {Context} context
* @param {AST.JSComment[]} comments
*/
function base_element(node, context) {
function base_element(node, context, comments) {
const child_context = context.new();
child_context.write('<' + node.name);
@ -111,7 +151,7 @@ function base_element(node, context) {
child_context.write('}');
}
const multiline_attributes = attributes(node.attributes, child_context);
const multiline_attributes = attributes(node, node.attributes, child_context, comments);
const is_doctype_node = node.name.toLowerCase() === '!doctype';
const is_self_closing =
is_void(node.name) || (node.type === 'Component' && node.fragment.nodes.length === 0);
@ -284,8 +324,11 @@ const css_visitors = {
}
};
/** @type {Visitors<AST.SvelteNode>} */
const svelte_visitors = {
/**
* @param {AST.JSComment[]} comments
* @returns {Visitors<AST.SvelteNode>}
*/
const svelte_visitors = (comments) => ({
Root(node, context) {
if (node.options) {
context.write('<svelte:options');
@ -315,7 +358,7 @@ const svelte_visitors = {
Script(node, context) {
context.write('<script');
attributes(node.attributes, context);
attributes(node, node.attributes, context, comments);
context.write('>');
block(context, node.content);
context.write('</script>');
@ -545,7 +588,7 @@ const svelte_visitors = {
},
Component(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
ConstTag(node, context) {
@ -681,7 +724,7 @@ const svelte_visitors = {
},
RegularElement(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
RenderTag(node, context) {
@ -691,7 +734,7 @@ const svelte_visitors = {
},
SlotElement(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
SnippetBlock(node, context) {
@ -747,7 +790,7 @@ const svelte_visitors = {
StyleSheet(node, context) {
context.write('<style');
attributes(node.attributes, context);
attributes(node, node.attributes, context, comments);
context.write('>');
if (node.children.length > 0) {
@ -774,7 +817,7 @@ const svelte_visitors = {
},
SvelteBoundary(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
SvelteComponent(node, context) {
@ -783,7 +826,7 @@ const svelte_visitors = {
context.write(' this={');
context.visit(node.expression);
context.write('}');
attributes(node.attributes, context);
attributes(node, node.attributes, context, comments);
if (node.fragment && node.fragment.nodes.length > 0) {
context.write('>');
block(context, node.fragment, true);
@ -794,7 +837,7 @@ const svelte_visitors = {
},
SvelteDocument(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
SvelteElement(node, context) {
@ -803,7 +846,7 @@ const svelte_visitors = {
context.write('this={');
context.visit(node.tag);
context.write('}');
attributes(node.attributes, context);
attributes(node, node.attributes, context, comments);
if (node.fragment && node.fragment.nodes.length > 0) {
context.write('>');
@ -815,19 +858,19 @@ const svelte_visitors = {
},
SvelteFragment(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
SvelteHead(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
SvelteSelf(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
SvelteWindow(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
Text(node, context) {
@ -835,7 +878,7 @@ const svelte_visitors = {
},
TitleElement(node, context) {
base_element(node, context);
base_element(node, context, comments);
},
TransitionDirective(node, context) {
@ -865,4 +908,4 @@ const svelte_visitors = {
context.write('}');
}
}
};
});

@ -23,6 +23,8 @@ export const TEMPLATE_USE_MATHML = 1 << 3;
export const HYDRATION_START = '[';
/** used to indicate that an `{:else}...` block was rendered */
export const HYDRATION_START_ELSE = '[!';
/** used to indicate that a boundary's `failed` snippet was rendered on the server */
export const HYDRATION_START_FAILED = '[?';
export const HYDRATION_END = ']';
export const HYDRATION_ERROR = {};

@ -21,6 +21,7 @@ export interface ComponentConstructorOptions<
sync?: boolean;
idPrefix?: string;
$$inline?: boolean;
transformError?: (error: unknown) => unknown;
}
/**
@ -338,6 +339,11 @@ export type MountOptions<Props extends Record<string, any> = Record<string, any>
* @default true
*/
intro?: boolean;
/**
* A function that transforms errors caught by error boundaries before they are passed to the `failed` snippet.
* Defaults to the identity function.
*/
transformError?: (error: unknown) => unknown | Promise<unknown>;
} & ({} extends Props
? {
/**

@ -67,7 +67,10 @@ export const STALE_REACTION = new (class StaleReactionError extends Error {
message = 'The reaction that called `getAbortSignal()` was re-run or destroyed';
})();
export const IS_XHTML = /* @__PURE__ */ globalThis.document?.contentType.includes('xml') ?? false;
export const IS_XHTML =
// We gotta write it like this because after downleveling the pure comment may end up in the wrong location
!!globalThis.document?.contentType &&
/* @__PURE__ */ globalThis.document.contentType.includes('xml');
export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const COMMENT_NODE = 8;

@ -1,6 +1,5 @@
/** @import { Blocker, TemplateNode, Value } from '#client' */
import { flatten } from '../../reactivity/async.js';
import { Batch, current_batch } from '../../reactivity/batch.js';
import { flatten, increment_pending } from '../../reactivity/async.js';
import { get } from '../../runtime.js';
import {
hydrate_next,
@ -10,7 +9,6 @@ import {
set_hydrating,
skip_nodes
} from '../hydration.js';
import { get_boundary } from './boundary.js';
/**
* @param {TemplateNode} node
@ -44,12 +42,7 @@ export function async(node, blockers = [], expressions = [], fn) {
return;
}
var boundary = get_boundary();
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
boundary.update_pending_count(1);
batch.increment(blocking);
const decrement_pending = increment_pending();
if (was_hydrating) {
var previous_hydrate_node = hydrate_node;
@ -72,8 +65,7 @@ export function async(node, blockers = [], expressions = [], fn) {
set_hydrating(false);
}
boundary.update_pending_count(-1);
batch.decrement(blocking);
decrement_pending();
}
});
}

@ -12,7 +12,7 @@ import {
import { queue_micro_task } from '../task.js';
import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js';
import { is_runes } from '../../context.js';
import { Batch, flushSync, is_flushing_sync } from '../../reactivity/batch.js';
import { Batch, current_batch, flushSync, is_flushing_sync } from '../../reactivity/batch.js';
import { BranchManager } from './branches.js';
import { capture, unset_context } from '../../reactivity/async.js';
@ -84,7 +84,7 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
try {
fn();
} finally {
unset_context();
unset_context(false);
// without this, the DOM does not update until two ticks after the promise
// resolves, which is unexpected behaviour (and somewhat irksome to test)
@ -102,7 +102,7 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
(e) => {
resolve(() => {
internal_set(error, e);
branches.ensure(THEN, catch_fn && ((target) => catch_fn(target, error)));
branches.ensure(CATCH, catch_fn && ((target) => catch_fn(target, error)));
if (!catch_fn) {
// Rethrow the error if no catch block exists

@ -1,14 +1,12 @@
/** @import { Effect, Source, TemplateNode, } from '#client' */
import {
BLOCK_EFFECT,
BOUNDARY_EFFECT,
COMMENT_NODE,
DIRTY,
EFFECT_PRESERVED,
EFFECT_TRANSPARENT,
MAYBE_DIRTY
} from '#client/constants';
import { HYDRATION_START_ELSE } from '../../../../constants.js';
import { HYDRATION_START_ELSE, HYDRATION_START_FAILED } from '../../../../constants.js';
import { component_context, set_component_context } from '../../context.js';
import { handle_error, invoke_error_boundary } from '../../error-handling.js';
import {
@ -53,16 +51,17 @@ import { set_signal_status } from '../../reactivity/status.js';
* }} BoundaryProps
*/
var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT;
var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED;
/**
* @param {TemplateNode} node
* @param {BoundaryProps} props
* @param {((anchor: Node) => void)} children
* @param {((error: unknown) => unknown) | undefined} [transform_error]
* @returns {void}
*/
export function boundary(node, props, children) {
new Boundary(node, props, children);
export function boundary(node, props, children, transform_error) {
new Boundary(node, props, children, transform_error);
}
export class Boundary {
@ -71,6 +70,13 @@ export class Boundary {
is_pending = false;
/**
* API-level transformError transform function. Transforms errors before they reach the `failed` snippet.
* Inherited from parent boundary, or defaults to identity.
* @type {(error: unknown) => unknown}
*/
transform_error;
/** @type {TemplateNode} */
#anchor;
@ -98,15 +104,10 @@ export class Boundary {
/** @type {DocumentFragment | null} */
#offscreen_fragment = null;
/** @type {TemplateNode | null} */
#pending_anchor = null;
#local_pending_count = 0;
#pending_count = 0;
#pending_count_update_queued = false;
#is_creating_fallback = false;
/** @type {Set<Effect>} */
#dirty_effects = new Set();
@ -138,55 +139,47 @@ export class Boundary {
* @param {TemplateNode} node
* @param {BoundaryProps} props
* @param {((anchor: Node) => void)} children
* @param {((error: unknown) => unknown) | undefined} [transform_error]
*/
constructor(node, props, children) {
constructor(node, props, children, transform_error) {
this.#anchor = node;
this.#props = props;
this.#children = children;
this.#children = (anchor) => {
var effect = /** @type {Effect} */ (active_effect);
effect.b = this;
effect.f |= BOUNDARY_EFFECT;
children(anchor);
};
this.parent = /** @type {Effect} */ (active_effect).b;
this.is_pending = !!this.#props.pending;
// Inherit transform_error from parent boundary, or use the provided one, or default to identity
this.transform_error = transform_error ?? this.parent?.transform_error ?? ((e) => e);
this.#effect = block(() => {
/** @type {Effect} */ (active_effect).b = this;
if (hydrating) {
const comment = this.#hydrate_open;
const comment = /** @type {Comment} */ (this.#hydrate_open);
hydrate_next();
const server_rendered_pending =
/** @type {Comment} */ (comment).nodeType === COMMENT_NODE &&
/** @type {Comment} */ (comment).data === HYDRATION_START_ELSE;
const server_rendered_pending = comment.data === HYDRATION_START_ELSE;
const server_rendered_failed = comment.data.startsWith(HYDRATION_START_FAILED);
if (server_rendered_pending) {
if (server_rendered_failed) {
// Server rendered the failed snippet - hydrate it.
// The serialized error is embedded in the comment: <!--[?<json>-->
const serialized_error = JSON.parse(comment.data.slice(HYDRATION_START_FAILED.length));
this.#hydrate_failed_content(serialized_error);
} else if (server_rendered_pending) {
this.#hydrate_pending_content();
} else {
this.#hydrate_resolved_content();
if (this.#pending_count === 0) {
this.is_pending = false;
}
}
} else {
var anchor = this.#get_anchor();
try {
this.#main_effect = branch(() => children(anchor));
} catch (error) {
this.error(error);
}
if (this.#pending_count > 0) {
this.#show_pending_snippet();
} else {
this.is_pending = false;
}
this.#render();
}
return () => {
this.#pending_anchor?.remove();
};
}, flags);
if (hydrating) {
@ -202,43 +195,95 @@ export class Boundary {
}
}
/**
* @param {unknown} error The deserialized error from the server's hydration comment
*/
#hydrate_failed_content(error) {
const failed = this.#props.failed;
if (!failed) return;
this.#failed_effect = branch(() => {
failed(
this.#anchor,
() => error,
() => () => {}
);
});
}
#hydrate_pending_content() {
const pending = this.#props.pending;
if (!pending) return;
this.is_pending = true;
this.#pending_effect = branch(() => pending(this.#anchor));
queue_micro_task(() => {
var anchor = this.#get_anchor();
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
var anchor = create_text();
fragment.append(anchor);
this.#main_effect = this.#run(() => {
Batch.ensure();
return branch(() => this.#children(anchor));
});
if (this.#pending_count > 0) {
this.#show_pending_snippet();
} else {
if (this.#pending_count === 0) {
this.#anchor.before(fragment);
this.#offscreen_fragment = null;
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
this.#pending_effect = null;
});
this.is_pending = false;
this.#resolve();
}
});
}
#get_anchor() {
var anchor = this.#anchor;
#render() {
try {
this.is_pending = this.has_pending_snippet();
this.#pending_count = 0;
this.#local_pending_count = 0;
this.#main_effect = branch(() => {
this.#children(this.#anchor);
});
if (this.is_pending) {
this.#pending_anchor = create_text();
this.#anchor.before(this.#pending_anchor);
if (this.#pending_count > 0) {
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
move_effect(this.#main_effect, fragment);
anchor = this.#pending_anchor;
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
this.#pending_effect = branch(() => pending(this.#anchor));
} else {
this.#resolve();
}
} catch (error) {
this.error(error);
}
}
return anchor;
#resolve() {
this.is_pending = false;
// any effects that were previously deferred should be rescheduled —
// after the next traversal (which will happen immediately, due to the
// same update that brought us here) the effects will be flushed
for (const e of this.#dirty_effects) {
set_signal_status(e, DIRTY);
schedule_effect(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
}
this.#dirty_effects.clear();
this.#maybe_dirty_effects.clear();
}
/**
@ -262,7 +307,8 @@ export class Boundary {
}
/**
* @param {() => Effect | null} fn
* @template T
* @param {() => T} fn
*/
#run(fn) {
var previous_effect = active_effect;
@ -285,20 +331,6 @@ export class Boundary {
}
}
#show_pending_snippet() {
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
if (this.#main_effect !== null) {
this.#offscreen_fragment = document.createDocumentFragment();
this.#offscreen_fragment.append(/** @type {TemplateNode} */ (this.#pending_anchor));
move_effect(this.#main_effect, this.#offscreen_fragment);
}
if (this.#pending_effect === null) {
this.#pending_effect = branch(() => pending(this.#anchor));
}
}
/**
* Updates the pending count associated with the currently visible pending snippet,
* if any, such that we can replace the snippet with content once work is done
@ -317,24 +349,7 @@ export class Boundary {
this.#pending_count += d;
if (this.#pending_count === 0) {
this.is_pending = false;
// any effects that were encountered and deferred during traversal
// should be rescheduled — after the next traversal (which will happen
// immediately, due to the same update that brought us here)
// the effects will be flushed
for (const e of this.#dirty_effects) {
set_signal_status(e, DIRTY);
schedule_effect(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
}
this.#dirty_effects.clear();
this.#maybe_dirty_effects.clear();
this.#resolve();
if (this.#pending_effect) {
pause_effect(this.#pending_effect, () => {
@ -383,7 +398,7 @@ export class Boundary {
// If we have nothing to capture the error, or if we hit an error while
// rendering the fallback, re-throw for another boundary to handle
if (this.#is_creating_fallback || (!onerror && !failed)) {
if (!onerror && !failed) {
throw error;
}
@ -423,37 +438,25 @@ export class Boundary {
e.svelte_boundary_reset_onerror();
}
// If the failure happened while flushing effects, current_batch can be null
Batch.ensure();
this.#local_pending_count = 0;
if (this.#failed_effect !== null) {
pause_effect(this.#failed_effect, () => {
this.#failed_effect = null;
});
}
// we intentionally do not try to find the nearest pending boundary. If this boundary has one, we'll render it on reset
// but it would be really weird to show the parent's boundary on a child reset.
this.is_pending = this.has_pending_snippet();
this.#run(() => {
// If the failure happened while flushing effects, current_batch can be null
Batch.ensure();
this.#main_effect = this.#run(() => {
this.#is_creating_fallback = false;
return branch(() => this.#children(this.#anchor));
this.#render();
});
if (this.#pending_count > 0) {
this.#show_pending_snippet();
} else {
this.is_pending = false;
}
};
queue_micro_task(() => {
/** @param {unknown} transformed_error */
const handle_error_result = (transformed_error) => {
try {
calling_on_error = true;
onerror?.(error, reset);
onerror?.(transformed_error, reset);
calling_on_error = false;
} catch (error) {
invoke_error_boundary(error, this.#effect && this.#effect.parent);
@ -462,32 +465,60 @@ export class Boundary {
if (failed) {
this.#failed_effect = this.#run(() => {
Batch.ensure();
this.#is_creating_fallback = true;
try {
return branch(() => {
// errors in `failed` snippets cause the boundary to error again
// TODO Svelte 6: revisit this decision, most likely better to go to parent boundary instead
var effect = /** @type {Effect} */ (active_effect);
effect.b = this;
effect.f |= BOUNDARY_EFFECT;
failed(
this.#anchor,
() => error,
() => transformed_error,
() => reset
);
});
} catch (error) {
invoke_error_boundary(error, /** @type {Effect} */ (this.#effect.parent));
return null;
} finally {
this.#is_creating_fallback = false;
}
});
}
};
queue_micro_task(() => {
// Run the error through the API-level transformError transform (e.g. SvelteKit's handleError)
/** @type {unknown} */
var result;
try {
result = this.transform_error(error);
} catch (e) {
invoke_error_boundary(e, this.#effect && this.#effect.parent);
return;
}
if (
result !== null &&
typeof result === 'object' &&
typeof (/** @type {any} */ (result).then) === 'function'
) {
// transformError returned a Promise — wait for it
/** @type {any} */ (result).then(
handle_error_result,
/** @param {unknown} e */
(e) => invoke_error_boundary(e, this.#effect && this.#effect.parent)
);
} else {
// Synchronous result — handle immediately
handle_error_result(result);
}
});
}
}
export function get_boundary() {
return /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
}
export function pending() {
if (active_effect === null) {
e.effect_pending_outside_reaction();

@ -68,9 +68,10 @@ export class BranchManager {
this.#transition = transition;
}
#commit = () => {
var batch = /** @type {Batch} */ (current_batch);
/**
* @param {Batch} batch
*/
#commit = (batch) => {
// if this batch was made obsolete, bail
if (!this.#batches.has(batch)) return;
@ -221,7 +222,7 @@ export class BranchManager {
this.anchor = hydrate_node;
}
this.#commit();
this.#commit(batch);
}
}
}

@ -250,6 +250,14 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var value = array[index];
var key = get_key(value, index);
if (DEV) {
// Check that the key function is idempotent (returns the same value when called twice)
var key_again = get_key(value, index);
if (key !== key_again) {
e.each_key_volatile(String(index), String(key), String(key_again));
}
}
var item = first_run ? null : items.get(key);
if (item) {

@ -1,21 +1,27 @@
/** @import { Effect, TemplateNode } from '#client' */
import { FILENAME, HYDRATION_ERROR } from '../../../../constants.js';
/** @import {} from 'trusted-types' */
import {
FILENAME,
HYDRATION_ERROR,
NAMESPACE_SVG,
NAMESPACE_MATHML
} from '../../../../constants.js';
import { remove_effect_dom, template_effect } from '../../reactivity/effects.js';
import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from '../hydration.js';
import { create_fragment_from_html } from '../reconciler.js';
import { assign_nodes } from '../template.js';
import * as w from '../../warnings.js';
import { hash, sanitize_location } from '../../../../utils.js';
import { DEV } from 'esm-env';
import { dev_current_component_function } from '../../context.js';
import { get_first_child, get_next_sibling } from '../operations.js';
import { create_element, get_first_child, get_next_sibling } from '../operations.js';
import { active_effect } from '../../runtime.js';
import { COMMENT_NODE } from '#client/constants';
/**
* @param {Element} element
* @param {string | null} server_hash
* @param {string} value
* @param {string | TrustedHTML} value
*/
function check_hash(element, server_hash, value) {
if (!server_hash || server_hash === hash(String(value ?? ''))) return;
@ -35,7 +41,7 @@ function check_hash(element, server_hash, value) {
/**
* @param {Element | Text | Comment} node
* @param {() => string} get_value
* @param {() => string | TrustedHTML} get_value
* @param {boolean} [svg]
* @param {boolean} [mathml]
* @param {boolean} [skip_warning]
@ -44,6 +50,7 @@ function check_hash(element, server_hash, value) {
export function html(node, get_value, svg = false, mathml = false, skip_warning = false) {
var anchor = node;
/** @type {string | TrustedHTML} */
var value = '';
template_effect(() => {
@ -92,18 +99,18 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning
return;
}
var html = value + '';
if (svg) html = `<svg>${html}</svg>`;
else if (mathml) html = `<math>${html}</math>`;
// Don't use create_fragment_with_script_from_html here because that would mean script tags are executed.
// @html is basically `.innerHTML = ...` and that doesn't execute scripts either due to security reasons.
/** @type {DocumentFragment | Element} */
var node = create_fragment_from_html(html);
// Use a <template>, <svg>, or <math> wrapper depending on context. If value is a TrustedHTML object,
// it will be assigned directly to innerHTML without coercion — this allows {@html policy.createHTML(...)} to work.
var ns = svg ? NAMESPACE_SVG : mathml ? NAMESPACE_MATHML : undefined;
var wrapper = /** @type {HTMLTemplateElement | SVGElement | MathMLElement} */ (
create_element(svg ? 'svg' : mathml ? 'math' : 'template', ns)
);
wrapper.innerHTML = /** @type {any} */ (value);
if (svg || mathml) {
node = /** @type {Element} */ (get_first_child(node));
}
/** @type {DocumentFragment | Element} */
var node = svg || mathml ? wrapper : /** @type {HTMLTemplateElement} */ (wrapper).content;
assign_nodes(
/** @type {TemplateNode} */ (get_first_child(node)),

@ -6,7 +6,8 @@ import {
read_hydration_instruction,
skip_nodes,
set_hydrate_node,
set_hydrating
set_hydrating,
hydrate_node
} from '../hydration.js';
import { block } from '../../reactivity/effects.js';
import { BranchManager } from './branches.js';
@ -19,7 +20,10 @@ import { HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js'
* @returns {void}
*/
export function if_block(node, fn, elseif = false) {
/** @type {TemplateNode | undefined} */
var marker;
if (hydrating) {
marker = hydrate_node;
hydrate_next();
}
@ -32,8 +36,7 @@ export function if_block(node, fn, elseif = false) {
*/
function update_branch(key, fn) {
if (hydrating) {
const data = read_hydration_instruction(node);
var data = read_hydration_instruction(/** @type {TemplateNode} */ (marker));
/**
* @type {number | false}
* "[" = branch 0, "[1" = branch 1, "[2" = branch 2, ..., "[!" = else (false)

@ -83,7 +83,7 @@ export function createRawSnippet(fn) {
hydrate_next();
} else {
var html = snippet.render().trim();
var fragment = create_fragment_from_html(html, true);
var fragment = create_fragment_from_html(html);
element = /** @type {Element} */ (get_first_child(fragment));
if (DEV && (get_next_sibling(element) !== null || element.nodeType !== ELEMENT_NODE)) {

@ -1,8 +1,17 @@
/** @import { TemplateNode, Dom } from '#client' */
import { EFFECT_TRANSPARENT } from '#client/constants';
import { block } from '../../reactivity/effects.js';
import { hydrate_next, hydrating } from '../hydration.js';
import {
hydrate_next,
hydrate_node,
hydrating,
read_hydration_instruction,
set_hydrate_node,
set_hydrating,
skip_nodes
} from '../hydration.js';
import { BranchManager } from './branches.js';
import { HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js';
/**
* @template P
@ -13,7 +22,11 @@ import { BranchManager } from './branches.js';
* @returns {void}
*/
export function component(node, get_component, render_fn) {
/** @type {TemplateNode | undefined} */
var hydration_start_node;
if (hydrating) {
hydration_start_node = hydrate_node;
hydrate_next();
}
@ -21,6 +34,28 @@ export function component(node, get_component, render_fn) {
block(() => {
var component = get_component() ?? null;
if (hydrating) {
var data = read_hydration_instruction(/** @type {TemplateNode} */ (hydration_start_node));
var server_had_component = data === HYDRATION_START;
var client_has_component = component !== null;
if (server_had_component !== client_has_component) {
// Hydration mismatch: skip the server-rendered nodes and render fresh
var anchor = skip_nodes();
set_hydrate_node(anchor);
branches.anchor = anchor;
set_hydrating(false);
branches.ensure(component, component && ((target) => render_fn(target, component)));
set_hydrating(true);
return;
}
}
branches.ensure(component, component && ((target) => render_fn(target, component)));
}, EFFECT_TRANSPARENT);
}

@ -2,7 +2,7 @@
import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js';
import { create_text, get_first_child, get_next_sibling } from '../operations.js';
import { block } from '../../reactivity/effects.js';
import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants';
import { COMMENT_NODE, EFFECT_PRESERVED, HEAD_EFFECT } from '#client/constants';
/**
* @param {string} hash
@ -49,7 +49,9 @@ export function head(hash, render_fn) {
}
try {
block(() => render_fn(anchor), HEAD_EFFECT);
// normally a branch is the child of a block and would have the EFFECT_PRESERVED flag,
// but since head blocks don't necessarily only have direct branch children we add it on the block itself
block(() => render_fn(anchor), HEAD_EFFECT | EFFECT_PRESERVED);
} finally {
if (was_hydrating) {
set_hydrating(true);

@ -1,5 +1,6 @@
import { hydrating, reset, set_hydrate_node, set_hydrating } from '../hydration.js';
import { create_comment, create_element } from '../operations.js';
import { create_trusted_html } from '../reconciler.js';
import { attach } from './attachments.js';
/** @type {boolean | null} */
@ -14,7 +15,7 @@ let supported = null;
function is_supported() {
if (supported === null) {
var select = create_element('select');
select.innerHTML = '<option><span>t</span></option>';
select.innerHTML = create_trusted_html('<option><span>t</span></option>');
supported = /** @type {Element} */ (select.firstChild)?.firstChild?.nodeType === 1;
}

@ -11,8 +11,11 @@ import {
set_active_reaction
} from '../../runtime.js';
import { without_reactive_context } from './bindings/shared.js';
import { can_delegate_event } from '../../../../utils.js';
/**
* Used on elements, as a map of event type -> event handler,
* and on events themselves to track which element handled an event
*/
export const event_symbol = Symbol('events');
/** @type {Set<string>} */
@ -177,8 +180,8 @@ export function handle_event_propagation(event) {
last_propagated_event = event;
// composedPath contains list of nodes the event has propagated through.
// We check __root to skip all nodes below it in case this is a
// parent of the __root node, which indicates that there's nested
// We check `event_symbol` to skip all nodes below it in case this is a
// parent of the `event_symbol` node, which indicates that there's nested
// mounted apps. In this case we don't want to trigger events multiple times.
var path_idx = 0;
@ -186,7 +189,7 @@ export function handle_event_propagation(event) {
// without it the variable will be DCE'd and things will
// fail mysteriously in Firefox
// @ts-expect-error is added below
var handled_at = last_propagated_event === event && event.__root;
var handled_at = last_propagated_event === event && event[event_symbol];
if (handled_at) {
var at_idx = path.indexOf(handled_at);
@ -198,7 +201,7 @@ export function handle_event_propagation(event) {
// -> ignore, but set handle_at to document/window so that we're resetting the event
// chain in case someone manually dispatches the same event object again.
// @ts-expect-error
event.__root = handler_element;
event[event_symbol] = handler_element;
return;
}
@ -298,7 +301,7 @@ export function handle_event_propagation(event) {
}
} finally {
// @ts-expect-error is used above
event.__root = handler_element;
event[event_symbol] = handler_element;
// @ts-ignore remove proxy on currentTarget
delete event.currentTarget;
set_active_reaction(previous_reaction);

@ -1,29 +1,25 @@
/** @import {} from 'trusted-types' */
import { create_element } from './operations.js';
const policy = /* @__PURE__ */ globalThis?.window?.trustedTypes?.createPolicy(
'svelte-trusted-html',
{
const policy =
// We gotta write it like this because after downleveling the pure comment may end up in the wrong location
globalThis?.window?.trustedTypes &&
/* @__PURE__ */ globalThis.window.trustedTypes.createPolicy('svelte-trusted-html', {
/** @param {string} html */
createHTML: (html) => {
return html;
}
}
);
});
/** @param {string} html */
function create_trusted_html(html) {
export function create_trusted_html(html) {
return /** @type {string} */ (policy?.createHTML(html) ?? html);
}
/**
* @param {string} html
* @param {boolean} trusted
*/
export function create_fragment_from_html(html, trusted = false) {
export function create_fragment_from_html(html) {
var elem = create_element('template');
html = html.replaceAll('<!>', '<!---->'); // XHTML compliance
elem.innerHTML = trusted ? create_trusted_html(html) : html;
elem.innerHTML = create_trusted_html(html.replaceAll('<!>', '<!---->')); // XHTML compliance
return elem.content;
}

@ -70,7 +70,7 @@ export function from_html(content, flags) {
}
if (node === undefined) {
node = create_fragment_from_html(has_start ? content : '<!>' + content, true);
node = create_fragment_from_html(has_start ? content : '<!>' + content);
if (!is_fragment) node = /** @type {TemplateNode} */ (get_first_child(node));
}
@ -118,7 +118,7 @@ function from_namespace(content, flags, ns = 'svg') {
}
if (!node) {
var fragment = /** @type {DocumentFragment} */ (create_fragment_from_html(wrapped, true));
var fragment = /** @type {DocumentFragment} */ (create_fragment_from_html(wrapped));
var root = /** @type {Element} */ (get_first_child(fragment));
if (is_fragment) {

@ -147,6 +147,25 @@ export function each_key_duplicate(a, b, value) {
}
}
/**
* 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
* @param {string} index
* @param {string} a
* @param {string} b
* @returns {never}
*/
export function each_key_volatile(index, a, b) {
if (DEV) {
const error = new Error(`each_key_volatile\nKeyed 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\nhttps://svelte.dev/e/each_key_volatile`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/each_key_volatile`);
}
}
/**
* `%rune%` cannot be used inside an effect cleanup function
* @param {string} rune

@ -153,7 +153,6 @@ export {
safe_get,
tick,
untrack,
exclude_from_object,
deep_read,
deep_read_state,
active_effect
@ -171,7 +170,7 @@ export {
} from './dom/operations.js';
export { attr, clsx } from '../shared/attributes.js';
export { snapshot } from '../shared/clone.js';
export { noop, fallback, to_array } from '../shared/utils.js';
export { noop, fallback, to_array, exclude_from_object } from '../shared/utils.js';
export {
invalid_default_snippet,
validate_dynamic_element_tag,

@ -8,7 +8,7 @@ import {
set_component_context,
set_dev_stack
} from '../context.js';
import { get_boundary } from '../dom/blocks/boundary.js';
import { Boundary } from '../dom/blocks/boundary.js';
import { invoke_error_boundary } from '../error-handling.js';
import {
active_effect,
@ -66,7 +66,6 @@ export function flatten(blockers, sync, async, fn) {
}
}
batch?.deactivate();
unset_context();
}
@ -207,10 +206,11 @@ export async function* for_await_track_reactivity_loss(iterable) {
}
}
export function unset_context() {
export function unset_context(deactivate_batch = true) {
set_active_effect(null);
set_active_reaction(null);
set_component_context(null);
if (deactivate_batch) current_batch?.deactivate();
if (DEV) {
set_from_async_derived(null);
@ -224,12 +224,7 @@ export function unset_context() {
export function run(thunks) {
const restore = capture();
var boundary = get_boundary();
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
boundary.update_pending_count(1);
batch.increment(blocking);
const decrement_pending = increment_pending();
var active = /** @type {Effect} */ (active_effect);
@ -253,6 +248,7 @@ export function run(thunks) {
promise.finally(() => {
blocker.settled = true;
unset_context();
});
for (const fn of thunks.slice(1)) {
@ -276,9 +272,7 @@ export function run(thunks) {
promise.finally(() => {
blocker.settled = true;
unset_context();
current_batch?.deactivate();
});
}
@ -286,10 +280,7 @@ export function run(thunks) {
// wait one more tick, so that template effects are
// guaranteed to run before `$effect(...)`
.then(() => Promise.resolve())
.finally(() => {
boundary.update_pending_count(-1);
batch.decrement(blocking);
});
.finally(decrement_pending);
return blockers;
}
@ -300,3 +291,17 @@ export function run(thunks) {
export function wait(blockers) {
return Promise.all(blockers.map((b) => b.promise));
}
export function increment_pending() {
var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
boundary.update_pending_count(1);
batch.increment(blocking);
return () => {
boundary.update_pending_count(-1);
batch.decrement(blocking);
};
}

@ -1,6 +1,5 @@
/** @import { Fork } from 'svelte' */
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
/** @import { Boundary } from '../dom/blocks/boundary' */
import {
BLOCK_EFFECT,
BRANCH_EFFECT,
@ -14,11 +13,11 @@ import {
ROOT_EFFECT,
MAYBE_DIRTY,
DERIVED,
BOUNDARY_EFFECT,
EAGER_EFFECT,
HEAD_EFFECT,
ERROR_VALUE,
MANAGED_EFFECT
MANAGED_EFFECT,
REACTION_RAN
} from '#client/constants';
import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property, includes } from '../../shared/utils.js';
@ -70,9 +69,15 @@ let last_scheduled_effect = null;
let is_flushing = false;
export let is_flushing_sync = false;
export class Batch {
committed = false;
/**
* During traversal, this is an array. Newly created effects are (if not immediately
* executed) pushed to this array, rather than going through the scheduling
* rigamarole that would cause another turn of the flush loop.
* @type {Effect[] | null}
*/
export let collected_effects = null;
export class Batch {
/**
* The current values of any sources that are updated in this batch
* They keys of this map are identical to `this.#previous`
@ -90,7 +95,7 @@ export class Batch {
/**
* When the batch is committed (and the DOM is updated), we need to remove old branches
* and append new ones by calling the functions added inside (if/each/key/etc) blocks
* @type {Set<() => void>}
* @type {Set<(batch: Batch) => void>}
*/
#commit_callbacks = new Set();
@ -142,7 +147,7 @@ export class Batch {
#decrement_queued = false;
is_deferred() {
#is_deferred() {
return this.is_fork || this.#blocking_pending > 0;
}
@ -188,7 +193,7 @@ export class Batch {
this.apply();
/** @type {Effect[]} */
var effects = [];
var effects = (collected_effects = []);
/** @type {Effect[]} */
var render_effects = [];
@ -202,7 +207,9 @@ export class Batch {
// log_inconsistent_branches(root);
}
if (this.is_deferred()) {
collected_effects = null;
if (this.#is_deferred()) {
this.#defer_effects(render_effects);
this.#defer_effects(effects);
@ -210,22 +217,26 @@ export class Batch {
reset_branch(e, t);
}
} else {
// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
// newly updated sources, which could lead to infinite loops when effects run over and over again.
previous_batch = this;
current_batch = null;
// append/remove branches
for (const fn of this.#commit_callbacks) fn();
for (const fn of this.#commit_callbacks) fn(this);
this.#commit_callbacks.clear();
if (this.#pending === 0) {
this.#commit();
}
// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
// newly updated sources, which could lead to infinite loops when effects run over and over again.
previous_batch = this;
current_batch = null;
flush_queued_effects(render_effects);
flush_queued_effects(effects);
// Clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
this.#dirty_effects.clear();
this.#maybe_dirty_effects.clear();
previous_batch = null;
this.#deferred?.resolve();
@ -246,9 +257,6 @@ export class Batch {
var effect = root.first;
/** @type {Effect | null} */
var pending_boundary = null;
while (effect !== null) {
var flags = effect.f;
var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;
@ -256,26 +264,9 @@ export class Batch {
var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect);
// Inside a `<svelte:boundary>` with a pending snippet,
// all effects are deferred until the boundary resolves
// (except block/async effects, which run immediately)
if (
async_mode_flag &&
pending_boundary === null &&
(flags & BOUNDARY_EFFECT) !== 0 &&
effect.b?.is_pending
) {
pending_boundary = effect;
}
if (!skip && effect.fn !== null) {
if (is_branch) {
effect.f ^= CLEAN;
} else if (
pending_boundary !== null &&
(flags & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0
) {
/** @type {Boundary} */ (pending_boundary.b).defer_effect(effect);
} else if ((flags & EFFECT) !== 0) {
effects.push(effect);
} else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) {
@ -293,16 +284,15 @@ export class Batch {
}
}
var parent = effect.parent;
effect = effect.next;
while (effect !== null) {
var next = effect.next;
while (effect === null && parent !== null) {
if (parent === pending_boundary) {
pending_boundary = null;
if (next !== null) {
effect = next;
break;
}
effect = parent.next;
parent = parent.parent;
effect = effect.parent;
}
}
}
@ -349,17 +339,16 @@ export class Batch {
}
flush() {
this.activate();
if (queued_root_effects.length > 0) {
this.activate();
flush_effects();
} else if (this.#pending === 0 && !this.is_fork) {
// append/remove branches
for (const fn of this.#commit_callbacks) fn(this);
this.#commit_callbacks.clear();
if (current_batch !== null && current_batch !== this) {
// this can happen if a new batch was created during `flush_effects()`
return;
}
} else if (this.#pending === 0) {
this.process([]); // TODO this feels awkward
this.#commit();
this.#deferred?.resolve();
}
this.deactivate();
@ -378,6 +367,7 @@ export class Batch {
if (batches.size > 1) {
this.previous.clear();
var previous_batch = current_batch;
var previous_batch_values = batch_values;
var is_earlier = true;
@ -441,11 +431,11 @@ export class Batch {
}
}
current_batch = null;
current_batch = previous_batch;
batch_values = previous_batch_values;
}
this.committed = true;
this.#skipped_branches.clear();
batches.delete(this);
}
@ -472,7 +462,7 @@ export class Batch {
queue_micro_task(() => {
this.#decrement_queued = false;
if (!this.is_deferred()) {
if (!this.#is_deferred()) {
// we only reschedule previously-deferred effects if we expect
// to be able to run them after processing the batch
this.revive();
@ -499,7 +489,7 @@ export class Batch {
this.flush();
}
/** @param {() => void} fn */
/** @param {(batch: Batch) => void} fn */
oncommit(fn) {
this.#commit_callbacks.add(fn);
}
@ -651,6 +641,7 @@ function flush_effects() {
is_flushing = false;
last_scheduled_effect = null;
collected_effects = null;
if (DEV) {
for (const source of /** @type {Set<Source>} */ (source_stacks)) {
@ -836,6 +827,19 @@ function depends_on(reaction, sources, checked) {
export function schedule_effect(signal) {
var effect = (last_scheduled_effect = signal);
var boundary = effect.b;
// defer render effects inside a pending boundary
// TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later
if (
boundary?.is_pending &&
(signal.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 &&
(signal.f & REACTION_RAN) === 0
) {
boundary.defer_effect(signal);
return;
}
while (effect.parent !== null) {
effect = effect.parent;
var flags = effect.f;
@ -843,17 +847,21 @@ export function schedule_effect(signal) {
// if the effect is being scheduled because a parent (each/await/etc) block
// updated an internal source, or because a branch is being unskipped,
// bail out or we'll cause a second flush
if (
is_flushing &&
effect === active_effect &&
(flags & BLOCK_EFFECT) !== 0 &&
(flags & HEAD_EFFECT) === 0
) {
return;
if (collected_effects !== null && effect === active_effect) {
// in sync mode, render effects run during traversal. in an extreme edge case
// they can be made dirty after they have already been visited, in which
// case we shouldn't bail out
if (async_mode_flag || (signal.f & RENDER_EFFECT) === 0) {
return;
}
}
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) return;
if ((flags & CLEAN) === 0) {
// branch is already dirty, bail
return;
}
effect.f ^= CLEAN;
}
}

@ -40,7 +40,7 @@ import { Boundary } from '../dom/blocks/boundary.js';
import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js';
import { batch_values, current_batch } from './batch.js';
import { unset_context } from './async.js';
import { increment_pending, unset_context } from './async.js';
import { deferred, includes, noop } from '../../shared/utils.js';
import { set_signal_status, update_derived_status } from './status.js';
@ -111,8 +111,6 @@ export function async_derived(fn, label, location) {
e.async_derived_orphan();
}
var boundary = /** @type {Boundary} */ (parent.b);
var promise = /** @type {Promise<V>} */ (/** @type {unknown} */ (undefined));
var signal = source(/** @type {V} */ (UNINITIALIZED));
@ -135,17 +133,7 @@ export function async_derived(fn, label, location) {
// If this code is changed at some point, make sure to still access the then property
// of fn() to read any signals it might access, so that we track them as dependencies.
// We call `unset_context` to undo any `save` calls that happen inside `fn()`
Promise.resolve(fn())
.then(d.resolve, d.reject)
.then(() => {
if (batch === current_batch && batch.committed) {
// if the batch was rejected as stale, we need to cleanup
// after any `$.save(...)` calls inside `fn()`
batch.deactivate();
}
unset_context();
});
Promise.resolve(fn()).then(d.resolve, d.reject).finally(unset_context);
} catch (error) {
d.reject(error);
unset_context();
@ -156,10 +144,7 @@ export function async_derived(fn, label, location) {
var batch = /** @type {Batch} */ (current_batch);
if (should_suspend) {
var blocking = boundary.is_rendered();
boundary.update_pending_count(1);
batch.increment(blocking);
var decrement_pending = increment_pending();
deferreds.get(batch)?.reject(STALE_REACTION);
deferreds.delete(batch); // delete to ensure correct order in Map iteration below
@ -208,9 +193,8 @@ export function async_derived(fn, label, location) {
}
}
if (should_suspend) {
boundary.update_pending_count(-1);
batch.decrement(blocking);
if (decrement_pending) {
decrement_pending();
}
};

@ -40,8 +40,8 @@ import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js';
import { Batch, current_batch, schedule_effect } from './batch.js';
import { flatten } from './async.js';
import { Batch, collected_effects, schedule_effect } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
@ -80,10 +80,9 @@ function push_effect(effect, parent_effect) {
/**
* @param {number} type
* @param {null | (() => void | (() => void))} fn
* @param {boolean} sync
* @returns {Effect}
*/
function create_effect(type, fn, sync) {
function create_effect(type, fn) {
var parent = active_effect;
if (DEV) {
@ -119,34 +118,39 @@ function create_effect(type, fn, sync) {
effect.component_function = dev_current_component_function;
}
if (sync) {
/** @type {Effect | null} */
var e = effect;
if ((type & EFFECT) !== 0) {
if (collected_effects !== null) {
// created during traversal — collect and run afterwards
collected_effects.push(effect);
} else {
// schedule for later
schedule_effect(effect);
}
} else if (fn !== null) {
try {
update_effect(effect);
} catch (e) {
destroy_effect(effect);
throw e;
}
} else if (fn !== null) {
schedule_effect(effect);
}
/** @type {Effect | null} */
var e = effect;
// if an effect has already ran and doesn't need to be kept in the tree
// (because it won't re-run, has no DOM, and has no teardown etc)
// then we skip it and go to its child (if any)
if (
sync &&
e.deps === null &&
e.teardown === null &&
e.nodes === null &&
e.first === e.last && // either `null`, or a singular child
(e.f & EFFECT_PRESERVED) === 0
) {
e = e.first;
if ((type & BLOCK_EFFECT) !== 0 && (type & EFFECT_TRANSPARENT) !== 0 && e !== null) {
e.f |= EFFECT_TRANSPARENT;
// if an effect doesn't need to be kept in the tree (because it
// won't re-run, has no DOM, and has no teardown etc)
// then we skip it and go to its child (if any)
if (
e.deps === null &&
e.teardown === null &&
e.nodes === null &&
e.first === e.last && // either `null`, or a singular child
(e.f & EFFECT_PRESERVED) === 0
) {
e = e.first;
if ((type & BLOCK_EFFECT) !== 0 && (type & EFFECT_TRANSPARENT) !== 0 && e !== null) {
e.f |= EFFECT_TRANSPARENT;
}
}
}
@ -183,7 +187,7 @@ export function effect_tracking() {
* @param {() => void} fn
*/
export function teardown(fn) {
const effect = create_effect(RENDER_EFFECT, null, false);
const effect = create_effect(RENDER_EFFECT, null);
set_signal_status(effect, CLEAN);
effect.teardown = fn;
return effect;
@ -221,7 +225,7 @@ export function user_effect(fn) {
* @param {() => void | (() => void)} fn
*/
export function create_user_effect(fn) {
return create_effect(EFFECT | USER_EFFECT, fn, false);
return create_effect(EFFECT | USER_EFFECT, fn);
}
/**
@ -236,12 +240,12 @@ export function user_pre_effect(fn) {
value: '$effect.pre'
});
}
return create_effect(RENDER_EFFECT | USER_EFFECT, fn, true);
return create_effect(RENDER_EFFECT | USER_EFFECT, fn);
}
/** @param {() => void | (() => void)} fn */
export function eager_effect(fn) {
return create_effect(EAGER_EFFECT, fn, true);
return create_effect(EAGER_EFFECT, fn);
}
/**
@ -251,7 +255,7 @@ export function eager_effect(fn) {
*/
export function effect_root(fn) {
Batch.ensure();
const effect = create_effect(ROOT_EFFECT | EFFECT_PRESERVED, fn, true);
const effect = create_effect(ROOT_EFFECT | EFFECT_PRESERVED, fn);
return () => {
destroy_effect(effect);
@ -265,7 +269,7 @@ export function effect_root(fn) {
*/
export function component_root(fn) {
Batch.ensure();
const effect = create_effect(ROOT_EFFECT | EFFECT_PRESERVED, fn, true);
const effect = create_effect(ROOT_EFFECT | EFFECT_PRESERVED, fn);
return (options = {}) => {
return new Promise((fulfil) => {
@ -287,7 +291,7 @@ export function component_root(fn) {
* @returns {Effect}
*/
export function effect(fn) {
return create_effect(EFFECT, fn, false);
return create_effect(EFFECT, fn);
}
/**
@ -345,7 +349,7 @@ export function legacy_pre_effect_reset() {
* @returns {Effect}
*/
export function async_effect(fn) {
return create_effect(ASYNC | EFFECT_PRESERVED, fn, true);
return create_effect(ASYNC | EFFECT_PRESERVED, fn);
}
/**
@ -353,7 +357,7 @@ export function async_effect(fn) {
* @returns {Effect}
*/
export function render_effect(fn, flags = 0) {
return create_effect(RENDER_EFFECT | flags, fn, true);
return create_effect(RENDER_EFFECT | flags, fn);
}
/**
@ -364,7 +368,7 @@ export function render_effect(fn, flags = 0) {
*/
export function template_effect(fn, sync = [], async = [], blockers = []) {
flatten(blockers, sync, async, (values) => {
create_effect(RENDER_EFFECT, () => fn(...values.map(get)), true);
create_effect(RENDER_EFFECT, () => fn(...values.map(get)));
});
}
@ -376,14 +380,16 @@ export function template_effect(fn, sync = [], async = [], blockers = []) {
* @param {Blocker[]} blockers
*/
export function deferred_template_effect(fn, sync = [], async = [], blockers = []) {
var batch = /** @type {Batch} */ (current_batch);
var is_async = async.length > 0 || blockers.length > 0;
if (is_async) batch.increment(true);
if (async.length > 0 || blockers.length > 0) {
var decrement_pending = increment_pending();
}
flatten(blockers, sync, async, (values) => {
create_effect(EFFECT, () => fn(...values.map(get)), false);
if (is_async) batch.decrement(true);
create_effect(EFFECT, () => fn(...values.map(get)));
if (decrement_pending) {
decrement_pending();
}
});
}
@ -392,7 +398,7 @@ export function deferred_template_effect(fn, sync = [], async = [], blockers = [
* @param {number} flags
*/
export function block(fn, flags = 0) {
var effect = create_effect(BLOCK_EFFECT | flags, fn, true);
var effect = create_effect(BLOCK_EFFECT | flags, fn);
if (DEV) {
effect.dev_stack = dev_stack;
}
@ -404,7 +410,7 @@ export function block(fn, flags = 0) {
* @param {number} flags
*/
export function managed(fn, flags = 0) {
var effect = create_effect(MANAGED_EFFECT | flags, fn, true);
var effect = create_effect(MANAGED_EFFECT | flags, fn);
if (DEV) {
effect.dev_stack = dev_stack;
}
@ -415,7 +421,7 @@ export function managed(fn, flags = 0) {
* @param {(() => void)} fn
*/
export function branch(fn) {
return create_effect(BRANCH_EFFECT | EFFECT_PRESERVED, fn, true);
return create_effect(BRANCH_EFFECT | EFFECT_PRESERVED, fn);
}
/**

@ -45,12 +45,12 @@ export function set_should_intro(value) {
*/
export function set_text(text, value) {
// For objects, we apply string coercion (which might make things like $state array references in the template reactive) before diffing
var str = value == null ? '' : typeof value === 'object' ? value + '' : value;
var str = value == null ? '' : typeof value === 'object' ? `${value}` : value;
// @ts-expect-error
if (str !== (text.__t ??= text.nodeValue)) {
// @ts-expect-error
text.__t = str;
text.nodeValue = str + '';
text.nodeValue = `${str}`;
}
}
@ -81,6 +81,7 @@ export function mount(component, options) {
* context?: Map<any, any>;
* intro?: boolean;
* recover?: boolean;
* transformError?: (error: unknown) => unknown;
* } : {
* target: Document | Element | ShadowRoot;
* props: Props;
@ -88,6 +89,7 @@ export function mount(component, options) {
* context?: Map<any, any>;
* intro?: boolean;
* recover?: boolean;
* transformError?: (error: unknown) => unknown;
* }} options
* @returns {Exports}
*/
@ -158,51 +160,12 @@ const listeners = new Map();
* @param {MountOptions} options
* @returns {Exports}
*/
function _mount(Component, { target, anchor, props = {}, events, context, intro = true }) {
function _mount(
Component,
{ target, anchor, props = {}, events, context, intro = true, transformError }
) {
init_operations();
/** @type {Set<string>} */
var registered_events = new Set();
/** @param {Array<string>} events */
var event_handle = (events) => {
for (var i = 0; i < events.length; i++) {
var event_name = events[i];
if (registered_events.has(event_name)) continue;
registered_events.add(event_name);
var passive = is_passive_event(event_name);
// Add the event listener to both the container and the document.
// The container listener ensures we catch events from within in case
// the outer content stops propagation of the event.
//
// The document listener ensures we catch events that originate from elements that were
// manually moved outside of the container (e.g. via manual portals).
for (const node of [target, document]) {
var counts = listeners.get(node);
if (counts === undefined) {
counts = new Map();
listeners.set(node, counts);
}
var count = counts.get(event_name);
if (count === undefined) {
node.addEventListener(event_name, handle_event_propagation, { passive });
counts.set(event_name, 1);
} else {
counts.set(event_name, count + 1);
}
}
}
};
event_handle(array_from(all_registered_events));
root_event_handles.add(event_handle);
/** @type {Exports} */
// @ts-expect-error will be defined because the render effect runs synchronously
var component = undefined;
@ -248,9 +211,53 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro
}
pop();
}
},
transformError
);
// Setup event delegation _after_ component is mounted - if an error would happen during mount, it would otherwise not be cleaned up
/** @type {Set<string>} */
var registered_events = new Set();
/** @param {Array<string>} events */
var event_handle = (events) => {
for (var i = 0; i < events.length; i++) {
var event_name = events[i];
if (registered_events.has(event_name)) continue;
registered_events.add(event_name);
var passive = is_passive_event(event_name);
// Add the event listener to both the container and the document.
// The container listener ensures we catch events from within in case
// the outer content stops propagation of the event.
//
// The document listener ensures we catch events that originate from elements that were
// manually moved outside of the container (e.g. via manual portals).
for (const node of [target, document]) {
var counts = listeners.get(node);
if (counts === undefined) {
counts = new Map();
listeners.set(node, counts);
}
var count = counts.get(event_name);
if (count === undefined) {
node.addEventListener(event_name, handle_event_propagation, { passive });
counts.set(event_name, 1);
} else {
counts.set(event_name, count + 1);
}
}
}
};
event_handle(array_from(all_registered_events));
root_event_handles.add(event_handle);
return () => {
for (var event_name of registered_events) {
for (const node of [target, document]) {

@ -752,30 +752,6 @@ export function untrack(fn) {
}
}
/**
* @param {Record<string | symbol, unknown>} obj
* @param {Array<string | symbol>} keys
* @returns {Record<string | symbol, unknown>}
*/
export function exclude_from_object(obj, keys) {
/** @type {Record<string | symbol, unknown>} */
var result = {};
for (var key in obj) {
if (!keys.includes(key)) {
result[key] = obj[key];
}
}
for (var symbol of Object.getOwnPropertySymbols(obj)) {
if (Object.propertyIsEnumerable.call(obj, symbol) && !keys.includes(symbol)) {
result[symbol] = obj[symbol];
}
}
return result;
}
/**
* Possibly traverse an object and read all its properties so that they're all reactive in case this is `$state`.
* Does only check first level of an object for performance reasons (heuristic should be good for 99% of all cases).

@ -4,6 +4,9 @@ let text_encoder;
// TODO - remove this and use global `crypto` when we drop Node 18
let crypto;
/** @param {string} module_name */
const obfuscated_import = (module_name) => import(/* @vite-ignore */ module_name);
/** @param {string} data */
export async function sha256(data) {
text_encoder ??= new TextEncoder();
@ -12,8 +15,8 @@ export async function sha256(data) {
crypto ??= globalThis.crypto?.subtle?.digest
? globalThis.crypto
: // @ts-ignore - we don't install node types in the prod build
// don't use 'node:crypto' because static analysers will think we rely on node when we don't
(await import(/* @vite-ignore */ 'node:' + 'crypto')).webcrypto;
// don't use import('node:crypto') directly because static analysers will think we rely on node when we don't
(await obfuscated_import('node:crypto')).webcrypto;
const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data));

@ -26,6 +26,19 @@ export function await_invalid() {
throw error;
}
/**
* `<svelte:element this="%tag%">` is not a valid element name the element will not be rendered
* @param {string} tag
* @returns {never}
*/
export function dynamic_element_invalid_tag(tag) {
const error = new Error(`dynamic_element_invalid_tag\n\`<svelte:element this="${tag}">\` is not a valid element name — the element will not be rendered\nhttps://svelte.dev/e/dynamic_element_invalid_tag`);
error.name = 'Svelte error';
throw error;
}
/**
* The `html` property of server render results has been deprecated. Use `body` instead.
* @returns {never}

@ -13,11 +13,17 @@ import {
} from '../../constants.js';
import { escape_html } from '../../escaping.js';
import { DEV } from 'esm-env';
import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydration.js';
import { EMPTY_COMMENT, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydration.js';
import { validate_store } from '../shared/validate.js';
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
import {
is_boolean_attribute,
is_raw_text_element,
is_void,
REGEX_VALID_TAG_NAME
} from '../../utils.js';
import { Renderer } from './renderer.js';
import * as e from './errors.js';
import { ssr_context } from './context.js';
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter
@ -35,6 +41,9 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop)
renderer.push('<!---->');
if (tag) {
if (!REGEX_VALID_TAG_NAME.test(tag)) {
e.dynamic_element_invalid_tag(tag);
}
renderer.push(`<${tag}`);
attributes_fn();
renderer.push(`>`);
@ -56,7 +65,7 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop)
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
* @template {Record<string, any>} Props
* @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp; transformError?: (error: unknown) => unknown }} [options]
* @returns {RenderOutput}
*/
export function render(component, options = {}) {
@ -97,16 +106,16 @@ export function css_props(renderer, is_html, props, component, dynamic = false)
renderer.push(`<g style="${styles}">`);
}
if (dynamic) {
component();
if (!dynamic) {
renderer.push('<!---->');
}
component();
if (is_html) {
renderer.push(`<!----></svelte-css-wrapper>`);
renderer.push('</svelte-css-wrapper>');
} else {
renderer.push(`<!----></g>`);
renderer.push('</g>');
}
}
@ -138,7 +147,7 @@ export function attributes(attrs, css_hash, classes, styles, flags = 0) {
const lowercase = (flags & ELEMENT_PRESERVE_ATTRIBUTE_CASE) === 0;
const is_input = (flags & ELEMENT_IS_INPUT) !== 0;
for (name in attrs) {
for (name of Object.keys(attrs)) {
// omit functions, internal svelte properties and invalid attribute names
if (typeof attrs[name] === 'function') continue;
if (name[0] === '$' && name[1] === '$') continue; // faster than name.startsWith('$$')
@ -150,6 +159,9 @@ export function attributes(attrs, css_hash, classes, styles, flags = 0) {
name = name.toLowerCase();
}
// omit event handler attributes
if (name.length > 2 && name.startsWith('on')) continue;
if (is_input) {
if (name === 'defaultvalue' || name === 'defaultchecked') {
name = name === 'defaultvalue' ? 'value' : 'checked';
@ -174,7 +186,8 @@ export function spread_props(props) {
for (let i = 0; i < props.length; i++) {
const obj = props[i];
for (key in obj) {
if (obj == null) continue;
for (key of Object.keys(obj)) {
const desc = Object.getOwnPropertyDescriptor(obj, key);
if (desc) {
Object.defineProperty(merged_props, key, desc);
@ -302,7 +315,7 @@ export function update_store_pre(store_values, store_name, store, d = 1) {
/** @param {Record<string, [any, any, any]>} store_values */
export function unsubscribe_stores(store_values) {
for (const store_name in store_values) {
for (const store_name of Object.keys(store_values)) {
store_values[store_name][1]();
}
}
@ -338,7 +351,7 @@ export function rest_props(props, rest) {
/** @type {Record<string, unknown>} */
const rest_props = {};
let key;
for (key in props) {
for (key of Object.keys(props)) {
if (!rest.includes(key)) {
rest_props[key] = props[key];
}
@ -363,7 +376,7 @@ export function sanitize_slots(props) {
/** @type {Record<string, boolean>} */
const sanitized = {};
if (props.children) sanitized.default = true;
for (const key in props.$$slots) {
for (const key of Object.keys(props.$$slots || {})) {
sanitized[key] = true;
}
return sanitized;
@ -376,7 +389,7 @@ export function sanitize_slots(props) {
* @param {Record<string, unknown>} props_now
*/
export function bind_props(props_parent, props_now) {
for (const key in props_now) {
for (const key of Object.keys(props_now)) {
const initial_value = props_parent[key];
const value = props_now[key];
if (
@ -457,7 +470,7 @@ export { push_element, pop_element, validate_snippet_args } from './dev.js';
export { snapshot } from '../shared/clone.js';
export { fallback, to_array } from '../shared/utils.js';
export { fallback, to_array, exclude_from_object } from '../shared/utils.js';
export {
invalid_default_snippet,
@ -474,17 +487,58 @@ export { escape_html as escape };
* @returns {(new_value?: T) => (T | void)}
*/
export function derived(fn) {
const get_value = once(fn);
/**
* @type {T | undefined}
*/
// deriveds created during render are memoized,
// deriveds created outside (e.g. SvelteKit `page` stuff) are not
const get_value = ssr_context === null ? fn : once(fn);
/** @type {T | undefined} */
let updated_value;
return function (new_value) {
if (arguments.length === 0) {
return updated_value ?? get_value();
}
updated_value = new_value;
return updated_value;
};
}
/**
* @template {number | bigint} T
* @param {(value?: T) => T} derived
* @param {1 | -1} [d]
* @returns {T}
*/
export function update_derived(derived, d = 1) {
const value = derived();
let increase = typeof value === 'bigint' ? BigInt(d) : d;
// for some reason TS is mad even if T is always number or bigint
derived(value + /** @type {*} */ (increase));
return value;
}
/**
* @template {number | bigint} T
* @param {(value?: T) => T} derived
* @param {1 | -1} [d]
* @returns {T}
*/
export function update_derived_pre(derived, d = 1) {
const old_value = derived();
let increase = typeof old_value === 'bigint' ? BigInt(d) : d;
// for some reason TS is mad even if T is always number or bigint
const value = old_value + /** @type {*} */ (increase);
derived(value);
return value;
}
/**
* @template T
* @param {()=>T} fn
*/
export function async_derived(fn) {
return Promise.resolve(fn()).then((value) => {
return () => value;
});
}

@ -3,15 +3,17 @@
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.js';
import { pop, push, set_ssr_context, ssr_context, save } from './context.js';
import { pop, push, set_ssr_context, ssr_context } from './context.js';
import * as e from './errors.js';
import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { HYDRATION_START_FAILED } from '../../constants.js';
import { attributes } from './index.js';
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { sha256 } from './crypto.js';
import * as devalue from 'devalue';
import { noop } from '../shared/utils.js';
import { has_own_property, noop } from '../shared/utils.js';
import { escape_html } from '../../escaping.js';
/** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@ -48,6 +50,17 @@ export class Renderer {
*/
#is_component_body = false;
/**
* If set, this renderer is an error boundary. When async collection
* of the children fails, the failed snippet is rendered instead.
* @type {{
* failed: (renderer: Renderer, error: unknown, reset: () => void) => void;
* transformError: (error: unknown) => unknown;
* context: SSRContext | null;
* } | null}
*/
#boundary = null;
/**
* The type of string content that this renderer is accumulating.
* @type {RendererType}
@ -203,17 +216,97 @@ export class Renderer {
set_ssr_context(parent);
if (result instanceof Promise) {
// catch to avoid unhandled promise rejections - we'll end up throwing in `collect_async` if something fails
result.catch(noop);
result.finally(() => set_ssr_context(null)).catch(noop);
if (child.global.mode === 'sync') {
e.await_invalid();
}
// just to avoid unhandled promise rejections -- we'll end up throwing in `collect_async` if something fails
result.catch(() => {});
child.promise = result;
}
return child;
}
/**
* Render children inside an error boundary. If the children throw and the API-level
* `transformError` transform handles the error (doesn't re-throw), the `failed` snippet is
* rendered instead. Otherwise the error propagates.
*
* @param {{ failed?: (renderer: Renderer, error: unknown, reset: () => void) => void }} props
* @param {(renderer: Renderer) => MaybePromise<void>} children_fn
*/
boundary(props, children_fn) {
// Create a child renderer for the boundary content.
// Mark it as a boundary so that #collect_content_async can catch
// errors from nested async children and render the failed snippet.
const child = new Renderer(this.global, this);
this.#out.push(child);
const parent_context = ssr_context;
if (props.failed) {
child.#boundary = {
failed: props.failed,
transformError: this.global.transformError,
context: parent_context
};
}
set_ssr_context({
...ssr_context,
p: parent_context,
c: null,
r: child
});
try {
const result = children_fn(child);
set_ssr_context(parent_context);
if (result instanceof Promise) {
if (child.global.mode === 'sync') {
e.await_invalid();
}
result.catch(noop);
child.promise = result;
}
} catch (error) {
// synchronous errors are handled here, async errors will be handled in #collect_content_async
set_ssr_context(parent_context);
const failed_snippet = props.failed;
if (!failed_snippet) throw error;
const result = this.global.transformError(error);
child.#out.length = 0;
child.#boundary = null;
if (result instanceof Promise) {
if (this.global.mode === 'sync') {
e.await_invalid();
}
child.promise = /** @type {Promise<unknown>} */ (result).then((transformed) => {
set_ssr_context(parent_context);
child.#out.push(Renderer.#serialize_failed_boundary(transformed));
failed_snippet(child, transformed, noop);
child.#out.push(BLOCK_CLOSE);
});
child.promise.catch(noop);
} else {
child.#out.push(Renderer.#serialize_failed_boundary(result));
failed_snippet(child, result, noop);
child.#out.push(BLOCK_CLOSE);
}
}
}
/**
* Create a component renderer. The component renderer inherits the state from the parent,
* but has its own content. It is treated as an ordering boundary for ondestroy callbacks.
@ -267,7 +360,7 @@ export class Renderer {
* @param {{ head?: string, body: any }} content
*/
const close = (renderer, value, { head, body }) => {
if ('value' in attrs) {
if (has_own_property.call(attrs, 'value')) {
value = attrs.value;
}
@ -298,7 +391,7 @@ export class Renderer {
}
});
} else {
close(this, body, { body });
close(this, body, { body: escape_html(body) });
}
}
@ -389,6 +482,21 @@ export class Renderer {
return this.#out.length;
}
/**
* Creates the hydration comment that marks the start of a failed boundary.
* The error is JSON-serialized and embedded inside an HTML comment for the client
* to parse during hydration. The JSON is escaped to prevent `-->` or `<!--` sequences
* from breaking out of the comment (XSS). Uses unicode escapes which `JSON.parse()`
* handles transparently.
* @param {unknown} error
* @returns {string}
*/
static #serialize_failed_boundary(error) {
var json = JSON.stringify(error);
var escaped = json.replace(/>/g, '\\u003e').replace(/</g, '\\u003c');
return `<!--${HYDRATION_START_FAILED}${escaped}-->`;
}
/**
* Only available on the server and when compiling with the `server` option.
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
@ -588,7 +696,34 @@ export class Renderer {
if (typeof item === 'string') {
content[this.type] += item;
} else if (item instanceof Renderer) {
await item.#collect_content_async(content);
if (item.#boundary) {
// This renderer is an error boundary - collect into a separate
// accumulator so we can discard partial content on error
/** @type {AccumulatedContent} */
const boundary_content = { head: '', body: '' };
try {
await item.#collect_content_async(boundary_content);
// Success - merge into the main content
content.head += boundary_content.head;
content.body += boundary_content.body;
} catch (error) {
const { context, failed, transformError } = item.#boundary;
set_ssr_context(context);
let transformed = await transformError(error);
// Render the failed snippet instead of the partial children content
const failed_renderer = new Renderer(item.global, item);
failed_renderer.type = item.type;
failed_renderer.#out.push(Renderer.#serialize_failed_boundary(transformed));
failed(failed_renderer, transformed, noop);
failed_renderer.#out.push(BLOCK_CLOSE);
await failed_renderer.#collect_content_async(content);
}
} else {
await item.#collect_content_async(content);
}
}
}
@ -616,28 +751,35 @@ export class Renderer {
* @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode
* @param {import('svelte').Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} options
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp; transformError?: (error: unknown) => unknown }} options
* @returns {Renderer}
*/
static #open_render(mode, component, options) {
const renderer = new Renderer(
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '', options.csp)
);
renderer.push(BLOCK_OPEN);
push();
if (options.context) /** @type {SSRContext} */ (ssr_context).c = options.context;
/** @type {SSRContext} */ (ssr_context).r = renderer;
var previous_context = ssr_context;
// @ts-expect-error
component(renderer, options.props ?? {});
try {
const renderer = new Renderer(
new SSRState(
mode,
options.idPrefix ? options.idPrefix + '-' : '',
options.csp,
options.transformError
)
);
pop();
/** @type {SSRContext} */
const context = { p: null, c: options.context ?? null, r: renderer };
set_ssr_context(context);
renderer.push(BLOCK_CLOSE);
renderer.push(BLOCK_OPEN);
// @ts-expect-error
component(renderer, options.props ?? {});
renderer.push(BLOCK_CLOSE);
return renderer;
return renderer;
} finally {
set_ssr_context(previous_context);
}
}
/**
@ -733,6 +875,13 @@ export class SSRState {
/** @readonly @type {Set<{ hash: string; code: string }>} */
css = new Set();
/**
* `transformError` passed to `render`. Called when an error boundary catches an error.
* Throws by default if unset in `render`.
* @type {(error: unknown) => unknown}
*/
transformError;
/** @type {{ path: number[], value: string }} */
#title = { path: [], value: '' };
@ -740,11 +889,18 @@ export class SSRState {
* @param {'sync' | 'async'} mode
* @param {string} id_prefix
* @param {Csp} csp
* @param {((error: unknown) => unknown) | undefined} [transformError]
*/
constructor(mode, id_prefix = '', csp = { hash: false }) {
constructor(mode, id_prefix = '', csp = { hash: false }, transformError) {
this.mode = mode;
this.csp = { ...csp, script_hashes: [] };
this.transformError =
transformError ??
((error) => {
throw error;
});
let uid = 1;
this.uid = () => `${id_prefix}s${uid++}`;
}

@ -225,6 +225,118 @@ test('select merges scoped css hash with static class', () => {
);
});
describe('boundary hydration comment escaping', () => {
const failed_snippet = (renderer: Renderer, error: unknown) => {
renderer.push(`<p>${(error as { message: string }).message}</p>`);
};
const transform = (error: unknown) => ({ message: (error as Error).message });
const payloads = [
{
name: 'escapes -->',
input: '--><img src=x onerror=alert(1)><!--',
expected: '{"message":"--\\u003e\\u003cimg src=x onerror=alert(1)\\u003e\\u003c!--"}'
},
{
name: 'escapes <!--',
input: '<!--<script>alert(1)</script>',
expected: '{"message":"\\u003c!--\\u003cscript\\u003ealert(1)\\u003c/script\\u003e"}'
},
{ name: 'escapes <!-->', input: '<!-->', expected: '{"message":"\\u003c!--\\u003e"}' },
{ name: 'escapes <!--->', input: '<!--->', expected: '{"message":"\\u003c!---\\u003e"}' },
{
name: 'escapes multiple -->',
input: '-->one-->two-->',
expected: '{"message":"--\\u003eone--\\u003etwo--\\u003e"}'
},
{ name: 'escapes --->', input: '--->', expected: '{"message":"---\\u003e"}' },
{ name: 'no double-encoding', input: '--\\u003e', expected: '{"message":"--\\\\u003e"}' },
{
name: 'the terrifying special pointy boy',
input: '--!>ooh, what an exotic closing comment tag',
expected: '{"message":"--!\\u003eooh, what an exotic closing comment tag"}'
}
];
type RenderFn = (input: string) => Promise<string> | string;
const paths: Array<{ path: string; async: boolean; render: RenderFn }> = [
{
path: 'sync children, sync transformError',
async: false,
render: (input) => {
const component = (renderer: Renderer) => {
renderer.boundary({ failed: failed_snippet }, () => {
throw new Error(input);
});
};
return Renderer.render(
component as unknown as Component,
{ transformError: transform } as any
).body;
}
},
{
path: 'sync children, async transformError',
async: true,
render: async (input) => {
const component = (renderer: Renderer) => {
renderer.boundary({ failed: failed_snippet }, () => {
throw new Error(input);
});
};
return (
await Renderer.render(
component as unknown as Component,
{
transformError: (error: unknown) => Promise.resolve(transform(error))
} as any
)
).body;
}
},
{
path: 'async children throw',
async: true,
render: async (input) => {
const component = (renderer: Renderer) => {
renderer.boundary({ failed: failed_snippet }, async () => {
await Promise.resolve();
throw new Error(input);
});
};
return (
await Renderer.render(
component as unknown as Component,
{
transformError: transform
} as any
)
).body;
}
}
];
describe.each(paths)('$path', ({ async: needs_async, render }) => {
if (needs_async) {
beforeAll(() => enable_async_mode_flag());
afterAll(() => disable_async_mode_flag());
}
test.each(payloads)('$name', async ({ input, expected }) => {
const body = await render(input);
// Extract the content between <!--[? and the first -->
// If escaping is broken, an unescaped --> in the JSON will truncate
// the match and the content won't equal the expected escaped JSON.
const match = body.match(/<!--\[\?(.+?)-->/);
expect(match, 'expected a hydration comment in output').toBeTruthy();
expect(match![1]).toBe(expected);
});
});
});
describe('async', () => {
beforeAll(() => {
enable_async_mode_flag();

@ -1,5 +1,6 @@
import { escape_html } from '../../escaping.js';
import { clsx as _clsx } from 'clsx';
import { has_own_property } from './utils.js';
/**
* `<div translate={false}>` should be rendered as `<div translate="no">` and _not_
@ -27,7 +28,8 @@ export function attr(name, value, is_boolean = false) {
is_boolean = true;
}
if (value == null || (!value && is_boolean)) return '';
const normalized = (name in replacements && replacements[name].get(value)) || value;
const normalized =
(has_own_property.call(replacements, name) && replacements[name].get(value)) || value;
const assignment = is_boolean ? `=""` : `="${escape_html(normalized, true)}"`;
return ` ${name}${assignment}`;
}
@ -61,7 +63,7 @@ export function to_class(value, hash, directives) {
}
if (directives) {
for (var key in directives) {
for (var key of Object.keys(directives)) {
if (directives[key]) {
classname = classname ? classname + ' ' + key : key;
} else if (classname.length) {
@ -96,7 +98,7 @@ function append_styles(styles, important = false) {
var separator = important ? ' !important;' : ';';
var css = '';
for (var key in styles) {
for (var key of Object.keys(styles)) {
var value = styles[key];
if (value != null && value !== '') {
css += ' ' + key + ': ' + value + separator;

@ -89,7 +89,7 @@ function clone(value, cloned, path, paths, original = null, no_tojson = false) {
cloned.set(original, copy);
}
for (var key in value) {
for (var key of Object.keys(value)) {
copy[key] = clone(
// @ts-expect-error
value[key],

@ -12,6 +12,7 @@ export var object_prototype = Object.prototype;
export var array_prototype = Array.prototype;
export var get_prototype_of = Object.getPrototypeOf;
export var is_extensible = Object.isExtensible;
export var has_own_property = Object.prototype.hasOwnProperty;
/**
* @param {any} thing
@ -117,3 +118,27 @@ export function to_array(value, n) {
return array;
}
/**
* @param {Record<string | symbol, unknown>} obj
* @param {Array<string | symbol>} keys
* @returns {Record<string | symbol, unknown>}
*/
export function exclude_from_object(obj, keys) {
/** @type {Record<string | symbol, unknown>} */
var result = {};
for (var key in obj) {
if (!keys.includes(key)) {
result[key] = obj[key];
}
}
for (var symbol of Object.getOwnPropertySymbols(obj)) {
if (Object.propertyIsEnumerable.call(obj, symbol) && !keys.includes(symbol)) {
result[symbol] = obj[symbol];
}
}
return result;
}

@ -119,7 +119,8 @@ class Svelte4Component {
props,
context: options.context,
intro: options.intro ?? false,
recover: options.recover
recover: options.recover,
transformError: options.transformError
});
// We don't flushSync for custom element wrappers or if the user doesn't want it,

@ -25,10 +25,10 @@ export { createClassComponent };
*/
export function asClassComponent(component) {
const component_constructor = as_class_component(component);
/** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map<any, any>; csp?: Csp }) => LegacyRenderResult & PromiseLike<LegacyRenderResult> } */
const _render = (props, { context, csp } = {}) => {
/** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map<any, any>; csp?: Csp; transformError?: (error: unknown) => unknown }) => LegacyRenderResult & PromiseLike<LegacyRenderResult> } */
const _render = (props, { context, csp, transformError } = {}) => {
// @ts-expect-error the typings are off, but this will work if the component is compiled in SSR mode
const result = render(component, { props, context, csp });
const result = render(component, { props, context, csp, transformError });
const munged = Object.defineProperties(
/** @type {LegacyRenderResult & PromiseLike<LegacyRenderResult>} */ ({}),

@ -17,6 +17,7 @@ export function render<
context?: Map<any, any>;
idPrefix?: string;
csp?: Csp;
transformError?: (error: unknown) => unknown | Promise<unknown>;
}
]
: [
@ -26,6 +27,7 @@ export function render<
context?: Map<any, any>;
idPrefix?: string;
csp?: Csp;
transformError?: (error: unknown) => unknown | Promise<unknown>;
}
]
): RenderOutput;

@ -480,6 +480,19 @@ export function is_raw_text_element(name) {
return RAW_TEXT_ELEMENTS.includes(/** @type {typeof RAW_TEXT_ELEMENTS[number]} */ (name));
}
// Matches valid HTML/SVG/MathML element names and custom element names.
// https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
//
// Standard elements: ASCII alpha start, followed by ASCII alphanumerics.
// Custom elements: ASCII alpha start, followed by any mix of PCENChar (which
// includes ASCII alphanumerics, `-`, `.`, `_`, and specified Unicode ranges),
// with at least one hyphen required somewhere after the first character.
//
// Rejects strings containing whitespace, quotes, angle brackets, slashes, equals,
// or other characters that could break out of a tag-name token and enable markup injection.
export const REGEX_VALID_TAG_NAME =
/^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9.\-_\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\u{10000}-\u{EFFFF}]+)*$/u;
/**
* Prevent devtools trying to make `location` a clickable link by inserting a zero-width space
* @template {string | undefined} T

@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
export const VERSION = '5.51.0';
export const VERSION = '5.53.5';
export const PUBLIC_VERSION = '5';

@ -4,7 +4,7 @@ export default test({
error: {
code: 'attribute_invalid_sequence_expression',
message:
'Sequence expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses',
'Comma-separated expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses',
position: [124, 131]
}
});

@ -4,7 +4,7 @@ export default test({
error: {
code: 'attribute_invalid_sequence_expression',
message:
'Sequence expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses',
'Comma-separated expressions are not allowed as attribute/directive values in runes mode, unless wrapped in parentheses',
position: [163, 170]
}
});

@ -0,0 +1,11 @@
form[method="get"].svelte-xyz h1:where(.svelte-xyz) {
color: red;
}
form[method="post"].svelte-xyz h1:where(.svelte-xyz) {
color: blue;
}
input[type="text"].svelte-xyz {
color: green;
}

@ -0,0 +1 @@
<form class="svelte-xyz" method="GET"><h1 class="svelte-xyz">Hello</h1></form> <form class="svelte-xyz" method="POST"><h1 class="svelte-xyz">World</h1></form> <input class="svelte-xyz" type="Text" />

@ -0,0 +1,23 @@
<form method="GET">
<h1>Hello</h1>
</form>
<form method="POST">
<h1>World</h1>
</form>
<input type="Text" />
<style>
form[method="get"] h1 {
color: red;
}
form[method="post"] h1 {
color: blue;
}
input[type="text"] {
color: green;
}
</style>

@ -880,5 +880,423 @@
],
"sourceType": "module"
}
}
},
"_comments": [
{
"type": "Line",
"value": " a leading comment",
"start": 10,
"end": 30,
"loc": {
"start": {
"line": 2,
"column": 1
},
"end": {
"line": 2,
"column": 21
}
}
},
{
"type": "Line",
"value": " a trailing comment",
"start": 45,
"end": 66,
"loc": {
"start": {
"line": 3,
"column": 14
},
"end": {
"line": 3,
"column": 35
}
}
},
{
"type": "Block",
"value": "* a comment ",
"start": 83,
"end": 99,
"loc": {
"start": {
"line": 6,
"column": 1
},
"end": {
"line": 6,
"column": 17
}
}
},
{
"type": "Line",
"value": " trailing",
"start": 125,
"end": 136,
"loc": {
"start": {
"line": 8,
"column": 7
},
"end": {
"line": 8,
"column": 18
}
}
},
{
"type": "Block",
"value": " leading comment 1 ",
"start": 139,
"end": 162,
"loc": {
"start": {
"line": 9,
"column": 2
},
"end": {
"line": 9,
"column": 25
}
}
},
{
"type": "Block",
"value": " leading comment 2 ",
"start": 165,
"end": 188,
"loc": {
"start": {
"line": 10,
"column": 2
},
"end": {
"line": 10,
"column": 25
}
}
},
{
"type": "Block",
"value": " leading comment 3 ",
"start": 191,
"end": 214,
"loc": {
"start": {
"line": 11,
"column": 2
},
"end": {
"line": 11,
"column": 25
}
}
},
{
"type": "Block",
"value": " trailing comment 1 ",
"start": 224,
"end": 248,
"loc": {
"start": {
"line": 13,
"column": 2
},
"end": {
"line": 13,
"column": 26
}
}
},
{
"type": "Block",
"value": " trailing comment 2 ",
"start": 251,
"end": 275,
"loc": {
"start": {
"line": 14,
"column": 2
},
"end": {
"line": 14,
"column": 26
}
}
},
{
"type": "Block",
"value": " trailing comment 3 ",
"start": 278,
"end": 302,
"loc": {
"start": {
"line": 15,
"column": 2
},
"end": {
"line": 15,
"column": 26
}
}
},
{
"type": "Line",
"value": " leading comment 1",
"start": 326,
"end": 346,
"loc": {
"start": {
"line": 19,
"column": 2
},
"end": {
"line": 19,
"column": 22
}
}
},
{
"type": "Line",
"value": " leading comment 2",
"start": 349,
"end": 369,
"loc": {
"start": {
"line": 20,
"column": 2
},
"end": {
"line": 20,
"column": 22
}
}
},
{
"type": "Line",
"value": " trailing comment 1",
"start": 375,
"end": 396,
"loc": {
"start": {
"line": 21,
"column": 5
},
"end": {
"line": 21,
"column": 26
}
}
},
{
"type": "Block",
"value": " trailing comment 2 ",
"start": 399,
"end": 423,
"loc": {
"start": {
"line": 22,
"column": 2
},
"end": {
"line": 22,
"column": 26
}
}
},
{
"type": "Line",
"value": " leading comment 1",
"start": 449,
"end": 469,
"loc": {
"start": {
"line": 26,
"column": 2
},
"end": {
"line": 26,
"column": 22
}
}
},
{
"type": "Line",
"value": " leading comment 2",
"start": 472,
"end": 492,
"loc": {
"start": {
"line": 27,
"column": 2
},
"end": {
"line": 27,
"column": 22
}
}
},
{
"type": "Line",
"value": " trailing comment 1",
"start": 501,
"end": 522,
"loc": {
"start": {
"line": 28,
"column": 8
},
"end": {
"line": 28,
"column": 29
}
}
},
{
"type": "Block",
"value": " trailing comment 2 ",
"start": 525,
"end": 549,
"loc": {
"start": {
"line": 29,
"column": 2
},
"end": {
"line": 29,
"column": 26
}
}
},
{
"type": "Line",
"value": " comment",
"start": 584,
"end": 594,
"loc": {
"start": {
"line": 34,
"column": 11
},
"end": {
"line": 34,
"column": 21
}
}
},
{
"type": "Block",
"value": " another comment ",
"start": 606,
"end": 627,
"loc": {
"start": {
"line": 36,
"column": 2
},
"end": {
"line": 36,
"column": 23
}
}
},
{
"type": "Line",
"value": " a trailing comment",
"start": 636,
"end": 657,
"loc": {
"start": {
"line": 37,
"column": 8
},
"end": {
"line": 37,
"column": 29
}
}
},
{
"type": "Block",
"value": " trailing block comment ",
"start": 660,
"end": 688,
"loc": {
"start": {
"line": 38,
"column": 2
},
"end": {
"line": 38,
"column": 30
}
}
},
{
"type": "Block",
"value": " leading block comment ",
"start": 696,
"end": 723,
"loc": {
"start": {
"line": 41,
"column": 1
},
"end": {
"line": 41,
"column": 28
}
}
},
{
"type": "Line",
"value": " leading line comment",
"start": 739,
"end": 762,
"loc": {
"start": {
"line": 43,
"column": 2
},
"end": {
"line": 43,
"column": 25
}
}
},
{
"type": "Line",
"value": " trailing line comment",
"start": 770,
"end": 794,
"loc": {
"start": {
"line": 44,
"column": 7
},
"end": {
"line": 44,
"column": 31
}
}
},
{
"type": "Block",
"value": " trailing block comment ",
"start": 796,
"end": 824,
"loc": {
"start": {
"line": 45,
"column": 1
},
"end": {
"line": 45,
"column": 29
}
}
}
]
}

@ -51,5 +51,23 @@
}
]
}
}
},
"_comments": [
{
"type": "Line",
"value": " TODO write some code",
"start": 10,
"end": 33,
"loc": {
"start": {
"line": 2,
"column": 1
},
"end": {
"line": 2,
"column": 24
}
}
}
]
}

@ -0,0 +1,22 @@
<div
data-one="1"
// this is a line comment
data-two="2"
/* this is a
m
u
l
t
i
l
i
n
e
comment
*/
// oh look another line comment
// (two, in fact)
data-three="3"
></div>
<span /* inline */ /* another inline */ data-one="1"></span>

@ -0,0 +1,286 @@
{
"css": null,
"js": [],
"start": 0,
"end": 261,
"type": "Root",
"fragment": {
"type": "Fragment",
"nodes": [
{
"type": "RegularElement",
"start": 0,
"end": 199,
"name": "div",
"name_loc": {
"start": {
"line": 1,
"column": 1,
"character": 1
},
"end": {
"line": 1,
"column": 4,
"character": 4
}
},
"attributes": [
{
"type": "Attribute",
"start": 6,
"end": 18,
"name": "data-one",
"name_loc": {
"start": {
"line": 2,
"column": 1,
"character": 6
},
"end": {
"line": 2,
"column": 9,
"character": 14
}
},
"value": [
{
"start": 16,
"end": 17,
"type": "Text",
"raw": "1",
"data": "1"
}
]
},
{
"type": "Attribute",
"start": 47,
"end": 59,
"name": "data-two",
"name_loc": {
"start": {
"line": 4,
"column": 1,
"character": 47
},
"end": {
"line": 4,
"column": 9,
"character": 55
}
},
"value": [
{
"start": 57,
"end": 58,
"type": "Text",
"raw": "2",
"data": "2"
}
]
},
{
"type": "Attribute",
"start": 177,
"end": 191,
"name": "data-three",
"name_loc": {
"start": {
"line": 19,
"column": 1,
"character": 177
},
"end": {
"line": 19,
"column": 11,
"character": 187
}
},
"value": [
{
"start": 189,
"end": 190,
"type": "Text",
"raw": "3",
"data": "3"
}
]
}
],
"fragment": {
"type": "Fragment",
"nodes": []
}
},
{
"type": "Text",
"start": 199,
"end": 201,
"raw": "\n\n",
"data": "\n\n"
},
{
"type": "RegularElement",
"start": 201,
"end": 261,
"name": "span",
"name_loc": {
"start": {
"line": 22,
"column": 1,
"character": 202
},
"end": {
"line": 22,
"column": 5,
"character": 206
}
},
"attributes": [
{
"type": "Attribute",
"start": 241,
"end": 253,
"name": "data-one",
"name_loc": {
"start": {
"line": 22,
"column": 40,
"character": 241
},
"end": {
"line": 22,
"column": 48,
"character": 249
}
},
"value": [
{
"start": 251,
"end": 252,
"type": "Text",
"raw": "1",
"data": "1"
}
]
}
],
"fragment": {
"type": "Fragment",
"nodes": []
}
}
]
},
"options": null,
"comments": [
{
"type": "Line",
"start": 20,
"end": 45,
"value": " this is a line comment",
"loc": {
"start": {
"line": 3,
"column": 1,
"character": 20
},
"end": {
"line": 3,
"column": 26,
"character": 45
}
}
},
{
"type": "Block",
"start": 61,
"end": 123,
"value": " this is a\n\t\tm\n\t\tu\n\t\tl\n\t\tt\n\t\ti\n\t\tl\n\t\ti\n\t\tn\n\t\te\n\t\tcomment\n\t",
"loc": {
"start": {
"line": 5,
"column": 1,
"character": 61
},
"end": {
"line": 16,
"column": 3,
"character": 123
}
}
},
{
"type": "Line",
"start": 125,
"end": 156,
"value": " oh look another line comment",
"loc": {
"start": {
"line": 17,
"column": 1,
"character": 125
},
"end": {
"line": 17,
"column": 32,
"character": 156
}
}
},
{
"type": "Line",
"start": 158,
"end": 175,
"value": " (two, in fact)",
"loc": {
"start": {
"line": 18,
"column": 1,
"character": 158
},
"end": {
"line": 18,
"column": 18,
"character": 175
}
}
},
{
"type": "Block",
"start": 207,
"end": 219,
"value": " inline ",
"loc": {
"start": {
"line": 22,
"column": 6,
"character": 207
},
"end": {
"line": 22,
"column": 18,
"character": 219
}
}
},
{
"type": "Block",
"start": 220,
"end": 240,
"value": " another inline ",
"loc": {
"start": {
"line": 22,
"column": 19,
"character": 220
},
"end": {
"line": 22,
"column": 39,
"character": 240
}
}
}
]
}

@ -25,8 +25,6 @@ const { test, run } = suite<ParserTest>(async (config, cwd) => {
)
);
delete actual.comments;
// run `UPDATE_SNAPSHOTS=true pnpm test parser` to update parser tests
if (process.env.UPDATE_SNAPSHOTS) {
fs.writeFileSync(`${cwd}/output.json`, JSON.stringify(actual, null, '\t') + '\n');
@ -34,6 +32,11 @@ const { test, run } = suite<ParserTest>(async (config, cwd) => {
fs.writeFileSync(`${cwd}/_actual.json`, JSON.stringify(actual, null, '\t'));
const expected = try_load_json(`${cwd}/output.json`);
if (!expected.comments) {
delete actual.comments;
}
assert.deepEqual(actual, expected);
}
@ -50,7 +53,9 @@ const { test, run } = suite<ParserTest>(async (config, cwd) => {
fs.writeFileSync(`${cwd}/_actual.svelte`, printed.code);
delete reparsed.comments;
if (!actual.comments) {
delete reparsed.comments;
}
assert.deepEqual(clean(actual), clean(reparsed));
}

@ -13,6 +13,7 @@ import type { CompileOptions } from '#compiler';
import { suite_with_variants, type BaseTest } from '../suite.js';
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;
@ -101,6 +102,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
expect_unhandled_rejections?: boolean;
withoutNormalizeHtml?: boolean | 'only-strip-comments';
recover?: boolean;
transformError?: (error: unknown) => unknown;
}
declare global {
@ -358,12 +360,17 @@ async function run_test_variant(
let snapshot = undefined;
if (variant === 'hydrate' || variant === 'ssr' || variant === 'async-ssr') {
if (ssr_context !== null) {
throw new Error('ssr_context was not cleared');
}
config.before_test?.();
// ssr into target
const SsrSvelteComponent = (await import(`${cwd}/_output/server/main.svelte.js`)).default;
const render_result = render(SsrSvelteComponent, {
props: config.server_props ?? config.props ?? {},
idPrefix: config.id_prefix
idPrefix: config.id_prefix,
transformError: config.transformError
});
const rendered =
variant === 'async-ssr' || (variant === 'hydrate' && compileOptions.experimental?.async)
@ -387,6 +394,10 @@ async function run_test_variant(
snapshot = config.snapshot(target);
}
}
if (ssr_context !== null) {
throw new Error('ssr_context was not cleared');
}
} else {
target.innerHTML = '';
}
@ -462,7 +473,8 @@ async function run_test_variant(
target,
props,
intro: config.intro,
recover: config.recover ?? false
recover: config.recover ?? false,
transformError: config.transformError
});
}
} else {

@ -0,0 +1,82 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
html: `
<button>a</button>
<button>b</button>
<button>resolve a</button>
<button>resolve b</button>
<p>pending a</p>
`,
async test({ assert, target }) {
const [a, b, resolve_a, resolve_b] = target.querySelectorAll('button');
resolve_a.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>resolve a</button>
<button>resolve b</button>
<p>page a</p>
`
);
b.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>resolve a</button>
<button>resolve b</button>
<p>pending b</p>
`
);
a.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>resolve a</button>
<button>resolve b</button>
<p>pending a</p>
`
);
resolve_b.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>resolve a</button>
<button>resolve b</button>
<p>pending a</p>
`
);
resolve_a.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>resolve a</button>
<button>resolve b</button>
<p>page a</p>
`
);
await new Promise((r) => setTimeout(r, 100));
}
});

@ -0,0 +1,52 @@
<script>
let page = $state('a');
/** @type {Array<() => void>} */
const a = [];
/** @type {Array<() => void>} */
const b = [];
function gate(next) {
const deferred = Promise.withResolvers();
if (next === 'a') a.push(deferred.resolve);
else b.push(deferred.resolve);
return deferred.promise;
}
function nav(next) {
page = next;
}
const to_render = $derived(page === 'a' ? snippet_a : snippet_b);
</script>
<button onclick={() => nav('a')}>a</button>
<button onclick={() => nav('b')}>b</button>
<button onclick={() => a.shift()?.()}>resolve a</button>
<button onclick={() => b.shift()?.()}>resolve b</button>
{#snippet snippet_a()}
<svelte:boundary>
{@const _a = await gate('a')}
<p>page a</p>
{#snippet pending()}
<p>pending a</p>
{/snippet}
</svelte:boundary>
{/snippet}
{#snippet snippet_b()}
<svelte:boundary>
{@const _b = await gate('b')}
<p>page b</p>
{#snippet pending()}
<p>pending b</p>
{/snippet}
</svelte:boundary>
{/snippet}
{@render to_render()}

@ -0,0 +1,38 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
html: `
<button>increment</button>
<button>shift</button>
<p>0</p>
`,
async test({ assert, target }) {
const [increment, shift] = target.querySelectorAll('button');
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>1</p>
`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>resolved</p>
`
);
}
});

@ -0,0 +1,31 @@
<script>
let resolvers = [];
function push(value) {
const deferred = Promise.withResolvers();
resolvers.push(() => deferred.resolve(value));
return deferred.promise;
}
function shift() {
resolvers.shift()?.();
}
let count = $state(0);
</script>
<button onclick={() => count += 1}>
increment
</button>
<button onclick={shift}>
shift
</button>
<svelte:boundary>
<p>{await push('resolved')}</p>
{#snippet pending()}
<p>{count}</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,12 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const p = target.querySelector('p');
assert.equal(p?.innerHTML, 'hello');
assert.equal(window.document.title, 'hello');
}
});

@ -0,0 +1,10 @@
<script>
let promise = Promise.resolve('hello');
const value = $derived(await promise);
</script>
<svelte:head>
<title>{value}</title>
</svelte:head>
<p>{value}</p>

@ -0,0 +1,15 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['hydrate', 'async-server', 'client'],
ssrHtml: '<p>caught: error (hello)</p>',
transformError: () => {
return 'error';
},
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, '<p>caught: error (hello)</p>');
}
});

@ -0,0 +1,12 @@
<script>
import { get } from "./main.svelte";
let { error } = $props();
const context = get()
</script>
{#if error}
<p>caught: {await error} ({context})</p>
{:else}
{await Promise.reject('catch me')}
{/if}

@ -0,0 +1,19 @@
<script module>
import { createContext } from "svelte";
import Child from "./child.svelte";
const [ get, set ] = createContext();
export {get};
</script>
<script>
set('hello');
</script>
<svelte:boundary>
{#snippet failed(error)}
<Child {error} />
{/snippet}
<Child />
</svelte:boundary>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save