Merge branch 'main' into style-object-syntax

pull/18176/head
Mathias Picker 3 months ago committed by GitHub
commit 08135a7c97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: inline primitive constants in attribute values during SSR

@ -18,12 +18,12 @@ jobs:
strategy: strategy:
matrix: matrix:
include: include:
- node-version: 18 # Vitest 4 requires Node 20+, so tests run on 20/22/24. The published
# Svelte package still supports Node >=18 (see packages/svelte/package.json).
- node-version: 20
os: windows-latest os: windows-latest
- node-version: 18 - node-version: 20
os: macOS-latest os: macOS-latest
- node-version: 18
os: ubuntu-latest
- node-version: 20 - node-version: 20
os: ubuntu-latest os: ubuntu-latest
- node-version: 22 - node-version: 22
@ -80,7 +80,7 @@ jobs:
Lint: Lint:
permissions: {} permissions: {}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4
@ -98,6 +98,8 @@ jobs:
- name: build and check generated types - name: build and check generated types
if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail if: (${{ success() }} || ${{ failure() }}) # ensures this step runs even if previous steps fail
run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); } run: pnpm build && { [ "`git status --porcelain=v1`" == "" ] || (echo "Generated types have changed — please regenerate types locally with `cd packages/svelte && pnpm generate:types` and commit the changes after you have reviewed them"; git diff; exit 1); }
- name: check browser-support docs page is up to date
run: '{ [ "`git status --porcelain=v1 documentation/docs/07-misc/.generated/`" == "" ] || (echo "The browser-support docs page is out of date — please regenerate it locally with \`cd packages/svelte && pnpm generate:browser-support\` and commit the changes"; git diff documentation/docs/07-misc/.generated/; exit 1); }'
Benchmarks: Benchmarks:
permissions: {} permissions: {}
runs-on: ubuntu-latest runs-on: ubuntu-latest

1
.gitignore vendored

@ -22,6 +22,7 @@ coverage
.DS_Store .DS_Store
tmp tmp
packages/svelte/scripts/_baseline/
benchmarking/.profiles benchmarking/.profiles
benchmarking/compare/.results benchmarking/compare/.results

@ -8,6 +8,7 @@ packages/**/config/*.js
# packages/svelte # packages/svelte
packages/svelte/messages/**/*.md packages/svelte/messages/**/*.md
packages/svelte/scripts/_bundle.js packages/svelte/scripts/_bundle.js
packages/svelte/scripts/_baseline/*.ts
packages/svelte/src/compiler/errors.js packages/svelte/src/compiler/errors.js
packages/svelte/src/compiler/warnings.js packages/svelte/src/compiler/warnings.js
packages/svelte/src/internal/client/errors.js packages/svelte/src/internal/client/errors.js

@ -4,6 +4,8 @@ This guide is for AI coding agents working in the Svelte monorepo.
**Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here. **Important:** Read and follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) as well - it contains essential information about testing, code structure, and contribution guidelines that applies here.
When submitting a PR, you **MUST** read [`PULL_REQUEST_TEMPLATE.md`](./.github/PULL_REQUEST_TEMPLATE.md) and fill it out correctly. **DO NOT** submit a PR without running the full test suite.
## Quick Reference ## Quick Reference
If asked to do a performance investigation, use the `performance-investigation` skill. If asked to do a performance investigation, use the `performance-investigation` skill.

@ -43,7 +43,7 @@ The maintainers meet on the final Saturday of each month. While these meetings a
### Prioritization ### Prioritization
We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active ones repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch. We do our best to review PRs and RFCs as they are sent, but it is difficult to keep up. We welcome help in reviewing PRs, RFCs, and issues. If an item aligns with the current priority on our [roadmap](https://svelte.dev/roadmap), it is more likely to be reviewed quickly. PRs to the most important and active repositories get reviewed more quickly while PRs to smaller inactive repos may sit for a bit before we periodically come by and review the pending PRs in a batch.
## Bugs ## Bugs

@ -106,7 +106,7 @@ In case you just want to render something `n` times, you can omit the `as` part:
.chess-board { .chess-board {
display: grid; display: grid;
grid-template-columns: repeat(8, 1fr); grid-template-columns: repeat(8, 1fr);
rows: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr);
border: 1px solid black; border: 1px solid black;
aspect-ratio: 1; aspect-ratio: 1;

@ -2,6 +2,8 @@
title: {@const ...} title: {@const ...}
--- ---
> [!NOTE] `{@const x = y}` is legacy syntax — use [`{const x = $derived(y)}`](declaration-tags) instead
The `{@const ...}` tag defines a local constant. The `{@const ...}` tag defines a local constant.
```svelte ```svelte

@ -0,0 +1,72 @@
---
title: {let/const ...}
---
Declaration tags define local variables inside markup with `const` or `let`:
<!-- codeblock:start {"title":"Declaration tags"} -->
```svelte
<!--- file: App.svelte --->
<script>
let boxes = [{ width: 10, height: 10 }, { width: 15, height: 15 }];
</script>
{#each boxes as box}
{const area = box.width * box.height}
{const label = `${box.width} ⨉ ${box.height} = ${area}`}
<p>{label}</p>
{/each}
```
<!-- codeblock:end -->
> [!NOTE] Declaration tags are available since Svelte 5.56.
> [!NOTE] The [`{@const ...}`](@const) syntax is considered legacy — use declaration tags instead.
When values should be reactive, you can use `$state` and `$derived`:
<!-- codeblock:start {"title":"Reactive declaration tags"} -->
```svelte
<!--- file: App.svelte --->
<script>
let user = $state({ name: 'Svelte' });
let editing = $state(false);
</script>
<p>Hello {user.name}</p>
<button onclick={() => editing = true}>edit name</button>
{#if editing}
{let name = $state(user.name)}
{const greeting = $derived(`Hello ${name}`)}
<hr>
<input bind:value={name} />
<p>{greeting}</p>
<button onclick={() => {
user.name = name;
editing = false;
}}>save</button>
{/if}
```
<!-- codeblock:end -->
Declaration tags can be used anywhere inside the component. They can reference values declared outside themselves (for example in the `<script>` tag or in `{#each ...}` blocks) and are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings):
<!-- codeblock:start {"title":"Declaration tag scope"} -->
```svelte
<!--- file: App.svelte --->
{const hello = 'hello'}
{hello} <!-- 'hello' -->
<div>
{const hello = 'hi'}
{hello} <!-- 'hi' -->
<div>
{hello} <!-- 'hi' -->
</div>
</div>
{hello} <!-- 'hello' -->
```
<!-- codeblock:end -->

@ -0,0 +1,7 @@
<!-- generated in ../../../../../packages/svelte/scripts/generate-browser-support.ts. do not edit -->
| Feature | Chrome/Edge | Firefox | Safari |
| - | - | - | - |
| [`$state.snapshot`](/docs/svelte/$state#$state.snapshot) | 98 | 94 | 15.4 |
| [`bind:devicePixelContentBoxSize`](/docs/svelte/bind#Dimensions) | <span style="color: var(--sk-fg-4)"></span> | 93 | not supported |
| [`flip` from `svelte/animate`](/docs/svelte/svelte-animate#flip) | <span style="color: var(--sk-fg-4)"></span> | 126 | <span style="color: var(--sk-fg-4)"></span> |

@ -0,0 +1,15 @@
<!-- generated in ../../../../../packages/svelte/scripts/generate-browser-support.ts. do not edit -->
| Browser | Minimum version |
| - | - |
| Chrome/Edge | 87 |
| Firefox | 83 |
| Safari | 14 |
| Opera | 73 |
| Opera (Android) | 62 |
| Samsung Internet | 14.0 |
| Android WebView | 87 |
| Internet Explorer | not supported |
> [!NOTE] This equates to a <a href="https://web-platform-dx.github.io/baseline/">Baseline</a> target of 2020.

@ -0,0 +1,15 @@
---
title: Browser support
---
The table below shows the minimum browser versions Svelte is expected to work in, derived from the browser APIs used by Svelte's internal code.
@include .generated/browser-support.md
This table only covers Svelte itself. It does not include [SvelteKit](/docs/kit), other Svelte libraries, or your own code.
## Exceptions
A few Svelte features require a higher minimum browser version. You'll only need to take the following table into consideration if you use these specific features.
@include .generated/browser-support-features.md

@ -88,6 +88,10 @@ Effect cannot be created inside a `$derived` value that was not itself created i
`%rune%` can only be used inside an effect (e.g. during component initialisation) `%rune%` can only be used inside an effect (e.g. during component initialisation)
``` ```
Effects can only be created while a parent effect is running. This means that they cannot, for example, be created inside an event handler or after an `await` expression (unless the `await` occurs directly inside a component's `<script>` tag, and not inside an async function).
In very rare cases, it is appropriate to use [`$effect.root`]($effect#$effect.root) so that you can create effects outside the normal component lifecycle.
### effect_pending_outside_reaction ### effect_pending_outside_reaction
``` ```

@ -399,6 +399,18 @@ Invalid selector
Cannot declare a variable with the same name as an import from `<script module>` Cannot declare a variable with the same name as an import from `<script module>`
``` ```
### declaration_tag_invalid_type
```
Declaration tags must be `let` or `const` declarations
```
### declaration_tag_no_legacy_mode
```
Declaration tags cannot be used in legacy mode
```
### derived_invalid_export ### derived_invalid_export
``` ```

@ -62,7 +62,7 @@ Enforce that `autofocus` is not used on elements. Autofocusing elements can caus
### a11y_click_events_have_key_events ### a11y_click_events_have_key_events
``` ```
Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate Visible, non-interactive element `<%element%>` with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate
``` ```
Enforce that visible, non-interactive elements with an `onclick` event are accompanied by a keyboard event handler. Enforce that visible, non-interactive elements with an `onclick` event are accompanied by a keyboard event handler.
@ -842,7 +842,7 @@ Reassignments of module-level declarations will not cause reactive statements to
### script_unknown_attribute ### script_unknown_attribute
``` ```
Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it
``` ```
### slot_element_deprecated ### slot_element_deprecated

@ -90,6 +90,7 @@ export default [
'**/tests', '**/tests',
'packages/svelte/scripts/process-messages/templates/*.js', 'packages/svelte/scripts/process-messages/templates/*.js',
'packages/svelte/scripts/_bundle.js', 'packages/svelte/scripts/_bundle.js',
'packages/svelte/scripts/_baseline/**',
'packages/svelte/src/compiler/errors.js', 'packages/svelte/src/compiler/errors.js',
'packages/svelte/src/internal/client/errors.js', 'packages/svelte/src/internal/client/errors.js',
'packages/svelte/src/internal/client/warnings.js', 'packages/svelte/src/internal/client/warnings.js',

@ -32,18 +32,18 @@
"@svitejs/changesets-changelog-github-compact": "^1.1.0", "@svitejs/changesets-changelog-github-compact": "^1.1.0",
"@types/node": "^20.11.5", "@types/node": "^20.11.5",
"@types/picomatch": "^4.0.2", "@types/picomatch": "^4.0.2",
"@vitest/coverage-v8": "^2.1.9", "@vitest/coverage-v8": "^4.1.7",
"eslint": "^10.0.0", "eslint": "^10.0.0",
"eslint-plugin-lube": "^0.5.1", "eslint-plugin-lube": "^0.5.1",
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.15.0",
"jsdom": "25.0.1", "jsdom": "25.0.1",
"playwright": "^1.58.0", "playwright": "^1.60.0",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "workspace:^", "svelte": "workspace:^",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"typescript-eslint": "^8.56.0", "typescript-eslint": "^8.56.0",
"v8-natives": "^1.2.5", "v8-natives": "^1.2.5",
"vitest": "^2.1.9" "vitest": "^4.1.7"
} }
} }

@ -1,5 +1,115 @@
# svelte # svelte
## 5.56.3
### Patch Changes
- fix: ignore errors that occur in destroyed effects ([#18384](https://github.com/sveltejs/svelte/pull/18384))
- fix: type BigInts in `$state.snapshot(...)` return values ([#18388](https://github.com/sveltejs/svelte/pull/18388))
## 5.56.2
### Patch Changes
- fix: properly track effect end node for async sibling component ([#18371](https://github.com/sveltejs/svelte/pull/18371))
- fix: prevent false-positive reactivity loss warning ([#18373](https://github.com/sveltejs/svelte/pull/18373))
- chore: bump esrap dependency ([#18372](https://github.com/sveltejs/svelte/pull/18372))
- fix: ignore declaration tags for animation directive ([#18366](https://github.com/sveltejs/svelte/pull/18366))
- fix: reject pending async deriveds on discard ([#18308](https://github.com/sveltejs/svelte/pull/18308))
## 5.56.1
### Patch Changes
- fix: error at compile time on duplicate snippet/declaration tag definitions ([#18351](https://github.com/sveltejs/svelte/pull/18351))
- fix: parse declaration tag contents more robustly ([#18353](https://github.com/sveltejs/svelte/pull/18353))
- fix: correctly transform references to earlier declarators in a declaration tag (e.g. `{let a = $state(0), b = $derived(a * 2)}`) ([#18348](https://github.com/sveltejs/svelte/pull/18348))
- fix: avoid spurious `state_referenced_locally` warnings for `$derived` declarations in declaration tags ([#18348](https://github.com/sveltejs/svelte/pull/18348))
- fix: tolerate whitespace before `let`/`const` in declaration tags ([#18348](https://github.com/sveltejs/svelte/pull/18348))
- fix: prevent infinite loop when a tag's expression ends with a trailing `/` at the end of the input ([#18350](https://github.com/sveltejs/svelte/pull/18350))
- fix: more robust parsing of declaration tags with regards to `type` ([#18330](https://github.com/sveltejs/svelte/pull/18330))
- fix: preserve newlines in spread input values when the `type` attribute is applied after `value` ([#18345](https://github.com/sveltejs/svelte/pull/18345))
- fix: update `SvelteURLSearchParams` when setting duplicate keys to the same joined value ([#18336](https://github.com/sveltejs/svelte/pull/18336))
- fix: check references for blockers on server, too ([#18352](https://github.com/sveltejs/svelte/pull/18352))
## 5.56.0
### Minor Changes
- feat: allow declarations in the template ([#18282](https://github.com/sveltejs/svelte/pull/18282))
### Patch Changes
- perf: use `createElement` instead of `createElementNS` for HTML elements ([#18262](https://github.com/sveltejs/svelte/pull/18262))
- perf: store `current_sources` as a `Set` for O(1) membership checks ([#18278](https://github.com/sveltejs/svelte/pull/18278))
- perf: deduplicate identical hoisted templates within a component ([#18320](https://github.com/sveltejs/svelte/pull/18320))
- perf: hoist `rest_props` exclude list as a module-scope `Set` ([#18252](https://github.com/sveltejs/svelte/pull/18252))
## 5.55.10
### Patch Changes
- fix: unlink errored and otherwise finished batch ([#18264](https://github.com/sveltejs/svelte/pull/18264))
- perf: walk composedPath() directly in delegated event propagation ([#18268](https://github.com/sveltejs/svelte/pull/18268))
- fix: transfer effects when merging batches ([#18254](https://github.com/sveltejs/svelte/pull/18254))
- fix: allow `$derived(await ...)` in disconnected effect roots ([#18273](https://github.com/sveltejs/svelte/pull/18273))
- fix: remove temporary raw-text hydration markers ([#18269](https://github.com/sveltejs/svelte/pull/18269))
- fix: propagate async `@const` blockers through closure references so template expressions like `{(() => host)()}` correctly wait for the awaited value ([#18309](https://github.com/sveltejs/svelte/pull/18309))
- fix: properly unlink batches ([#18298](https://github.com/sveltejs/svelte/pull/18298))
- fix: settle discarded batch ([#18290](https://github.com/sveltejs/svelte/pull/18290))
- fix: declare `let:` directives before `{@const}` declarations on slotted elements ([#18271](https://github.com/sveltejs/svelte/pull/18271))
- fix: resume outro-ed branches if they were kept around ([#18291](https://github.com/sveltejs/svelte/pull/18291))
- fix: avoid waterfall-warning when async resolves to same value ([#18297](https://github.com/sveltejs/svelte/pull/18297))
- fix: correctly coordinate component-level effects inside async blocks ([#18260](https://github.com/sveltejs/svelte/pull/18260))
- fix: make unnecessary commit work less likely ([#18263](https://github.com/sveltejs/svelte/pull/18263))
- chore: add tag name to `a11y_click_events_have_key_events` warning ([#18272](https://github.com/sveltejs/svelte/pull/18272))
- fix: catch rejected promises while merging/committing ([#18266](https://github.com/sveltejs/svelte/pull/18266))
## 5.55.9
### Patch Changes
- fix: don't unset batch when calling `{#await ...}` promise ([#18243](https://github.com/sveltejs/svelte/pull/18243))
- fix: promise-ify `{#await await ...}` expressions on the server and correctly hydrate them on the client ([#18243](https://github.com/sveltejs/svelte/pull/18243))
- fix: deduplicate dependencies that are added outside the init/update cycle ([#18243](https://github.com/sveltejs/svelte/pull/18243))
- fix: avoid false-positive batch invariant error ([#18246](https://github.com/sveltejs/svelte/pull/18246))
- fix: inline primitive constants in attribute values during SSR ([#18232](https://github.com/sveltejs/svelte/pull/18232))
## 5.55.8 ## 5.55.8
### Patch Changes ### Patch Changes

@ -60,6 +60,10 @@ The key expression in a keyed each block must return the same value when called
> `%rune%` can only be used inside an effect (e.g. during component initialisation) > `%rune%` can only be used inside an effect (e.g. during component initialisation)
Effects can only be created while a parent effect is running. This means that they cannot, for example, be created inside an event handler or after an `await` expression (unless the `await` occurs directly inside a component's `<script>` tag, and not inside an async function).
In very rare cases, it is appropriate to use [`$effect.root`]($effect#$effect.root) so that you can create effects outside the normal component lifecycle.
## effect_pending_outside_reaction ## effect_pending_outside_reaction
> `$effect.pending()` can only be called inside an effect or derived > `$effect.pending()` can only be called inside an effect or derived

@ -191,6 +191,14 @@ The same applies to components:
> {@debug ...} arguments must be identifiers, not arbitrary expressions > {@debug ...} arguments must be identifiers, not arbitrary expressions
## declaration_tag_invalid_type
> Declaration tags must be `let` or `const` declarations
## declaration_tag_no_legacy_mode
> Declaration tags cannot be used in legacy mode
## directive_invalid_value ## directive_invalid_value
> Directive value must be a JavaScript expression enclosed in curly braces > Directive value must be a JavaScript expression enclosed in curly braces

@ -49,7 +49,7 @@ Enforce that `autofocus` is not used on elements. Autofocusing elements can caus
## a11y_click_events_have_key_events ## a11y_click_events_have_key_events
> Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate > Visible, non-interactive element `<%element%>` with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate
Enforce that visible, non-interactive elements with an `onclick` event are accompanied by a keyboard event handler. Enforce that visible, non-interactive elements with an `onclick` event are accompanied by a keyboard event handler.

@ -107,7 +107,7 @@ This code will work when the component is rendered on the client (which is why t
## script_unknown_attribute ## script_unknown_attribute
> Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it > Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it
## slot_element_deprecated ## slot_element_deprecated

@ -2,7 +2,7 @@
"name": "svelte", "name": "svelte",
"description": "Cybernetically enhanced web apps", "description": "Cybernetically enhanced web apps",
"license": "MIT", "license": "MIT",
"version": "5.55.8", "version": "5.56.3",
"type": "module", "type": "module",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"engines": { "engines": {
@ -138,38 +138,41 @@
"templating" "templating"
], ],
"scripts": { "scripts": {
"build": "rollup -c && pnpm generate", "build": "rollup -c && pnpm generate && node scripts/check-treeshakeability.js",
"dev": "node scripts/process-messages -w & rollup -cw", "dev": "node scripts/process-messages -w & rollup -cw",
"check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc", "check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc",
"check:tsgo": "tsgo --project tsconfig.runtime.json --skipLibCheck && tsgo --skipLibCheck", "check:tsgo": "tsgo --project tsconfig.runtime.json --skipLibCheck && tsgo --skipLibCheck",
"check:watch": "tsc --watch", "check:watch": "tsc --watch",
"generate": "node scripts/process-messages && node ./scripts/generate-types.js", "generate": "node scripts/process-messages && node ./scripts/generate-types.js && pnpm generate:browser-support",
"generate:version": "node ./scripts/generate-version.js", "generate:version": "node ./scripts/generate-version.js",
"generate:types": "node ./scripts/generate-types.js && tsc -p tsconfig.generated.json", "generate:types": "node ./scripts/generate-types.js && tsc -p tsconfig.generated.json",
"prepublishOnly": "pnpm build && node scripts/check-treeshakeability.js", "generate:browser-support": "node ./scripts/generate-browser-support.ts",
"prepublishOnly": "pnpm build",
"knip": "pnpm dlx knip" "knip": "pnpm dlx knip"
}, },
"devDependencies": { "devDependencies": {
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@playwright/test": "^1.58.0", "@playwright/test": "^1.60.0",
"@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-virtual": "^3.0.2", "@rollup/plugin-virtual": "^3.0.2",
"@types/aria-query": "^5.0.4", "@types/aria-query": "^5.0.4",
"@types/node": "^20.11.5", "@types/node": "^20.11.5",
"baseline-browser-mapping": "^2.10.32",
"dts-buddy": "^0.5.5", "dts-buddy": "^0.5.5",
"esbuild": "^0.25.10", "esbuild": "^0.25.10",
"rollup": "^4.59.0", "rollup": "^4.59.0",
"source-map": "^0.7.4", "source-map": "^0.7.4",
"tinyglobby": "^0.2.12", "tinyglobby": "^0.2.12",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vitest": "^2.1.9" "vitest": "^4.1.7",
"web-features": "^3.29.0"
}, },
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7", "@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1", "acorn": "^8.12.1",
@ -178,7 +181,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.8.1", "devalue": "^5.8.1",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.2.4", "esrap": "^2.2.11",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",

@ -0,0 +1,417 @@
import ts from 'typescript';
import { features } from 'web-features';
/**
* Maps compat-key suffixes under `javascript.operators` and
* `javascript.statements` (and a few other `javascript.*` subtrees) to
* detection callbacks. The callback receives a TS AST node and returns
* true if that node represents the operator or statement.
*
* @type {Record<string, (node: ts.Node) => boolean>}
*/
const SYNTAX_PREDICATES = {
nullish_coalescing: (node) =>
ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken,
nullish_coalescing_assignment: (node) =>
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken,
logical_or_assignment: (node) =>
ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken,
logical_and_assignment: (node) =>
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken,
optional_chaining: (node) =>
(ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node) ||
ts.isCallExpression(node)) &&
node.questionDotToken !== undefined,
spread: (node) => ts.isSpreadElement(node) || ts.isSpreadAssignment(node),
destructuring: (node) => ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node),
arrow_functions: (node) => ts.isArrowFunction(node),
try_catch_optional_binding: (node) =>
ts.isCatchClause(node) && node.variableDeclaration === undefined,
async_iteration: (node) => ts.isForOfStatement(node) && node.awaitModifier !== undefined,
for_await: (node) => ts.isForOfStatement(node) && node.awaitModifier !== undefined,
private_class_fields: (node) => ts.isPrivateIdentifier(node),
async_generator_function: (node) =>
(ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isMethodDeclaration(node)) &&
node.asteriskToken !== undefined &&
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false),
generator_function: (node) =>
(ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isMethodDeclaration(node)) &&
node.asteriskToken !== undefined &&
!(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false),
async_function: (node) =>
(ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node)) &&
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false) &&
node.asteriskToken === undefined,
classes: (node) => ts.isClassDeclaration(node) || ts.isClassExpression(node),
let_const: (node) =>
ts.isVariableDeclarationList(node) &&
((node.flags & ts.NodeFlags.Let) !== 0 || (node.flags & ts.NodeFlags.Const) !== 0),
template_literals: (node) =>
ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)
};
/**
* Walk `web-features` once and partition every `compat_features` path
* into the lookup tables the AST walker uses.
*/
function build_detection_maps() {
/** @type {Map<string, string>} identifier name → feature_id */
const globals = new Map();
/** @type {Map<string, Map<string, string>>} type → member → feature_id */
const members = new Map();
/** @type {Map<string, string>} string-literal value → feature_id */
const string_literals = new Map();
/** @type {Array<{ predicate: (n: ts.Node) => boolean, feature_id: string }>} */
const syntax_predicates = [];
const add_member = (
/** @type {string} */ type,
/** @type {string} */ member,
/** @type {string} */ feature_id
) => {
let by_member = members.get(type);
if (!by_member) {
by_member = new Map();
members.set(type, by_member);
}
// Only set if not already present — the first feature claiming a
// (type, member) pair wins. (Multiple features can map to the same
// pair via duplicated compat paths; we just need any.)
if (!by_member.has(member)) by_member.set(member, feature_id);
};
for (const [feature_id, feature] of Object.entries(features)) {
if (!('compat_features' in feature) || !feature.compat_features) continue;
for (const path of feature.compat_features) {
const parts = path.split('.');
// `api.X` → global identifier (constructor or function), e.g.
// `api.ResizeObserver`, `api.structuredClone`.
if (parts[0] === 'api' && parts.length === 2) {
globals.set(parts[1], feature_id);
continue;
}
// `api.X.Y` → member access on type X. E.g.
// `api.HTMLElement.inert`, `api.ResizeObserverEntry.contentBoxSize`.
if (parts[0] === 'api' && parts.length === 3) {
add_member(parts[1], parts[2], feature_id);
continue;
}
// `javascript.builtins.X` → global like Promise, Symbol, Proxy.
if (parts[0] === 'javascript' && parts[1] === 'builtins' && parts.length === 3) {
globals.set(parts[2], feature_id);
continue;
}
// `javascript.builtins.X.Y` → method/property on type X.
// We also accept the `ArrayConstructor`-style mapping for static
// methods: when the AST walker sees `Array.from(...)`, the
// receiver type's symbol name is `ArrayConstructor`, not `Array`.
if (parts[0] === 'javascript' && parts[1] === 'builtins' && parts.length === 4) {
add_member(parts[2], parts[3], feature_id);
add_member(`${parts[2]}Constructor`, parts[3], feature_id);
continue;
}
// `javascript.*` syntax: try the second segment first (covers
// `javascript.operators.X` and `javascript.statements.X`), then
// the last segment (covers `javascript.classes.private_class_fields`
// and similar).
if (parts[0] === 'javascript' && parts.length >= 3) {
const candidates = [parts[2], parts[parts.length - 1]];
for (const key of candidates) {
if (Object.hasOwn(SYNTAX_PREDICATES, key)) {
syntax_predicates.push({ predicate: SYNTAX_PREDICATES[key], feature_id });
break;
}
}
continue;
}
}
}
return { globals, members, string_literals, syntax_predicates };
}
const MAPS = build_detection_maps();
/**
* Versions and friendly names for synthetic feature IDs registered via
* `register_extra_rules` APIs the type-aware walker can see but that
* `web-features` doesn't (yet) catalogue. `versions_for_feature` consults
* this map before falling back to `web-features` lookups.
*
* @type {Map<string, { name: string, versions: Record<string, string | null>, baseline_year: number | null }>}
*/
const EXTRA_FEATURE_INFO = new Map();
/**
* Register additional detection rules for APIs the `web-features` dataset
* doesn't track yet. Two rule shapes are accepted:
*
* - **Member access**: `{ receiver_type, member, ... }` flags
* `expr.member` when `expr`'s type resolves to `receiver_type`.
* Equivalent to a `api.X.Y` rule auto-derived from web-features.
*
* - **String literal**: `{ string_literal, ... }` flags any
* occurrence of the literal value in source. Used for API options
* that are string-typed (e.g. `{ box: 'device-pixel-content-box' }`).
* The walker can be tightened later with a contextual-type check if
* false positives ever surface; for now an exact match is enough
* (these strings are too specific to occur incidentally).
*
* Each rule contributes its `feature_id`, per-browser versions,
* baseline year, and display name to the shared lookup tables.
*
* @param {Array<{
* feature_id: string,
* name: string,
* baseline_year: number,
* versions: Record<string, string | null>,
* receiver_type?: string,
* member?: string,
* string_literal?: string
* }>} rules
*/
export function register_extra_rules(rules) {
for (const rule of rules) {
if (rule.receiver_type && rule.member) {
let by_member = MAPS.members.get(rule.receiver_type);
if (!by_member) {
by_member = new Map();
MAPS.members.set(rule.receiver_type, by_member);
}
// Don't overwrite an existing web-features rule; that's canonical.
if (!by_member.has(rule.member)) {
by_member.set(rule.member, rule.feature_id);
}
} else if (rule.string_literal) {
if (!MAPS.string_literals.has(rule.string_literal)) {
MAPS.string_literals.set(rule.string_literal, rule.feature_id);
}
}
EXTRA_FEATURE_INFO.set(rule.feature_id, {
name: rule.name,
baseline_year: rule.baseline_year,
versions: rule.versions
});
}
}
/**
* Compile bundle files into a single `ts.Program` so type-checking is
* amortised across all of them.
*
* @param {string[]} files
*/
function build_program(files) {
const program = ts.createProgram(files, {
allowJs: true,
checkJs: false,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
strict: false,
noEmit: true,
skipLibCheck: true,
isolatedModules: true,
noErrorTruncation: true
});
const checker = program.getTypeChecker();
return { program, checker };
}
/**
* Collect the names of a type and its base types so a `member` lookup
* keyed on (say) `HTMLElement` matches a receiver typed as
* `HTMLDivElement`. Also includes the apparent type to catch primitives
* (`'foo'` apparent-types to `String`).
*
* @param {ts.Type} type
* @param {ts.TypeChecker} checker
*/
function get_type_names(type, checker) {
const names = new Set();
const constituents = type.isUnionOrIntersection() ? type.types : [type];
for (const t of constituents) {
const symbol = t.getSymbol() ?? t.aliasSymbol;
if (symbol) names.add(symbol.getName());
for (const base of t.getBaseTypes?.() ?? []) {
const base_symbol = base.getSymbol() ?? base.aliasSymbol;
if (base_symbol) names.add(base_symbol.getName());
}
const apparent = checker.getApparentType(t);
if (apparent && apparent !== t) {
const apparent_symbol = apparent.getSymbol() ?? apparent.aliasSymbol;
if (apparent_symbol) names.add(apparent_symbol.getName());
}
}
return names;
}
/**
* Cheap test for whether a symbol refers to a global binding (declared
* in a lib.d.ts or ambient module) rather than a user-defined local.
*
* @param {ts.Symbol | undefined} symbol
*/
function is_global_binding(symbol) {
if (!symbol) return true; // unresolved → assume global
const declarations = symbol.getDeclarations() ?? [];
if (declarations.length === 0) return true;
for (const decl of declarations) {
const file_name = decl.getSourceFile().fileName;
if (file_name.includes('/lib.') && file_name.endsWith('.d.ts')) return true;
}
return false;
}
/**
* Walk a TS source file emitting feature IDs as they're discovered.
*
* @param {ts.SourceFile} source
* @param {ts.TypeChecker | null} checker
* When `null`, type-aware checks (member access) are skipped. Used for
* the compiler-output fixtures which are parsed without a Program.
* @param {(feature_id: string) => void} emit
*/
function walk_source(source, checker, emit) {
/** @param {ts.Node} node */
function visit(node) {
// Syntax predicates run regardless of whether we have a checker.
for (const { predicate, feature_id } of MAPS.syntax_predicates) {
if (predicate(node)) emit(feature_id);
}
// Global identifier detection.
if (
ts.isIdentifier(node) &&
!(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) &&
!ts.isPropertyAssignment(node.parent) &&
!ts.isMethodDeclaration(node.parent) &&
!ts.isPropertySignature(node.parent)
) {
const feature_id = MAPS.globals.get(node.text);
if (feature_id) {
if (!checker || is_global_binding(checker.getSymbolAtLocation(node))) {
emit(feature_id);
}
}
}
// String-literal detection. The walker matches by value alone;
// false positives are theoretically possible but the literal
// values we care about (e.g. `'device-pixel-content-box'`) are
// distinctive enough that one hasn't been observed.
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
const feature_id = MAPS.string_literals.get(node.text);
if (feature_id) emit(feature_id);
}
// Member access detection (requires the checker).
if (checker && ts.isPropertyAccessExpression(node)) {
const member_name = node.name.text;
const receiver_type = checker.getTypeAtLocation(node.expression);
const type_names = get_type_names(receiver_type, checker);
for (const type_name of type_names) {
const by_member = MAPS.members.get(type_name);
if (!by_member) continue;
const feature_id = by_member.get(member_name);
if (feature_id) {
emit(feature_id);
break;
}
}
}
ts.forEachChild(node, visit);
}
visit(source);
}
/**
* Detect features used in a set of bundle files. Returns the union of
* web-features IDs flagged across all files.
*
* @param {string[]} bundle_files Absolute paths to JS/TS files.
* @returns {Set<string>}
*/
export function detect_features(bundle_files) {
const { program, checker } = build_program(bundle_files);
const flagged = new Set();
for (const file of bundle_files) {
const source = program.getSourceFile(file);
if (!source) continue;
walk_source(source, checker, (id) => flagged.add(id));
}
return flagged;
}
/**
* Detect features used in a single in-memory source string. No type
* checker (so member-based rules silently skip) useful for the
* compiler-output fixtures, where syntax-level detection is sufficient.
*
* @param {string} source_text
* @returns {Set<string>}
*/
export function detect_features_in_text(source_text) {
const source = ts.createSourceFile(
'fixture.js',
source_text,
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.JS
);
const flagged = new Set();
walk_source(source, null, (id) => flagged.add(id));
return flagged;
}
/**
* Per-browser minimum versions for a feature ID. Consults
* supplemental rules first (from `register_extra_rules`), then falls
* back to `web-features`. Returns null when neither has data.
*
* @param {string} feature_id
*/
export function versions_for_feature(feature_id) {
const extra = EXTRA_FEATURE_INFO.get(feature_id);
if (extra) return extra.versions;
const feature = features[feature_id];
if (!feature || !('status' in feature)) return null;
return /** @type {Record<string, string> | null} */ (
/** @type {unknown} */ (feature.status.support)
);
}
/**
* Baseline year for a feature. Returns `null` for features without a
* Baseline date (limited availability, supplemental rules without a
* year, or absent from the dataset).
*
* @param {string} feature_id
*/
export function baseline_year_for_feature(feature_id) {
const extra = EXTRA_FEATURE_INFO.get(feature_id);
if (extra) return extra.baseline_year;
const feature = features[feature_id];
if (!feature || !('status' in feature)) return null;
const status = /** @type {{ baseline_low_date?: string }} */ (feature.status);
if (!status.baseline_low_date) return null;
return Number(status.baseline_low_date.slice(0, 4));
}

@ -0,0 +1,877 @@
/* eslint-disable no-console */
// Regenerates `documentation/docs/07-misc/05-browser-support.md`.
//
// Pipeline:
// 1. Bundle each runtime entry point with rollup using production export
// conditions, then walk the resulting JS with TypeScript's compiler
// API + TypeChecker. The walker (see `browser-support.detector.js`)
// flags any web-features ID the runtime references.
// 2. Verify each entry in `BEHAVIORAL_IGNORE` is still flagged by the
// detector — if not, the entry can be removed.
// 3. Enumerate every user-facing feature (each `bind:*`, every public
// subpackage export, every rune from the compiler's `RUNES` array,
// and the handful of directives that need their own fixtures). For
// each: compile, bundle, walk. If the bundle requires browser
// versions newer than the runtime floor, emit a row in the
// conditional-features table. Blind-spot regexes pick up APIs the
// AST walker can't see (string-literal constructor options,
// `getComputedStyle(...).zoom` reads).
// 4. Translate floors into concrete browser versions via `web-features`
// data (exact per-feature versions), falling back to
// `baseline-browser-mapping` for year-only resolution.
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { rollup, type OutputChunk } from 'rollup';
import virtual from '@rollup/plugin-virtual';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { getCompatibleVersions } from 'baseline-browser-mapping';
import {
detect_features,
detect_features_in_text,
versions_for_feature,
baseline_year_for_feature,
register_extra_rules
} from './browser-support.detector.js';
import { binding_properties } from '../src/compiler/phases/bindings.js';
import { RUNES } from '../src/utils.js';
import { compile as svelte_compile } from '../src/compiler/index.js';
type BindingProperty = import('../src/compiler/phases/bindings.js').BindingProperty;
type PackageExport = string | { browser?: string; default?: string };
type CompilerFixture = { filename: string; code: string };
type Feature = { name: string; source: string; kind: 'svelte' | 'js' };
type BrowserVersions = Record<string, string | null>;
type RuntimeFloor = number | 'newly';
type ConditionalRow = {
name: string;
doc_link: string | null;
versions: BrowserVersions;
baseline_year: RuntimeFloor;
};
const doc_links: Record<string, string | null> = {
'`$state.snapshot`': '/docs/svelte/$state#$state.snapshot',
'`bind:devicePixelContentBoxSize`': '/docs/svelte/bind#Dimensions',
'`flip` from `svelte/animate`': '/docs/svelte/svelte-animate#flip'
};
// Supplemental detection rules for APIs `web-features` doesn't track
// yet. Each rule is checked with full TS type-aware precision — the
// only reason it lives here instead of being auto-derived is that no
// compat key in `web-features` covers the API.
register_extra_rules([
{
// `getComputedStyle(current).zoom` walk in `svelte/animate`'s
// `flip` fallback path. Firefox didn't expose `.zoom` on
// `CSSStyleDeclaration` until v126 (May 2024) — pre-126 the read
// yields an empty string, breaking the animation math. No entry
// for it exists in `web-features` (CSS `zoom` is an IDL accessor
// without its own Baseline feature record).
receiver_type: 'CSSStyleDeclaration',
member: 'zoom',
feature_id: 'extra:css-zoom-read',
name: 'CSS zoom property reads (getComputedStyle(...).zoom)',
baseline_year: 2024,
versions: { firefox: '126' }
},
{
// `box: 'device-pixel-content-box'` in `bind_resize_observer`
// (size.js). The TS DOM lib declares the option value as part of
// the `ResizeObserverBoxOptions` union — we could check the
// contextual type, but matching the literal value is enough since
// the string is too specific to occur incidentally. Per MDN BCD:
// - constructor option: Chrome 84, Firefox 93, Safari 15.4
// - `ResizeObserverEntry.devicePixelContentBoxSize`: Safari NOT
// SUPPORTED (`version_added: false`)
// Safari therefore silently accepts the option from 15.4 onwards
// but never exposes the matching entry property, so the binding
// reads `undefined` on any Safari.
string_literal: 'device-pixel-content-box',
feature_id: 'extra:device-pixel-content-box',
name: 'ResizeObserver `box: device-pixel-content-box` option + `entry.devicePixelContentBoxSize`',
baseline_year: 2023,
versions: {
chrome: '84',
edge: '84',
firefox: '93',
safari: null,
safari_ios: null
}
}
]);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkg_dir = path.resolve(__dirname, '..');
const repo_root = path.resolve(pkg_dir, '..', '..');
const docs_dir = path.join(repo_root, 'documentation/docs/07-misc/.generated');
const snapshot_dir = path.join(pkg_dir, 'tests/snapshot/samples');
const tmp_dir = path.join(__dirname, '_baseline');
const pkg = JSON.parse(fs.readFileSync(path.join(pkg_dir, 'package.json'), 'utf-8')) as {
exports: Record<string, PackageExport>;
};
/**
* Suppressions that should NEVER affect the floor regardless of whether
* the runtime currently uses the API. Two reasons an entry belongs here:
*
* - The `web-features` dataset misclassifies the API (e.g.
* `devicepixelratio` is marked Baseline `false` because Safari is
* missing from its `support` map, but the property has shipped in
* every Safari for over a decade).
* - Svelte feature-detects the API at runtime with `?.` and degrades
* gracefully when it's unavailable. Example: `trusted-types` in
* `src/internal/client/dom/reconciler.js`.
*
* These are exempt from the staleness check.
*/
const SAFE_TO_IGNORE = new Set(['devicepixelratio', 'trusted-types']);
/**
* Suppressions for features that DO live in the runtime but are reached
* only via a specific code path documented in the per-feature table on
* the docs page.
*/
const BEHAVIORAL_IGNORE = new Set([
'structured-clone',
'extra:css-zoom-read',
'extra:device-pixel-content-box'
]);
/** Aggregate ignore set — used for the headline floor. */
const AGGREGATE_IGNORE = new Set([...SAFE_TO_IGNORE, ...BEHAVIORAL_IGNORE]);
/**
* Subpaths in `pkg.exports` whose runtime is Node-only. Can't be derived
* from the exports map alone `./compiler` has a `require:` field that
* hints at CJS, but `./server` and `./internal/server` are plain `default`
* entries indistinguishable from a browser module.
*/
const NODE_ONLY_EXPORTS = new Set(['./compiler', './server', './internal/server']);
/**
* Every subpath in `pkg.exports` that ships browser JS. Type-only entries
* (`./action`, `./elements`) and the `./package.json` re-export filter out
* naturally on the `.js` check; only Node-only subpaths need an explicit
* exception, so new browser exports are picked up automatically.
*/
function browser_subpaths(): string[] {
const subpaths: string[] = [];
for (const [subpath, conditions] of Object.entries(pkg.exports)) {
if (NODE_ONLY_EXPORTS.has(subpath)) continue;
if (typeof conditions !== 'object' || conditions === null) continue;
const file = conditions.browser ?? conditions.default;
if (typeof file !== 'string' || !file.endsWith('.js')) continue;
subpaths.push(subpath);
}
return subpaths;
}
/**
* `.` `svelte`, `./animate` `svelte/animate`, etc.
*/
function importee_for(subpath: string): string {
return subpath === '.' ? 'svelte' : `svelte${subpath.slice(1)}`;
}
/**
* True if a subpath represents a public, user-facing subpackage whose
* named exports should each get their own per-feature fixture. Excludes
* the main entry (covered by the aggregate scan), the `./legacy` shim,
* and everything under `./internal/`.
*/
function is_public_subpackage(subpath: string): boolean {
return subpath !== '.' && subpath !== './legacy' && !subpath.startsWith('./internal');
}
/**
* For each public subpackage, dynamically import the module and return
* its named exports. Driven entirely by `pkg.exports`, so a new
* subpackage is picked up the next time the script runs.
*/
async function enumerate_subpackage_exports(): Promise<Record<string, string[]>> {
const result: Record<string, string[]> = {};
for (const subpath of browser_subpaths()) {
if (!is_public_subpackage(subpath)) continue;
const module_id = importee_for(subpath);
try {
const ns = await import(module_id);
const names = Object.keys(ns)
.filter((k) => k !== 'default')
.sort();
if (names.length > 0) result[module_id] = names;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(` (could not enumerate ${module_id}: ${message.split('\n')[0]})`);
}
}
return result;
}
const rune_fixtures: Record<(typeof RUNES)[number], string> = {
$state: `<script>let v = $state(0); console.log(v);</script>`,
'$state.raw': `<script>let v = $state.raw({}); console.log(v);</script>`,
'$state.eager': `<script>let v = $state.eager(0); console.log(v);</script>`,
'$state.snapshot': `<script>const v = $state({}); const snap = $state.snapshot(v); console.log(snap);</script>`,
$derived: `<script>let a = $state(0); let d = $derived(a + 1); console.log(d);</script>`,
'$derived.by': `<script>let a = $state(0); let d = $derived.by(() => a + 1); console.log(d);</script>`,
$props: `<script>let { x } = $props(); console.log(x);</script>`,
'$props.id': `<script>const id = $props.id(); console.log(id);</script>`,
$bindable: `<script>let { v = $bindable() } = $props(); console.log(v);</script>`,
$effect: `<script>$effect(() => { console.log('e'); });</script>`,
'$effect.pre': `<script>$effect.pre(() => { console.log('p'); });</script>`,
'$effect.tracking': `<script>$effect(() => { console.log($effect.tracking()); });</script>`,
'$effect.root': `<script>const stop = $effect.root(() => () => {}); stop();</script>`,
'$effect.pending': `<script>$effect(() => { console.log($effect.pending()); });</script>`,
$inspect: `<script>let v = $state(0); $inspect(v);</script>`,
'$inspect().with': `<script>let v = $state(0); $inspect(v).with(() => {});</script>`,
'$inspect.trace': `<script>$effect(() => { $inspect.trace(); });</script>`,
$host: `<svelte:options customElement="x-y" />\n<script>const h = $host(); console.log(h);</script>`
};
function rune_fixture(rune: (typeof RUNES)[number]): string {
if (!Object.hasOwn(rune_fixtures, rune)) {
throw new Error(`Fixture missing for ${rune}`);
}
return rune_fixtures[rune];
}
/**
* Compiled-fixture sources for directives. Bindings are covered by the
* `binding_properties` enumeration; transitions, animate, actions, and
* `@attach` need explicit fixtures because they require accompanying
* imports or surrounding markup.
*/
const TESTED_DIRECTIVES = [
{
name: '`transition:` / `in:` / `out:`',
source: `<script>import { fade } from 'svelte/transition'; let show = $state(false);</script>{#if show}<div transition:fade></div>{/if}`
},
{
name: '`animate:`',
source: `<script>const flip = () => {}; let items = $state([1,2,3]);</script>{#each items as item (item)}<div animate:flip>{item}</div>{/each}`
},
{
name: '`use:` actions',
source: `<script>function action(node){return {destroy(){}}}</script><div use:action></div>`
},
{
name: '`@attach`',
source: `<script>const attachment = (node) => () => {};</script><div {@attach attachment}></div>`
},
{
name: '`{@html ...}`',
source: `<script>let html = $state('<b>x</b>');</script>{@html html}`
},
{
name: 'Custom elements (`<svelte:options customElement>`)',
source: `<svelte:options customElement="my-el" />\n<div></div>`
}
];
/**
* Filesystem-safe identifier for an importee like `svelte/internal/client`.
*/
function safe_name(importee: string): string {
return importee.replace(/[^a-z0-9]+/gi, '_');
}
/**
* Bundle an entry the way users receive it, so we scan the same code the
* browser does. Mirrors `check-treeshakeability.js`.
*
* `entry_code` is virtual module source: typically `export * from
* 'svelte/...'` for a runtime entry, or compiled fixture JS for a per-feature
* scan. `silent` suppresses rollup's circular-dependency warnings used for
* fixture bundles where they're known and noisy.
*/
async function bundle(entry_code: string, options: { silent?: boolean } = {}): Promise<string> {
const built = await rollup({
input: '__entry__',
plugins: [
virtual({ __entry__: entry_code }),
{
name: 'resolve-svelte',
resolveId(id: string) {
if (id.startsWith('svelte')) {
const entry = pkg.exports[id.replace('svelte', '.')];
if (!entry) return;
if (typeof entry === 'string') return path.resolve(pkg_dir, entry);
const file = entry.browser ?? entry.default;
if (file) return path.resolve(pkg_dir, file);
}
}
},
nodeResolve({ exportConditions: ['production', 'import', 'browser', 'default'] })
],
// Treat optional peers / Node-only branches as external so we only scan
// code that actually runs in the browser.
external: ['esm-env'],
onwarn: options.silent
? () => {}
: (warning, handler) => {
if (warning.code === 'CIRCULAR_DEPENDENCY') return;
handler(warning);
}
});
const { output } = await built.generate({ format: 'esm' });
await built.close();
return output
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
.map((chunk) => chunk.code)
.join('\n');
}
/**
* Read every compiler-emitted client file from the snapshot tests. These
* fixtures cover the full range of patterns the compiler emits bindings,
* transitions, `<svelte:element>`, async derived, hydration markers, etc.
*/
function load_compiler_output_fixtures(): CompilerFixture[] {
const fixtures: CompilerFixture[] = [];
for (const sample of fs.readdirSync(snapshot_dir)) {
const client_dir = path.join(snapshot_dir, sample, '_expected/client');
if (!fs.existsSync(client_dir)) continue;
for (const file of fs.readdirSync(client_dir)) {
if (!file.endsWith('.js')) continue;
fixtures.push({
filename: `${sample}/${file}`,
code: fs.readFileSync(path.join(client_dir, file), 'utf-8')
});
}
}
return fixtures;
}
/**
* Combine per-feature version data into a single Record. Takes the max
* (strictest) version per browser across the input feature IDs.
*
* `null` propagates as "not supported" if any contributing feature
* marks a browser unsupported, the merged record does too.
*
* Returns `null` if NONE of the IDs have versions in `web-features` or
* supplemental rules, so callers can fall back to year-based mapping.
*/
function versions_from_features(ids: Iterable<string>): BrowserVersions | null {
const merged: BrowserVersions = {};
let any_found = false;
for (const id of ids) {
const support = versions_for_feature(id);
if (!support) continue;
any_found = true;
for (const [browser, version] of Object.entries(support)) {
const current = merged[browser];
// `null` means "not supported"; propagate it directly.
if (version === null) {
merged[browser] = null;
continue;
}
if (current === null) continue; // already known unsupported
if (current === undefined || Number(version) > Number(current)) {
merged[browser] = version;
}
}
}
return any_found ? merged : null;
}
/**
* Highest baseline year among `detected`, and the set of feature IDs that
* drove it. Used both for the aggregate runtime floor and for per-fixture
* scans the two differ only in their ignore set.
*/
function compute_floor(
detected: Iterable<string>,
ignore: Set<string>
): { year: number; drivers: Set<string> } {
let year = 0;
const drivers = new Set<string>();
for (const id of detected) {
if (ignore.has(id)) continue;
const y = baseline_year_for_feature(id);
if (!y) continue;
if (y > year) {
year = y;
drivers.clear();
}
if (y === year) drivers.add(id);
}
return { year, drivers };
}
/**
* Run the TS-based detector across the runtime bundles and the compiler-
* output fixtures, then compute the highest baseline year among the
* detected features (after subtracting `AGGREGATE_IGNORE`).
*
* `runtime_files` are absolute paths to runtime bundle files. Returns the
* minimum Baseline year the combined code satisfies.
*/
function find_minimum_target(
runtime_files: string[],
compiler_fixtures: CompilerFixture[]
): number {
// Type-aware walk over the runtime bundles.
const detected = detect_features(runtime_files);
// Syntax-only walk over the compiler-output fixtures (text only, no
// program context; the bare TS source-file parser handles the syntax
// features the compiler emits).
for (const fixture of compiler_fixtures) {
for (const id of detect_features_in_text(fixture.code)) detected.add(id);
}
const { year, drivers } = compute_floor(detected, AGGREGATE_IGNORE);
// Floor at 2015 so the docs never claim a pre-ES6 target if every
// detected feature happens to lack a Baseline year.
const final_year = Math.max(year, 2015);
console.log(`${final_year} (features that drove the floor:)`);
for (const id of [...drivers].sort()) {
console.log(` - ${id}`);
}
return final_year;
}
/**
* Verify every entry in `BEHAVIORAL_IGNORE` is actually used by the
* runtime. Without this, a behavioural suppression can outlive the API
* it suppresses the comment stays in the config pointing at code that
* no longer exists.
*
* `SAFE_TO_IGNORE` entries are exempt: they're safe to carry regardless
* of whether the runtime currently uses the API.
*/
function validate_ignore_features(runtime_files: string[]): void {
if (BEHAVIORAL_IGNORE.size === 0) return;
const detected = detect_features(runtime_files);
const stale = [...BEHAVIORAL_IGNORE].filter((id) => !detected.has(id));
if (stale.length > 0) {
throw new Error(
`BEHAVIORAL_IGNORE contains entries that the detector does not flag — ` +
`they can be removed:\n` +
stale.map((id) => ` - ${id}`).join('\n') +
`\n\nEdit \`packages/svelte/scripts/generate-browser-support.js\` ` +
`and delete the stale entries. If the API was removed from the runtime ` +
`as part of this change, that is exactly the intended signal.`
);
}
}
/**
* Build the full list of user-facing features to test for conditional
* floor bumps. Each feature gets a self-contained fixture, compiled and
* bundled like real user code, then scanned. If the bundle's floor
* exceeds the runtime floor, a row is auto-emitted in the docs.
*
* `subpackage_exports` maps subpath list of exported symbols, produced by
* `enumerate_subpackage_exports`. Passed in rather than computed here so the
* dynamic-import discovery can happen once in `main`.
*/
function enumerate_features(subpackage_exports: Record<string, string[]>): Feature[] {
const features: Feature[] = [];
// Every `bind:*` accepted by the compiler. Element selection respects
// the `valid_elements` constraint declared in `binding_properties`.
for (const [name, props] of Object.entries(binding_properties)) {
const fixture = binding_fixture(name, props);
if (fixture) {
features.push({
name: `\`bind:${name}\``,
kind: 'svelte',
source: fixture
});
}
}
for (const [module, exports] of Object.entries(subpackage_exports)) {
for (const exp of exports) {
features.push({
name: `\`${exp}\` from \`${module}\``,
kind: 'js',
source: `import { ${exp} } from '${module}'; export const _ = ${exp};`
});
}
}
for (const rune of RUNES) {
features.push({
name: `\`${rune}\``,
kind: 'svelte',
source: rune_fixture(rune)
});
}
for (const directive of TESTED_DIRECTIVES) {
features.push({ name: directive.name, kind: 'svelte', source: directive.source });
}
return features;
}
/**
* Produce the `.svelte` source for a single binding fixture. Returns
* `null` for bindings the compiler treats as elements rather than
* properties (none currently, but defensive).
*/
function binding_fixture(name: string, props: BindingProperty): string {
// Map declared `valid_elements` to a concrete element + minimal attrs
// so the compiler accepts the binding.
const tag = (props.valid_elements ?? ['div'])[0];
const reactive = `let v = $state();`;
if (tag === 'svelte:window') {
return `<script>${reactive}</script><svelte:window bind:${name}={v} />`;
}
if (tag === 'svelte:document') {
return `<script>${reactive}</script><svelte:document bind:${name}={v} />`;
}
if (tag === 'input') {
// `bind:checked` and `bind:group` require type="checkbox" | "radio"
const type =
name === 'checked' || name === 'indeterminate'
? ' type="checkbox"'
: name === 'group'
? ' type="radio" value="a"'
: name === 'files'
? ' type="file"'
: '';
return `<script>${reactive}</script><input${type} bind:${name}={v} />`;
}
if (tag === 'details') {
return `<script>${reactive}</script><details bind:${name}={v}><summary>x</summary></details>`;
}
return `<script>${reactive}</script><${tag} bind:${name}={v}></${tag}>`;
}
/**
* Compile a `.svelte` fixture to JS (no-op for `.js` fixtures), then
* bundle the result through the shared `bundle` helper. Fixtures are tiny
* so circular-dep warnings from the Svelte runtime are silenced.
*/
async function bundle_fixture(feature: Feature): Promise<string> {
const entry_code =
feature.kind === 'svelte'
? svelte_compile(feature.source, {
generate: 'client',
filename: 'Fixture.svelte',
dev: false
}).js.code
: feature.source;
return bundle(entry_code, { silent: true });
}
/**
* Detect features in a single fixture bundle and report the per-fixture
* floor year along with the IDs that drove it. Used for the per-feature
* conditional table.
*
* `fixture_file` is the absolute path to the `.ts` bundle.
*/
function scan_fixture(fixture_file: string): {
year: number;
driving_ids: string[];
} {
const { year, drivers } = compute_floor(detect_features([fixture_file]), SAFE_TO_IGNORE);
return {
year,
driving_ids: [...drivers]
};
}
/**
* Iterate every feature, bundle its fixture, scan it. Return the rows
* that need to appear in the conditional-features table.
*/
async function find_all_conditional_features(
runtime_floor: RuntimeFloor,
subpackage_exports: Record<string, string[]>
): Promise<ConditionalRow[]> {
const runtime_year = typeof runtime_floor === 'number' ? runtime_floor : Infinity;
const features = enumerate_features(subpackage_exports);
const rows: ConditionalRow[] = [];
const missing_doc_links: string[] = [];
for (let i = 0; i < features.length; i++) {
const feature = features[i];
process.stdout.write(`\r ${i + 1}/${features.length} ${feature.name}`.padEnd(80));
let bundle_code;
try {
bundle_code = await bundle_fixture(feature);
} catch {
continue; // some fixtures (rare element combos) may fail to compile
}
// Write the bundle so the type-aware scanner can resolve its types.
const fixture_file = path.join(tmp_dir, `fixture_${i}.ts`);
fs.writeFileSync(fixture_file, bundle_code);
const scanned = scan_fixture(fixture_file);
const final_year = scanned.year;
// Skip features at or below the runtime floor — they don't need a row.
if (final_year <= runtime_year || final_year === 0) continue;
// Use exact per-feature versions where available (from web-features
// or supplemental rules), falling back to the conservative year
// mapping only if no feature has explicit version data.
let versions = versions_from_features(scanned.driving_ids);
if (!versions) {
try {
versions = browser_versions_for(final_year);
} catch {
continue;
}
}
let doc_link = doc_links[feature.name];
if (doc_link === undefined) {
doc_link = null;
missing_doc_links.push(feature.name);
}
rows.push({
name: feature.name,
doc_link,
versions,
baseline_year: final_year
});
}
process.stdout.write('\n');
if (missing_doc_links.length) {
throw new Error(`Missing documentation url for some features.
Add them to the \`doc_links\` map in \`scripts/generate-browser-support.ts\`, or add an explicit \`null\` if they don't have a documentation url.
${missing_doc_links.map((name) => ` - "${name}"`).join('\n')}`);
}
return rows;
}
function render_conditional_table(features: ConditionalRow[], runtime_floor: RuntimeFloor): string {
if (features.length === 0) {
return '_No features currently require browser versions newer than the runtime floor._';
}
features.sort((a, b) => a.name.localeCompare(b.name));
const browsers = [
['chrome', 'Chrome/Edge'],
['firefox', 'Firefox'],
['safari', 'Safari']
] as const;
const floor_versions = browser_versions_for(runtime_floor);
const rows: string[][] = [];
for (const row of features) {
const name_cell = row.doc_link ? `[${row.name}](${row.doc_link})` : row.name;
const versions = browsers.map(([key]) => {
const v = row.versions[key];
if (v === null) return 'not supported';
if (v === undefined) return '<span style="color: var(--sk-fg-4)">—</span>';
const floor_v = floor_versions[key];
if (floor_v && Number(v) <= Number(floor_v))
return '<span style="color: var(--sk-fg-4)">—</span>';
return v;
});
rows.push([name_cell, ...versions]);
}
return render_markdown_table(['Feature', ...browsers.map(([, label]) => `${label}`)], rows);
}
function browser_versions_for(target: RuntimeFloor): Record<string, string> {
// `targetYear` returns the minimum versions in which every feature that
// reached Baseline by the end of that year is supported. If the lint
// search fell through to `'newly'`, we use the current year — that gives
// the most recent Newly-available cutoff, which is the strongest
// statement `baseline-browser-mapping` is able to make.
const target_year = typeof target === 'number' ? target : new Date().getFullYear();
const versions = getCompatibleVersions({
targetYear: target_year,
includeDownstreamBrowsers: true
});
// The core Baseline browsers plus the downstream browsers worth listing
// in the docs. Downstream browsers come from `baseline-browser-mapping`'s
// dataset and represent the highest-traffic Chromium derivatives; the
// long tail (UC, QQ, Yandex, in-app Facebook/Instagram browsers, etc.)
// is omitted to keep the table focused.
const visible_browsers = new Set([
'chrome',
'chrome_android',
'edge',
'firefox',
'firefox_android',
'safari',
'safari_ios',
'opera',
'opera_android',
'samsunginternet_android',
'webview_android'
]);
const suffixes = ['_android', '_ios'];
const lookup: Record<string, string> = {};
outer: for (const { browser, version } of versions) {
if (visible_browsers.has(browser)) {
for (const suffix of suffixes) {
// skip e.g. 'Chrome (Android)' if it matches Chrome
if (browser.endsWith(suffix) && version === lookup[browser.replace(suffix, '')]) {
continue outer;
}
}
lookup[browser] = version;
}
}
return lookup;
}
const BROWSER = {
chrome: 'Chrome',
edge: 'Edge',
firefox: 'Firefox',
safari: 'Safari',
opera: 'Opera',
samsung_internet: 'Samsung Internet',
webview_android: 'Android WebView',
internet_explorer: 'Internet Explorer'
};
function render_browser_table(versions: Record<string, string>, target: RuntimeFloor): string {
const rows: Array<[string, string]> = [
[BROWSER.chrome, versions.chrome],
[`${BROWSER.chrome} (Android)`, versions.chrome_android]
];
if (versions.chrome === versions.edge) {
rows[0][0] += `/${BROWSER.edge}`;
} else {
rows.push([BROWSER.edge, versions.edge]);
}
rows.push(
[BROWSER.firefox, versions.firefox],
[`${BROWSER.firefox} (Android)`, versions.firefox_android],
[BROWSER.safari, versions.safari],
[`${BROWSER.safari} (iOS)`, versions.safari_ios],
[BROWSER.opera, versions.opera],
[`${BROWSER.opera} (Android)`, versions.opera_android],
[BROWSER.samsung_internet, versions.samsunginternet_android],
[BROWSER.webview_android, versions.webview_android],
[BROWSER.internet_explorer, 'not supported']
);
const target_label = target === 'newly' ? '"newly available"' : target;
return (
render_markdown_table(
['Browser', 'Minimum version'],
rows.filter(([, version]) => version !== undefined)
) +
`\n\n> [!NOTE] This equates to a <a href="https://web-platform-dx.github.io/baseline/">Baseline</a> target of ${target_label}.`
);
}
function render_markdown_table(headers: string[], rows: string[][]): string {
return `| ${headers.join(' | ')} |
| ${headers.map(() => '-').join(' | ')} |
${rows.map((row) => `| ${row.join(' | ')} |`).join('\n')}
`;
}
async function main() {
console.log('Preparing scratch directory…');
// Wipe and recreate so stale bundles can't leak into the next scan.
fs.rmSync(tmp_dir, { recursive: true, force: true });
fs.mkdirSync(tmp_dir, { recursive: true });
try {
console.log('Bundling runtime entries…');
const runtime_files: string[] = [];
for (const importee of browser_subpaths().map(importee_for)) {
// `import * as` + re-export keeps default and named exports
// alive, so flag modules (only a default export) don't produce
// empty chunks but their code still ends up in the scan.
const code = await bundle(`import * as __ns from '${importee}'; export default __ns;`);
const file = path.join(tmp_dir, `${safe_name(importee)}.ts`);
fs.writeFileSync(file, code);
runtime_files.push(file);
}
console.log('Loading compiler-output fixtures…');
const compiler_fixtures = load_compiler_output_fixtures();
console.log(` (${compiler_fixtures.length} fixtures found)`);
console.log('Searching for the minimum Baseline target (type-aware)…');
const target = find_minimum_target(runtime_files, compiler_fixtures);
console.log('Checking BEHAVIORAL_IGNORE for stale entries…');
validate_ignore_features(runtime_files);
console.log(' no stale entries');
console.log('Enumerating subpackage exports…');
const subpackage_exports = await enumerate_subpackage_exports();
const total_exports = Object.values(subpackage_exports).reduce((n, list) => n + list.length, 0);
console.log(
` ${total_exports} export(s) across ${Object.keys(subpackage_exports).length} subpackage(s)`
);
console.log('Scanning per-feature fixtures for conditional requirements…');
const conditional_rows = await find_all_conditional_features(target, subpackage_exports);
console.log(
` ${conditional_rows.length} feature(s) require browsers newer than the runtime floor`
);
console.log('Resolving browser versions…');
const versions = browser_versions_for(target);
console.log('Rewriting docs page…');
generate('browser-support.md', render_browser_table(versions, target));
generate('browser-support-features.md', render_conditional_table(conditional_rows, target));
console.log('Done.');
} finally {
fs.rmSync(tmp_dir, { recursive: true, force: true });
}
}
function generate(file: string, content: string): void {
const filename = path.join(docs_dir, file);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
} catch {}
const backlink = path.relative(filename, fileURLToPath(import.meta.url));
fs.writeFileSync(filename, `<!-- generated in ${backlink}. do not edit -->\n\n${content}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

@ -378,7 +378,6 @@ function run() {
}; };
const block = esrap.print( const block = esrap.print(
// @ts-expect-error some bullshit
/** @type {ESTree.Program} */ ({ ...ast, body: [clone] }), /** @type {ESTree.Program} */ ({ ...ast, body: [clone] }),
ts({ comments: [jsdoc_clone] }) ts({ comments: [jsdoc_clone] })
).code; ).code;

@ -24,7 +24,7 @@ declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined; declare function $state<T>(): T | undefined;
declare namespace $state { declare namespace $state {
type Primitive = string | number | boolean | null | undefined; type Primitive = string | number | bigint | boolean | null | undefined;
type TypedArray = type TypedArray =
| Int8Array | Int8Array

@ -1004,6 +1004,24 @@ export function debug_tag_invalid_arguments(node) {
e(node, 'debug_tag_invalid_arguments', `{@debug ...} arguments must be identifiers, not arbitrary expressions\nhttps://svelte.dev/e/debug_tag_invalid_arguments`); e(node, 'debug_tag_invalid_arguments', `{@debug ...} arguments must be identifiers, not arbitrary expressions\nhttps://svelte.dev/e/debug_tag_invalid_arguments`);
} }
/**
* Declaration tags must be `let` or `const` declarations
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function declaration_tag_invalid_type(node) {
e(node, 'declaration_tag_invalid_type', `Declaration tags must be \`let\` or \`const\` declarations\nhttps://svelte.dev/e/declaration_tag_invalid_type`);
}
/**
* Declaration tags cannot be used in legacy mode
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function declaration_tag_no_legacy_mode(node) {
e(node, 'declaration_tag_no_legacy_mode', `Declaration tags cannot be used in legacy mode\nhttps://svelte.dev/e/declaration_tag_no_legacy_mode`);
}
/** /**
* Directive value must be a JavaScript expression enclosed in curly braces * Directive value must be a JavaScript expression enclosed in curly braces
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node

@ -262,6 +262,10 @@ export function convert(source, ast) {
}; };
}, },
// @ts-ignore // @ts-ignore
DeclarationTag(node) {
return node;
},
// @ts-ignore
KeyBlock(node, { visit }) { KeyBlock(node, { visit }) {
remove_surrounding_whitespace_nodes(node.fragment.nodes); remove_surrounding_whitespace_nodes(node.fragment.nodes);
return { return {

@ -1,4 +1,4 @@
/** @import { Comment, Program } from 'estree' */ /** @import { Comment, Program, Statement } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { Parser } from './index.js' */ /** @import { Parser } from './index.js' */
import * as acorn from 'acorn'; import * as acorn from 'acorn';
@ -98,6 +98,36 @@ export function parse_expression_at(parser, source, index) {
} }
} }
/**
* @param {Parser} parser
* @param {string} source
* @param {number} index
* @returns {Statement}
*/
export function parse_statement_at(parser, source, index) {
// cast to `any`: acorn's Parser constructor and parseStatement/nextToken aren't in its public types
const acorn = /** @type {any} */ (parser.ts ? TSParser : JSParser);
const { onComment, add_comments } = get_comment_handlers(source, parser.root.comments, index);
try {
// This is like parseExpressionAt but for statements
const p = new acorn(
{ onComment, sourceType: 'module', ecmaVersion: 16, locations: true },
source,
index
);
p.nextToken();
const statement = /** @type {Statement} */ (p.parseStatement(null, true, Object.create(null)));
add_comments(/** @type {acorn.Node} */ (statement));
return statement;
} catch (err) {
// A statement that runs to the end of the source (e.g. an unterminated declaration tag)
// is an EOF, not a stray token; preserve the friendlier `unexpected_eof` diagnostic.
if (/** @type {any} */ (err).pos === source.length) e.unexpected_eof(source.length);
handle_parse_error(err);
}
}
const regex_position_indicator = / \(\d+:\d+\)$/; const regex_position_indicator = / \(\d+:\d+\)$/;
/** /**

@ -302,7 +302,10 @@ export class Parser {
} }
pop() { pop() {
this.fragments.pop(); const fragment = this.fragments.pop();
if (fragment?.metadata.transparent && fragment.nodes.some((n) => n.type === 'DeclarationTag')) {
fragment.metadata.transparent = false;
}
return this.stack.pop(); return this.stack.pop();
} }

@ -1,16 +1,20 @@
/** @import { ArrowFunctionExpression, Expression, Identifier, Pattern } from 'estree' */ /** @import { ArrowFunctionExpression, Expression, Identifier, Pattern, VariableDeclaration } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { Parser } from '../index.js' */ /** @import { Parser } from '../index.js' */
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
import { ExpressionMetadata } from '../../nodes.js'; import { ExpressionMetadata } from '../../nodes.js';
import { parse_expression_at } from '../acorn.js'; import { parse_expression_at, parse_statement_at } from '../acorn.js';
import read_pattern from '../read/context.js'; import read_pattern from '../read/context.js';
import read_expression, { get_loose_identifier } from '../read/expression.js'; import read_expression, { get_loose_identifier } from '../read/expression.js';
import { create_fragment } from '../utils/create.js'; import { create_fragment } from '../utils/create.js';
import { match_bracket } from '../utils/bracket.js'; import { find_matching_bracket, match_bracket } from '../utils/bracket.js';
const regex_whitespace_with_closing_curly_brace = /\s*}/y; const regex_whitespace_with_closing_curly_brace = /\s*}/y;
const regex_supported_declaration = /(?:let|const)\b/y;
const regex_unsupported_declaration = /(?:var|interface|enum)\b/y;
// `type` is a contextual keyword; this is just a shape hint, confirmed by parsing.
const regex_maybe_type_declaration = /type\b/y;
const pointy_bois = { '<': '>' }; const pointy_bois = { '<': '>' };
@ -31,6 +35,20 @@ export default function tag(parser) {
} }
} }
const declaration = read_declaration(parser);
if (declaration) {
parser.append({
type: 'DeclarationTag',
start,
end: parser.index,
declaration: /** @type {VariableDeclaration} */ (declaration),
metadata: {
expression: new ExpressionMetadata()
}
});
return;
}
const expression = read_expression(parser); const expression = read_expression(parser);
parser.allow_whitespace(); parser.allow_whitespace();
@ -47,6 +65,89 @@ export default function tag(parser) {
}); });
} }
/**
* @param {Parser} parser
* @returns {null | import('estree').VariableDeclaration}
*/
function read_declaration(parser) {
const start = parser.index;
const unsupported = parser.match_regex(regex_unsupported_declaration);
if (unsupported) {
e.declaration_tag_invalid_type({ start, end: start + unsupported.length });
}
if (
!parser.match_regex(regex_supported_declaration) &&
// `type` is special, since it is not a reserved keyword and can be used
// as part of a valid expression. We gotta parse first and then see what it is.
!parser.match_regex(regex_maybe_type_declaration)
) {
return null;
}
const initial_comment_count = parser.root.comments.length;
/** @type {import('estree').Statement | import('estree').VariableDeclaration} */
let declaration;
try {
declaration = parse_statement_at(parser, parser.template, start);
} catch (error) {
if (!parser.loose) throw error;
const end = find_matching_bracket(parser.template, start, '{');
if (end === undefined) throw error;
parser.index = end;
const kind = parser.template.startsWith('const', start) ? 'const' : 'let';
declaration = {
type: 'VariableDeclaration',
kind,
declarations: [
{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name: '',
start: parser.index,
end: parser.index
},
init: null,
start: parser.index,
end: parser.index
}
],
start,
end
};
}
if (declaration.type !== 'VariableDeclaration') {
if (declaration.type === 'ExpressionStatement') {
parser.root.comments.length = initial_comment_count; // Else they show up duplicated
return null;
} else {
// This is a TSTypeAliasDeclaration
e.declaration_tag_invalid_type({
start: declaration.start ?? start,
end: declaration.end ?? parser.index
});
}
}
// TODO support using
if (declaration.kind !== 'let' && declaration.kind !== 'const') {
e.declaration_tag_invalid_type(declaration);
}
parser.index = /** @type {number} */ (declaration.end);
parser.allow_whitespace();
parser.eat('}', true);
return declaration;
}
/** @param {Parser} parser */ /** @param {Parser} parser */
function open(parser) { function open(parser) {
let start = parser.index - 2; let start = parser.index - 2;

@ -39,7 +39,9 @@ function find_string_end(string, search_start_index, string_start_char) {
* @returns {number} The index of the end of this regex expression, or `Infinity` if not found. * @returns {number} The index of the end of this regex expression, or `Infinity` if not found.
*/ */
function find_regex_end(string, search_start_index) { function find_regex_end(string, search_start_index) {
return find_unescaped_char(string, search_start_index, '/'); const slash = find_unescaped_char(string, search_start_index, '/');
const eol = find_unescaped_char(string, search_start_index, '\n');
return slash < eol ? slash : Infinity;
} }
/** /**
@ -105,7 +107,11 @@ export function find_matching_bracket(template, index, open) {
continue; continue;
case '/': { case '/': {
const next_char = template[i + 1]; const next_char = template[i + 1];
if (!next_char) continue; if (!next_char) {
// `/` is the last character; advance past it so we don't loop forever
i++;
continue;
}
if (next_char === '/') { if (next_char === '/') {
i = infinity_if_negative(template.indexOf('\n', i + 1)) + '\n'.length; i = infinity_if_negative(template.indexOf('\n', i + 1)) + '\n'.length;
continue; continue;
@ -114,7 +120,12 @@ export function find_matching_bracket(template, index, open) {
i = infinity_if_negative(template.indexOf('*/', i + 1)) + '*/'.length; i = infinity_if_negative(template.indexOf('*/', i + 1)) + '*/'.length;
continue; continue;
} }
i = find_regex_end(template, i + 1) + '/'.length; const end = find_regex_end(template, i + 1) + '/'.length;
if (end === Infinity) {
i++;
} else {
i = end;
}
continue; continue;
} }
default: { default: {

@ -36,6 +36,7 @@ import { ClassDeclaration } from './visitors/ClassDeclaration.js';
import { ClassDirective } from './visitors/ClassDirective.js'; import { ClassDirective } from './visitors/ClassDirective.js';
import { Component } from './visitors/Component.js'; import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js'; import { ConstTag } from './visitors/ConstTag.js';
import { DeclarationTag } from './visitors/DeclarationTag.js';
import { DebugTag } from './visitors/DebugTag.js'; import { DebugTag } from './visitors/DebugTag.js';
import { EachBlock } from './visitors/EachBlock.js'; import { EachBlock } from './visitors/EachBlock.js';
import { ExportDefaultDeclaration } from './visitors/ExportDefaultDeclaration.js'; import { ExportDefaultDeclaration } from './visitors/ExportDefaultDeclaration.js';
@ -157,6 +158,7 @@ const visitors = {
ClassDirective, ClassDirective,
Component, Component,
ConstTag, ConstTag,
DeclarationTag,
DebugTag, DebugTag,
EachBlock, EachBlock,
ExportDefaultDeclaration, ExportDefaultDeclaration,
@ -312,6 +314,7 @@ export function analyze_module(source, options) {
options: /** @type {ValidatedCompileOptions} */ (options), options: /** @type {ValidatedCompileOptions} */ (options),
fragment: null, fragment: null,
parent_element: null, parent_element: null,
in_declaration_tag: false,
reactive_statement: null, reactive_statement: null,
derived_function_depth: -1 derived_function_depth: -1
}, },
@ -718,6 +721,7 @@ export function analyze_component(root, source, options) {
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module', ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
fragment: ast === template.ast ? ast : null, fragment: ast === template.ast ? ast : null,
parent_element: null, parent_element: null,
in_declaration_tag: false,
has_props_rune: false, has_props_rune: false,
component_slots: new Set(), component_slots: new Set(),
expression: null, expression: null,
@ -785,6 +789,7 @@ export function analyze_component(root, source, options) {
options, options,
fragment: ast === template.ast ? ast : null, fragment: ast === template.ast ? ast : null,
parent_element: null, parent_element: null,
in_declaration_tag: false,
has_props_rune: false, has_props_rune: false,
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module', ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
reactive_statement: null, reactive_statement: null,

@ -16,6 +16,8 @@ export interface AnalysisState {
* Parent doesn't necessarily mean direct path predecessor because there could be `#each`, `#if` etc in-between. * Parent doesn't necessarily mean direct path predecessor because there could be `#each`, `#if` etc in-between.
*/ */
parent_element: string | null; parent_element: string | null;
/** True if inside DeclarationTag */
in_declaration_tag: boolean;
has_props_rune: boolean; has_props_rune: boolean;
/** Which slots the current parent component has */ /** Which slots the current parent component has */
component_slots: Set<string>; component_slots: Set<string>;
@ -35,7 +37,7 @@ export interface AnalysisState {
*/ */
derived_function_depth: number; derived_function_depth: number;
/** Collected info about async `{@const }` declarations */ /** Collected info about async `{@const }`/`{let/const ...}` declarations */
async_consts?: { async_consts?: {
id: Identifier; id: Identifier;
/** How many `$.run(...)` entries are already allocated in this scope */ /** How many `$.run(...)` entries are already allocated in this scope */

@ -115,8 +115,7 @@ function is_last_evaluated_expression(path, node) {
break; break;
case 'MemberExpression': case 'MemberExpression':
if (parent.computed && node === parent.object) return false; return false;
break;
case 'ObjectExpression': case 'ObjectExpression':
if (node !== parent.properties.at(-1)) return false; if (node !== parent.properties.at(-1)) return false;

@ -255,6 +255,9 @@ export function CallExpression(node, context) {
if (expression.has_await) { if (expression.has_await) {
context.state.analysis.async_deriveds.add(node); context.state.analysis.async_deriveds.add(node);
} }
// Tell surrounding declaration tag about metadata for correct calculation of blockers etc
if (context.state.in_declaration_tag) context.state.expression?.merge(expression);
} else if (rune === '$inspect') { } else if (rune === '$inspect') {
context.next({ ...context.state, function_depth: context.state.function_depth + 1 }); context.next({ ...context.state, function_depth: context.state.function_depth + 1 });
} else { } else {

@ -1,8 +1,8 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
import * as b from '#compiler/builders';
import { validate_opening_tag } from './shared/utils.js'; import { validate_opening_tag } from './shared/utils.js';
import { mark_async_declaration } from './DeclarationTag.js';
/** /**
* @param {AST.ConstTag} node * @param {AST.ConstTag} node
@ -44,28 +44,5 @@ export function ConstTag(node, context) {
derived_function_depth: context.state.function_depth + 1 derived_function_depth: context.state.function_depth + 1
}); });
const has_await = node.metadata.expression.has_await; mark_async_declaration(context, node.metadata, [declaration]);
const blockers = [...node.metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (has_await || context.state.async_consts || blockers.length > 0) {
const run = (context.state.async_consts ??= {
id: context.state.analysis.root.unique('promises'),
declaration_count: 0
});
node.metadata.promises_id = run.id;
const bindings = context.state.scope.get_bindings(declaration);
// keep the counter in sync with the number of thunks pushed in ConstTag in transform
// TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust
// via something like the approach in https://github.com/sveltejs/svelte/pull/18032
const length = run.declaration_count + (blockers.length > 0 ? 1 : 0);
run.declaration_count += blockers.length > 0 ? 2 : 1;
const blocker = b.member(run.id, b.literal(length), true);
for (const binding of bindings) {
binding.blocker = blocker;
}
}
} }

@ -0,0 +1,71 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import * as b from '#compiler/builders';
import * as e from '../../../errors.js';
import { extract_identifiers } from '../../../utils/ast.js';
/**
* @param {AST.DeclarationTag} node
* @param {Context} context
*/
export function DeclarationTag(node, context) {
if (!context.state.analysis.runes && !context.state.analysis.maybe_runes) {
e.declaration_tag_no_legacy_mode(node);
}
const is_top_level = context.path.length === 1 && context.path[0].type === 'Fragment';
if (is_top_level) {
const duplicate = node.declaration.declarations
.flatMap((declaration) => extract_identifiers(declaration.id))
.find((id) => context.state.analysis.instance.scope.declarations.has(id.name));
if (duplicate) {
e.declaration_duplicate(duplicate, duplicate.name);
}
}
context.visit(node.declaration, {
...context.state,
in_declaration_tag: true,
// the declaration lives in the fragment scope, which is one level deeper than the
// `function_depth` we're tracking here (`set_scope` doesn't update `function_depth`).
// align them so that `state_referenced_locally` warnings are calculated correctly
function_depth: context.state.scope.function_depth,
expression: node.metadata.expression
});
mark_async_declaration(context, node.metadata, node.declaration.declarations);
}
/**
* @param {Context} context
* @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata
* @param {import('estree').VariableDeclarator[]} declarations
*/
export function mark_async_declaration(context, metadata, declarations) {
const has_await = metadata.expression.has_await;
const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (has_await || context.state.async_consts || blockers.length > 0) {
const run = (context.state.async_consts ??= {
id: context.state.analysis.root.unique('promises'),
declaration_count: 0
});
metadata.promises_id = run.id;
const bindings = declarations.flatMap((declaration) =>
context.state.scope.get_bindings(declaration)
);
// keep the counter in sync with the number of thunks pushed in transform
// TODO 6.0 once non-async and non-runes mode is gone investigate making this more robust
// via something like the approach in https://github.com/sveltejs/svelte/pull/18032
const length = run.declaration_count + (blockers.length > 0 ? 1 : 0);
run.declaration_count += blockers.length > 0 ? 2 : 1;
const blocker = b.member(run.id, b.literal(length), true);
for (const binding of bindings) {
binding.blocker = blocker;
}
}
}

@ -162,7 +162,7 @@ export function Identifier(node, context) {
if (binding.metadata?.is_template_declaration && context.state.options.experimental.async) { if (binding.metadata?.is_template_declaration && context.state.options.experimental.async) {
let snippet_name; let snippet_name;
// Find out if this references a {@const ...} declaration of an implicit children snippet // Find out if this references a {@const ...}/{let/const ...} declaration of an implicit children snippet
// when it is itself inside a snippet block at the same level. If so, error. // when it is itself inside a snippet block at the same level. If so, error.
for (let i = context.path.length - 1; i >= 0; i--) { for (let i = context.path.length - 1; i >= 0; i--) {
const parent = context.path[i]; const parent = context.path[i];

@ -25,19 +25,24 @@ export function SnippetBlock(node, context) {
context.next({ ...context.state, parent_element: null }); context.next({ ...context.state, parent_element: null });
const can_hoist = const is_top_level = context.path.length === 1 && context.path[0].type === 'Fragment';
context.path.length === 1 &&
context.path[0].type === 'Fragment' &&
can_hoist_snippet(context.state.scope, context.state.scopes);
if (is_top_level) {
const name = node.expression.name; const name = node.expression.name;
if (can_hoist) { if (context.state.analysis.instance.scope.declarations.has(name)) {
e.declaration_duplicate(node.expression, name);
}
node.metadata.can_hoist =
is_top_level && can_hoist_snippet(context.state.scope, context.state.scopes);
if (node.metadata.can_hoist) {
const name = node.expression.name;
const binding = /** @type {Binding} */ (context.state.scope.get(name)); const binding = /** @type {Binding} */ (context.state.scope.get(name));
context.state.analysis.module.scope.declarations.set(name, binding); context.state.analysis.module.scope.declarations.set(name, binding);
} }
}
node.metadata.can_hoist = can_hoist;
const { path } = context; const { path } = context;
const parent = path.at(-2); const parent = path.at(-2);

@ -302,7 +302,7 @@ export function check_element(node, context) {
const has_key_event = const has_key_event =
handlers.has('keydown') || handlers.has('keyup') || handlers.has('keypress'); handlers.has('keydown') || handlers.has('keyup') || handlers.has('keypress');
if (!has_key_event) { if (!has_key_event) {
w.a11y_click_events_have_key_events(node); w.a11y_click_events_have_key_events(node, node.name);
} }
} }
} }

@ -100,6 +100,7 @@ export function validate_element(node, context) {
(n) => (n) =>
n.type !== 'Comment' && n.type !== 'Comment' &&
n.type !== 'ConstTag' && n.type !== 'ConstTag' &&
n.type !== 'DeclarationTag' &&
(n.type !== 'Text' || n.data.trim() !== '') (n.type !== 'Text' || n.data.trim() !== '')
).length > 1 ).length > 1
) { ) {

@ -4,7 +4,7 @@
/** @import { Visitors, ComponentClientTransformState, ClientTransformState } from './types' */ /** @import { Visitors, ComponentClientTransformState, ClientTransformState } from './types' */
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { build_getter, is_state_source } from './utils.js'; import { build_getter, get_transform } from './utils.js';
import { render_stylesheet } from '../css/index.js'; import { render_stylesheet } from '../css/index.js';
import { dev, filename } from '../../../state.js'; import { dev, filename } from '../../../state.js';
import { AnimateDirective } from './visitors/AnimateDirective.js'; import { AnimateDirective } from './visitors/AnimateDirective.js';
@ -22,6 +22,7 @@ import { ClassBody } from './visitors/ClassBody.js';
import { Comment } from './visitors/Comment.js'; import { Comment } from './visitors/Comment.js';
import { Component } from './visitors/Component.js'; import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js'; import { ConstTag } from './visitors/ConstTag.js';
import { DeclarationTag } from './visitors/DeclarationTag.js';
import { DebugTag } from './visitors/DebugTag.js'; import { DebugTag } from './visitors/DebugTag.js';
import { EachBlock } from './visitors/EachBlock.js'; import { EachBlock } from './visitors/EachBlock.js';
import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js'; import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js';
@ -66,20 +67,7 @@ const visitors = {
const scope = state.scopes.get(node); const scope = state.scopes.get(node);
if (scope && scope !== state.scope) { if (scope && scope !== state.scope) {
const transform = { ...state.transform }; next({ ...state, transform: get_transform(scope, state), scope });
for (const [name, binding] of scope.declarations) {
if (
binding.kind === 'normal' ||
// Reads of `$state(...)` declarations are not
// transformed if they are never reassigned
(binding.kind === 'state' && !is_state_source(binding, state.analysis))
) {
delete transform[name];
}
}
next({ ...state, transform, scope });
} else { } else {
next(); next();
} }
@ -99,6 +87,7 @@ const visitors = {
Comment, Comment,
Component, Component,
ConstTag, ConstTag,
DeclarationTag,
DebugTag, DebugTag,
EachBlock, EachBlock,
ExportNamedDeclaration, ExportNamedDeclaration,
@ -152,6 +141,7 @@ export function client_component(analysis, options) {
scopes: analysis.module.scopes, scopes: analysis.module.scopes,
is_instance: false, is_instance: false,
hoisted: [b.import_all('$', 'svelte/internal/client'), ...analysis.instance_body.hoisted], hoisted: [b.import_all('$', 'svelte/internal/client'), ...analysis.instance_body.hoisted],
templates: new Map(),
node: /** @type {any} */ (null), // populated by the root node node: /** @type {any} */ (null), // populated by the root node
legacy_reactive_imports: [], legacy_reactive_imports: [],
legacy_reactive_statements: new Map(), legacy_reactive_statements: new Map(),

@ -1,3 +1,4 @@
/** @import { TemplateLiteral } from 'estree' */
/** @import { Namespace } from '#compiler' */ /** @import { Namespace } from '#compiler' */
/** @import { ComponentClientTransformState } from '../types.js' */ /** @import { ComponentClientTransformState } from '../types.js' */
/** @import { Node } from './types.js' */ /** @import { Node } from './types.js' */
@ -31,14 +32,29 @@ function build_locations(nodes) {
/** /**
* @param {ComponentClientTransformState} state * @param {ComponentClientTransformState} state
* @param {Namespace} namespace * @param {string} name
* @param {number} [flags] * @param {number} [flags]
*/ */
export function transform_template(state, namespace, flags = 0) { export function transform_template(state, name, flags = 0) {
const namespace = state.metadata.namespace;
const tree = state.options.fragments === 'tree'; const tree = state.options.fragments === 'tree';
const expression = tree ? state.template.as_tree() : state.template.as_html(); const expression = tree ? state.template.as_tree() : state.template.as_html();
const key =
tree || dev
? null
: get_template_key(
/** @type {TemplateLiteral} */ (expression),
state.metadata.namespace,
flags
);
if (key !== null) {
const existing = state.templates.get(key);
if (existing !== undefined) return existing;
}
if (tree) { if (tree) {
if (namespace === 'svg') flags |= TEMPLATE_USE_SVG; if (namespace === 'svg') flags |= TEMPLATE_USE_SVG;
if (namespace === 'mathml') flags |= TEMPLATE_USE_MATHML; if (namespace === 'mathml') flags |= TEMPLATE_USE_MATHML;
@ -63,5 +79,26 @@ export function transform_template(state, namespace, flags = 0) {
); );
} }
return call; const id = state.scope.root.unique(name);
state.hoisted.push(b.var(id, call));
if (key !== null) {
state.templates.set(key, id);
}
return id;
}
/**
* Returns a stable key for templates that are safe to deduplicate - plain
* `$.from_html`/`from_svg`/`from_mathml` factories with literal arguments - or `null`
* for anything else. Dev-mode templates are wrapped in `$.add_locations(...)`, which
* embeds per-call-site locations, so they never produce a key and are never shared.
* @param {TemplateLiteral} template
* @param {Namespace} namespace
* @param {number} flags
* @returns {string | null}
*/
function get_template_key(template, namespace, flags) {
return `${namespace} ${flags} ${template.quasis[0].value.raw}`;
} }

@ -40,6 +40,8 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly analysis: ComponentAnalysis; readonly analysis: ComponentAnalysis;
readonly options: ValidatedCompileOptions; readonly options: ValidatedCompileOptions;
readonly hoisted: Array<Statement | ModuleDeclaration>; readonly hoisted: Array<Statement | ModuleDeclaration>;
/** Deduplicates hoisted templates by content, mapping a template key to its hoisted identifier */
readonly templates: Map<string, Identifier>;
readonly events: Set<string>; readonly events: Set<string>;
readonly store_to_invalidate?: string; readonly store_to_invalidate?: string;

@ -179,3 +179,24 @@ export function create_derived(state, expression, async = false) {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk); return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk);
} }
} }
/**
* @param {Scope} scope
* @param {ClientTransformState} state
*/
export function get_transform(scope, state) {
const transform = { ...state.transform };
for (const [name, binding] of scope.declarations) {
if (
binding.kind === 'normal' ||
// Reads of `$state(...)` declarations are not
// transformed if they are never reassigned
(binding.kind === 'state' && !is_state_source(binding, state.analysis))
) {
delete transform[name];
}
}
return transform;
}

@ -71,14 +71,14 @@ export function AwaitBlock(node, context) {
'await' 'await'
); );
if (node.metadata.expression.has_blockers()) { if (node.metadata.expression.has_blockers() || node.metadata.expression.has_await) {
context.state.init.push( context.state.init.push(
b.stmt( b.stmt(
b.call( b.call(
'$.async', '$.async',
context.state.node, context.state.node,
node.metadata.expression.blockers(), node.metadata.expression.blockers(),
b.array([]), b.array([]), // {#await await ...} is special insofar that the await should not be waited on
b.arrow([context.state.node], b.block([stmt])) b.arrow([context.state.node], b.block([stmt]))
) )
) )

@ -7,6 +7,7 @@ import * as b from '#compiler/builders';
import { create_derived } from '../utils.js'; import { create_derived } from '../utils.js';
import { get_value } from './shared/declarations.js'; import { get_value } from './shared/declarations.js';
import { build_expression } from './shared/utils.js'; import { build_expression } from './shared/utils.js';
import { add_async_declaration } from './DeclarationTag.js';
/** /**
* @param {AST.ConstTag} node * @param {AST.ConstTag} node
@ -26,7 +27,7 @@ export function ConstTag(node, context) {
context.state.transform[declaration.id.name] = { read: get_value }; context.state.transform[declaration.id.name] = { read: get_value };
add_const_declaration(context.state, declaration.id, expression, node.metadata); add_const_declaration(context, declaration.id, expression, node.metadata);
} else { } else {
const identifiers = extract_identifiers(declaration.id); const identifiers = extract_identifiers(declaration.id);
const tmp = b.id(context.state.scope.generate('computed_const')); const tmp = b.id(context.state.scope.generate('computed_const'));
@ -63,7 +64,7 @@ export function ConstTag(node, context) {
expression = b.call('$.tag', expression, b.literal('[@const]')); expression = b.call('$.tag', expression, b.literal('[@const]'));
} }
add_const_declaration(context.state, tmp, expression, node.metadata); add_const_declaration(context, tmp, expression, node.metadata);
for (const node of identifiers) { for (const node of identifiers) {
context.state.transform[node.name] = { context.state.transform[node.name] = {
@ -74,38 +75,26 @@ export function ConstTag(node, context) {
} }
/** /**
* @param {ComponentContext['state']} state * @param {ComponentContext} context
* @param {Identifier} id * @param {Identifier} id
* @param {Expression} expression * @param {Expression} expression
* @param {AST.ConstTag['metadata']} metadata * @param {AST.ConstTag['metadata']} metadata
*/ */
function add_const_declaration(state, id, expression, metadata) { function add_const_declaration(context, id, expression, metadata) {
// we need to eagerly evaluate the expression in order to hit any // we need to eagerly evaluate the expression in order to hit any
// 'Cannot access x before initialization' errors // 'Cannot access x before initialization' errors
const after = dev ? [b.stmt(b.call('$.get', id))] : []; const after = dev ? [b.stmt(b.call('$.get', id))] : [];
const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== state.async_consts?.id);
if (metadata.promises_id) { if (metadata.promises_id) {
const run = (state.async_consts ??= { add_async_declaration(
id: metadata.promises_id, context,
thunks: [] metadata,
}); [id],
[b.stmt(b.assignment('=', id, expression))],
state.consts.push(b.let(id)); 'let'
);
if (blockers.length === 1) {
run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise')));
} else if (blockers.length > 0) {
run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers))));
}
// keep the number of thunks pushed in sync with ConstTag in analysis phase
const assignment = b.assignment('=', id, expression);
run.thunks.push(b.thunk(assignment, metadata.expression.has_await));
} else { } else {
const { state } = context;
state.consts.push(b.const(id, expression)); state.consts.push(b.const(id, expression));
state.consts.push(...after); state.consts.push(...after);
} }

@ -0,0 +1,89 @@
/** @import { Expression, Identifier, Pattern, Statement, ExpressionStatement, VariableDeclaration } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { add_state_transformers } from './shared/declarations.js';
/**
* @param {AST.DeclarationTag} node
* @param {ComponentContext} context
*/
export function DeclarationTag(node, context) {
// register the transformers _before_ visiting the declaration, so that
// later declarators can reference earlier ones (e.g. `{let a = $state(0), b = $derived(a * 2)}`)
add_state_transformers(context);
const declaration = /** @type {Statement | undefined} */ (context.visit(node.declaration));
if (
node.metadata.promises_id &&
node.declaration.type === 'VariableDeclaration' &&
declaration?.type === 'VariableDeclaration'
) {
const { ids, assignments } = build_async_declaration_parts(declaration);
add_async_declaration(context, node.metadata, ids, assignments, declaration.kind);
} else {
context.state.consts.push(declaration ?? node.declaration);
}
}
/**
* @param {VariableDeclaration} declaration
*/
export function build_async_declaration_parts(declaration) {
const ids = new Map();
for (const declarator of declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) {
ids.set(id.name, id);
}
}
const assignments = declaration.declarations
.filter((declarator) => declarator.init !== null)
.map((declarator) =>
b.stmt(
b.assignment(
'=',
/** @type {Pattern} */ (declarator.id),
/** @type {Expression} */ (declarator.init)
)
)
);
return { ids: [...ids.values()], assignments };
}
/**
* @param {ComponentContext} context
* @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata
* @param {Identifier[]} ids
* @param {ExpressionStatement[]} assignments
* @param {VariableDeclaration['kind']} [kind]
*/
export function add_async_declaration(context, metadata, ids, assignments, kind = 'let') {
const run = (context.state.async_consts ??= {
id: /** @type {Identifier} */ (metadata.promises_id),
thunks: []
});
for (const id of ids) {
context.state.consts.push(kind === 'var' ? b.var(id.name) : b.let(id.name));
}
const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (blockers.length === 1) {
run.thunks.push(b.thunk(b.member(/** @type {Expression} */ (blockers[0]), 'promise')));
} else if (blockers.length > 0) {
run.thunks.push(b.thunk(b.call('$.wait', b.array(blockers))));
}
// keep the number of thunks pushed in sync with analysis phase
const has_await =
metadata.expression.has_await ||
assignments.some((assignment) => has_await_expression(assignment));
const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments);
run.thunks.push(b.thunk(body, has_await));
}

@ -52,7 +52,6 @@ export function Fragment(node, context) {
(trimmed[0].type === 'IfBlock' && (trimmed[0].type === 'IfBlock' &&
trimmed[0].elseif && trimmed[0].elseif &&
/** @type {AST.IfBlock} */ (parent).metadata.flattened?.includes(trimmed[0]))); /** @type {AST.IfBlock} */ (parent).metadata.flattened?.includes(trimmed[0])));
const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent
/** @type {Statement[]} */ /** @type {Statement[]} */
const body = []; const body = [];
@ -96,8 +95,7 @@ export function Fragment(node, context) {
let flags = state.template.needs_import_node ? TEMPLATE_USE_IMPORT_NODE : undefined; let flags = state.template.needs_import_node ? TEMPLATE_USE_IMPORT_NODE : undefined;
const template = transform_template(state, namespace, flags); const template_name = transform_template(state, 'root', flags);
state.hoisted.push(b.var(template_name, template));
state.init.unshift(b.var(id, b.call(template_name))); state.init.unshift(b.var(id, b.call(template_name)));
close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
@ -147,8 +145,7 @@ export function Fragment(node, context) {
// special case — we can use `$.comment` instead of creating a unique template // special case — we can use `$.comment` instead of creating a unique template
state.init.unshift(b.var(id, b.call('$.comment'))); state.init.unshift(b.var(id, b.call('$.comment')));
} else { } else {
const template = transform_template(state, namespace, flags); const template_name = transform_template(state, 'root', flags);
state.hoisted.push(b.var(template_name, template));
state.init.unshift(b.var(id, b.call(template_name))); state.init.unshift(b.var(id, b.call(template_name)));
} }

@ -18,7 +18,7 @@ import {
is_customizable_select_element is_customizable_select_element
} from '../../../nodes.js'; } from '../../../nodes.js';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js'; import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_getter } from '../utils.js'; import { build_getter, get_transform } from '../utils.js';
import { import {
get_attribute_name, get_attribute_name,
build_attribute_value, build_attribute_value,
@ -201,8 +201,8 @@ export function RegularElement(node, context) {
} }
} }
// Let bindings first, they can be used on attributes // Let bindings first, they can be used on attributes and `{@const}` declarations
context.state.init.push(...lets); context.state.let_directives.push(...lets);
const node_id = context.state.node; const node_id = context.state.node;
@ -300,11 +300,14 @@ export function RegularElement(node, context) {
} }
} }
const scope = /** @type {Scope} */ (context.state.scopes.get(node.fragment));
/** @type {ComponentClientTransformState} */ /** @type {ComponentClientTransformState} */
const state = { const state = {
...context.state, ...context.state,
metadata, metadata,
scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)), scope,
transform: get_transform(scope, context.state),
preserve_whitespace: context.state.preserve_whitespace || name === 'pre' || name === 'textarea' preserve_whitespace: context.state.preserve_whitespace || name === 'pre' || name === 'textarea'
}; };
@ -318,8 +321,19 @@ export function RegularElement(node, context) {
state.options.preserveComments state.options.preserveComments
); );
const has_declarations = !node.fragment.metadata.transparent;
/** @type {typeof state} */ /** @type {typeof state} */
const child_state = { ...state, init: [], update: [], after_update: [], snippets: [] }; const child_state = {
...state,
init: [],
update: [],
after_update: [],
snippets: [],
consts: has_declarations ? [] : state.consts,
async_consts: has_declarations ? undefined : state.async_consts,
memoizer: has_declarations ? new Memoizer() : state.memoizer
};
for (const node of hoisted) { for (const node of hoisted) {
context.visit(node, child_state); context.visit(node, child_state);
@ -360,7 +374,6 @@ export function RegularElement(node, context) {
context.state.template.push_comment(); context.state.template.push_comment();
// Create a separate template for the rich content // Create a separate template for the rich content
const template_name = context.state.scope.root.unique(`${name}_content`);
const fragment_id = b.id(context.state.scope.generate('fragment')); const fragment_id = b.id(context.state.scope.generate('fragment'));
const anchor_id = b.id(context.state.scope.generate('anchor')); const anchor_id = b.id(context.state.scope.generate('anchor'));
@ -384,9 +397,8 @@ export function RegularElement(node, context) {
} }
); );
// Transform the template to $.from_html(...) and hoist it // Transform the template to $.from_html(...) and hoist it (deduplicating identical templates)
const template = transform_template(select_state, metadata.namespace, TEMPLATE_FRAGMENT); const template_name = transform_template(select_state, `${name}_content`, TEMPLATE_FRAGMENT);
context.state.hoisted.push(b.var(template_name, template));
// Build the rich content function body // Build the rich content function body
// The anchor is the child of the element (a hydration marker during hydration) // The anchor is the child of the element (a hydration marker during hydration)
@ -427,11 +439,21 @@ export function RegularElement(node, context) {
} }
} }
if (node.fragment.nodes.some((node) => node.type === 'SnippetBlock')) { if (node.fragment.nodes.some((node) => node.type === 'SnippetBlock') || has_declarations) {
if (child_state.async_consts && child_state.async_consts.thunks.length > 0) {
child_state.consts.push(
b.var(
child_state.async_consts.id,
b.call('$.run', b.array(child_state.async_consts.thunks))
)
);
}
// Wrap children in `{...}` to avoid declaration conflicts // Wrap children in `{...}` to avoid declaration conflicts
context.state.init.push( context.state.init.push(
b.block([ b.block([
...child_state.snippets, ...child_state.snippets,
...child_state.consts,
...child_state.init, ...child_state.init,
...element_state.init, ...element_state.init,
child_state.update.length > 0 ? build_render_statement(child_state) : b.empty, child_state.update.length > 0 ? build_render_statement(child_state) : b.empty,

@ -40,6 +40,7 @@ export function SvelteBoundary(node, context) {
const hoisted = []; const hoisted = [];
let has_const = false; let has_const = false;
let has_declaration = false;
// const tags need to live inside the boundary, but might also be referenced in hoisted snippets. // const tags need to live inside the boundary, but might also be referenced in hoisted snippets.
// to resolve this we cheat: we duplicate const tags inside snippets // to resolve this we cheat: we duplicate const tags inside snippets
@ -55,6 +56,10 @@ export function SvelteBoundary(node, context) {
}); });
} }
} }
if (child.type === 'DeclarationTag') {
has_declaration = true;
}
} }
for (const child of node.fragment.nodes) { for (const child of node.fragment.nodes) {
@ -68,10 +73,10 @@ export function SvelteBoundary(node, context) {
if (child.type === 'SnippetBlock') { if (child.type === 'SnippetBlock') {
if ( if (
context.state.options.experimental.async && context.state.options.experimental.async &&
has_const && (has_const || has_declaration) &&
!['failed', 'pending'].includes(child.expression.name) !['failed', 'pending'].includes(child.expression.name)
) { ) {
// we can't hoist snippets as they may reference const tags, so we just keep them in the fragment // we can't hoist snippets as they may reference const/declaration tags, so we just keep them in the fragment
nodes.push(child); nodes.push(child);
} else { } else {
/** @type {Statement[]} */ /** @type {Statement[]} */

@ -49,8 +49,13 @@ export function VariableDeclaration(node, context) {
} }
if (declarator.id.type === 'Identifier') { if (declarator.id.type === 'Identifier') {
const exclude_id = context.state.scope.root.unique('rest_excludes');
context.state.hoisted.push(
b.var(exclude_id, b.new('Set', b.array(seen.map((name) => b.literal(name)))))
);
/** @type {Expression[]} */ /** @type {Expression[]} */
const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))]; const args = [b.id('$$props'), exclude_id];
if (dev) { if (dev) {
// include rest name, so we can provide informative error messages // include rest name, so we can provide informative error messages
@ -95,8 +100,13 @@ export function VariableDeclaration(node, context) {
} }
} else { } else {
// RestElement // RestElement
const exclude_id = context.state.scope.root.unique('rest_excludes');
context.state.hoisted.push(
b.var(exclude_id, b.new('Set', b.array(seen.map((name) => b.literal(name)))))
);
/** @type {Expression[]} */ /** @type {Expression[]} */
const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))]; const args = [b.id('$$props'), exclude_id];
if (dev) { if (dev) {
// include rest name, so we can provide informative error messages // include rest name, so we can provide informative error messages
@ -194,11 +204,6 @@ export function VariableDeclaration(node, context) {
/** @type {CallExpression} */ (init) /** @type {CallExpression} */ (init)
); );
// for now, only wrap async derived in $.save if it's not
// a top-level instance derived. TODO in future maybe we
// can dewaterfall all of them?
const should_save = context.state.is_instance && context.state.scope.function_depth > 1;
if (declarator.id.type === 'Identifier') { if (declarator.id.type === 'Identifier') {
let expression = /** @type {Expression} */ (context.visit(value)); let expression = /** @type {Expression} */ (context.visit(value));
@ -213,9 +218,7 @@ export function VariableDeclaration(node, context) {
location ? b.literal(location) : undefined location ? b.literal(location) : undefined
); );
call = should_save ? save(call) : b.await(call); declarations.push(b.declarator(declarator.id, b.await(call)));
declarations.push(b.declarator(declarator.id, call));
} else { } else {
if (rune === '$derived') expression = b.thunk(expression); if (rune === '$derived') expression = b.thunk(expression);
@ -251,7 +254,7 @@ export function VariableDeclaration(node, context) {
location ? b.literal(location) : undefined location ? b.literal(location) : undefined
); );
call = should_save ? save(call) : b.await(call); call = b.await(call);
} }
declarations.push(b.declarator(id, call)); declarations.push(b.declarator(id, call));
@ -386,13 +389,17 @@ export function VariableDeclaration(node, context) {
* @param {Expression} value * @param {Expression} value
*/ */
function create_state_declarators(declarator, context, value) { function create_state_declarators(declarator, context, value) {
/**
* @param {Expression} value
* @param {string} name
*/
const mutable_source = (value, name) => {
const call = b.call('$.mutable_source', value, context.state.analysis.immutable && b.true);
return dev ? b.call('$.tag', call, b.literal(name)) : call;
};
if (declarator.id.type === 'Identifier') { if (declarator.id.type === 'Identifier') {
return [ return [b.declarator(declarator.id, mutable_source(value, declarator.id.name))];
b.declarator(
declarator.id,
b.call('$.mutable_source', value, context.state.analysis.immutable ? b.true : undefined)
)
];
} }
const tmp = b.id(context.state.scope.generate('tmp')); const tmp = b.id(context.state.scope.generate('tmp'));
@ -414,7 +421,7 @@ function create_state_declarators(declarator, context, value) {
return b.declarator( return b.declarator(
path.node, path.node,
binding?.kind === 'state' binding?.kind === 'state'
? b.call('$.mutable_source', value, context.state.analysis.immutable ? b.true : undefined) ? mutable_source(value, /** @type {Identifier} */ (path.node).name)
: value : value
); );
}) })

@ -52,7 +52,7 @@ export class Memoizer {
* @param {ExpressionMetadata} metadata * @param {ExpressionMetadata} metadata
*/ */
check_blockers(metadata) { check_blockers(metadata) {
for (const binding of metadata.dependencies) { for (const binding of metadata.references) {
if (binding.blocker) { if (binding.blocker) {
this.#blockers.add(binding.blocker); this.#blockers.add(binding.blocker);
} }

@ -15,6 +15,7 @@ import { CallExpression } from './visitors/CallExpression.js';
import { ClassBody } from './visitors/ClassBody.js'; import { ClassBody } from './visitors/ClassBody.js';
import { Component } from './visitors/Component.js'; import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js'; import { ConstTag } from './visitors/ConstTag.js';
import { DeclarationTag } from './visitors/DeclarationTag.js';
import { DebugTag } from './visitors/DebugTag.js'; import { DebugTag } from './visitors/DebugTag.js';
import { EachBlock } from './visitors/EachBlock.js'; import { EachBlock } from './visitors/EachBlock.js';
import { ExpressionStatement } from './visitors/ExpressionStatement.js'; import { ExpressionStatement } from './visitors/ExpressionStatement.js';
@ -64,6 +65,7 @@ const template_visitors = {
AwaitBlock, AwaitBlock,
Component, Component,
ConstTag, ConstTag,
DeclarationTag,
DebugTag, DebugTag,
EachBlock, EachBlock,
Fragment, Fragment,

@ -28,7 +28,7 @@ export interface ComponentServerTransformState extends ServerTransformState {
readonly preserve_whitespace: boolean; readonly preserve_whitespace: boolean;
/** True if the current node is a) a component or render tag and b) the sole child of a block */ /** True if the current node is a) a component or render tag and b) the sole child of a block */
readonly is_standalone: boolean; readonly is_standalone: boolean;
/** Transformed async `{@const }` declarations (if any) and those coming after them */ /** Transformed async `{@const }`/`{let/const ...}` declarations (if any) and those coming after them */
async_consts?: { async_consts?: {
id: Identifier; id: Identifier;
thunks: Expression[]; thunks: Expression[];

@ -9,12 +9,19 @@ import { block_close, create_child_block } from './shared/utils.js';
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
export function AwaitBlock(node, context) { export function AwaitBlock(node, context) {
let expression = /** @type {Expression} */ (context.visit(node.expression));
if (node.metadata.expression.has_await) {
// If this is an await expression, turn it into a IIFE so that the result is a promise.
// {#await await ...} is special insofar that the await should not be waited on.
expression = b.call(b.arrow([], expression, true));
}
/** @type {Statement} */ /** @type {Statement} */
let statement = b.stmt( let statement = b.stmt(
b.call( b.call(
'$.await', '$.await',
b.id('$$renderer'), b.id('$$renderer'),
/** @type {Expression} */ (context.visit(node.expression)), expression,
b.thunk( b.thunk(
node.pending ? /** @type {BlockStatement} */ (context.visit(node.pending)) : b.block([]) node.pending ? /** @type {BlockStatement} */ (context.visit(node.pending)) : b.block([])
), ),

@ -1,8 +1,9 @@
/** @import { Expression, Pattern, Statement } from 'estree' */ /** @import { Expression, Pattern } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { extract_identifiers } from '../../../../utils/ast.js'; import { extract_identifiers } from '../../../../utils/ast.js';
import { add_async_declaration } from './DeclarationTag.js';
/** /**
* @param {AST.ConstTag} node * @param {AST.ConstTag} node
@ -12,31 +13,15 @@ export function ConstTag(node, context) {
const declaration = node.declaration.declarations[0]; const declaration = node.declaration.declarations[0];
const id = /** @type {Pattern} */ (context.visit(declaration.id)); const id = /** @type {Pattern} */ (context.visit(declaration.id));
const init = /** @type {Expression} */ (context.visit(declaration.init)); const init = /** @type {Expression} */ (context.visit(declaration.init));
const blockers = [...node.metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (node.metadata.promises_id) { if (node.metadata.promises_id) {
const run = (context.state.async_consts ??= { add_async_declaration(
id: node.metadata.promises_id, context,
thunks: [] node.metadata,
}); extract_identifiers(id),
[b.stmt(b.assignment('=', id, init))],
const identifiers = extract_identifiers(declaration.id); 'let'
);
for (const identifier of identifiers) {
context.state.init.push(b.let(identifier.name));
}
if (blockers.length === 1) {
run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0])));
} else if (blockers.length > 0) {
run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers))));
}
// keep the number of thunks pushed in sync with ConstTag in analysis phase
const assignment = b.assignment('=', id, init);
run.thunks.push(b.thunk(assignment, node.metadata.expression.has_await));
} else { } else {
context.state.init.push(b.const(id, init)); context.state.init.push(b.const(id, init));
} }

@ -0,0 +1,85 @@
/** @import { Expression, Identifier, Pattern, Statement, ExpressionStatement, VariableDeclaration } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
/**
* @param {AST.DeclarationTag} node
* @param {ComponentContext} context
*/
export function DeclarationTag(node, context) {
const declaration = /** @type {Statement} */ (context.visit(node.declaration));
if (
node.metadata.promises_id &&
node.declaration.type === 'VariableDeclaration' &&
declaration.type === 'VariableDeclaration'
) {
const { ids, assignments } = build_async_declaration_parts(declaration);
add_async_declaration(context, node.metadata, ids, assignments, declaration.kind);
} else {
context.state.init.push(declaration);
}
}
/**
* @param {VariableDeclaration} declaration
*/
export function build_async_declaration_parts(declaration) {
const ids = new Map();
for (const declarator of declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) {
ids.set(id.name, id);
}
}
const assignments = declaration.declarations
.filter((declarator) => declarator.init !== null)
.map((declarator) =>
b.stmt(
b.assignment(
'=',
/** @type {Pattern} */ (declarator.id),
/** @type {Expression} */ (declarator.init)
)
)
);
return { ids: [...ids.values()], assignments };
}
/**
* @param {ComponentContext} context
* @param {AST.ConstTag['metadata'] | AST.DeclarationTag['metadata']} metadata
* @param {Identifier[]} ids
* @param {ExpressionStatement[]} assignments
* @param {VariableDeclaration['kind']} [kind]
*/
export function add_async_declaration(context, metadata, ids, assignments, kind = 'let') {
const run = (context.state.async_consts ??= {
id: /** @type {Identifier} */ (metadata.promises_id),
thunks: []
});
for (const id of ids) {
context.state.init.push(kind === 'var' ? b.var(id.name) : b.let(id.name));
}
const blockers = [...metadata.expression.dependencies]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);
if (blockers.length === 1) {
run.thunks.push(b.thunk(/** @type {Expression} */ (blockers[0])));
} else if (blockers.length > 0) {
run.thunks.push(b.thunk(b.call('Promise.all', b.array(blockers))));
}
// keep the number of thunks pushed in sync with analysis phase
const has_await =
metadata.expression.has_await ||
assignments.some((assignment) => has_await_expression(assignment));
const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments);
run.thunks.push(b.thunk(body, has_await));
}

@ -17,17 +17,23 @@ import { is_customizable_select_element } from '../../../nodes.js';
export function RegularElement(node, context) { export function RegularElement(node, context) {
const name = context.state.namespace === 'html' ? node.name.toLowerCase() : node.name; const name = context.state.namespace === 'html' ? node.name.toLowerCase() : node.name;
const namespace = determine_namespace_for_children(node, context.state.namespace); const namespace = determine_namespace_for_children(node, context.state.namespace);
const has_child_declarations = !node.fragment.metadata.transparent;
/** @type {ComponentServerTransformState} */ /** @type {ComponentServerTransformState} */
const state = { const state = {
...context.state, ...context.state,
namespace, namespace,
scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)),
preserve_whitespace: preserve_whitespace:
context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea', context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea',
init: [], init: [],
template: [] template: [],
async_consts: undefined
}; };
/** @type {ComponentServerTransformState} */
const attribute_state = { ...state, scope: context.state.scope };
const node_is_void = is_void(name); const node_is_void = is_void(name);
const optimiser = new PromiseOptimiser(); const optimiser = new PromiseOptimiser();
@ -50,7 +56,11 @@ export function RegularElement(node, context) {
if (!is_special) { if (!is_special) {
// only open the tag in the non-special path // only open the tag in the non-special path
state.template.push(b.literal(`<${name}`)); state.template.push(b.literal(`<${name}`));
body = build_element_attributes(node, { ...context, state }, optimiser.transform); body = build_element_attributes(
node,
{ ...context, state: attribute_state },
optimiser.transform
);
state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance
} }
@ -72,10 +82,7 @@ export function RegularElement(node, context) {
node.fragment.nodes, node.fragment.nodes,
context.path, context.path,
namespace, namespace,
{ state,
...state,
scope: /** @type {Scope} */ (state.scopes.get(node.fragment))
},
state.preserve_whitespace, state.preserve_whitespace,
state.options.preserveComments state.options.preserveComments
); );
@ -205,7 +212,17 @@ export function RegularElement(node, context) {
state.template.push(b.stmt(b.call('$.pop_element'))); state.template.push(b.stmt(b.call('$.pop_element')));
} }
if (optimiser.is_async()) { if (has_child_declarations && state.async_consts && state.async_consts.thunks.length > 0) {
state.init.push(
b.var(state.async_consts.id, b.call('$$renderer.run', b.array(state.async_consts.thunks)))
);
}
if (has_child_declarations) {
context.state.template.push(
...optimiser.render([b.block([...state.init, ...build_template(state.template)])])
);
} else if (optimiser.is_async()) {
context.state.template.push( context.state.template.push(
...optimiser.render([...state.init, ...build_template(state.template)]) ...optimiser.render([...state.init, ...build_template(state.template)])
); );

@ -343,7 +343,7 @@ export class PromiseOptimiser {
* @param {ExpressionMetadata} metadata * @param {ExpressionMetadata} metadata
*/ */
check_blockers(metadata) { check_blockers(metadata) {
for (const binding of metadata.dependencies) { for (const binding of metadata.references) {
if (binding.blocker) { if (binding.blocker) {
this.#blockers.add(binding.blocker); this.#blockers.add(binding.blocker);
} }

@ -152,6 +152,7 @@ export function clean_nodes(
if ( if (
node.type === 'ConstTag' || node.type === 'ConstTag' ||
node.type === 'DeclarationTag' ||
node.type === 'DebugTag' || node.type === 'DebugTag' ||
node.type === 'SvelteBody' || node.type === 'SvelteBody' ||
node.type === 'SvelteWindow' || node.type === 'SvelteWindow' ||

@ -102,8 +102,8 @@ export class ExpressionMetadata {
if (!this.#blockers) { if (!this.#blockers) {
this.#blockers = new Set(); this.#blockers = new Set();
for (const d of this.dependencies) { for (const r of this.references) {
if (d.blocker) this.#blockers.add(d.blocker); if (r.blocker) this.#blockers.add(r.blocker);
} }
} }
@ -217,6 +217,7 @@ function* find_descendants(fragment) {
case 'SnippetBlock': case 'SnippetBlock':
case 'DebugTag': case 'DebugTag':
case 'ConstTag': case 'ConstTag':
case 'DeclarationTag':
case 'Comment': case 'Comment':
case 'ExpressionTag': case 'ExpressionTag':
break; break;

@ -603,6 +603,48 @@ const svelte_visitors = (comments) => ({
context.write('}'); context.write('}');
}, },
DeclarationTag(node, context) {
context.write('{');
// This is duplicated from esrap's handling of VariableDeclaration,
// which we need to do in order to omit the trailing semicolon that esrap would add.
const open = context.new();
const join = context.new();
const child_context = context.new();
context.append(child_context);
child_context.write(`${node.declaration.kind} `);
child_context.append(open);
const declarations = node.declaration.declarations;
let first = true;
for (const d of declarations) {
if (!first) child_context.append(join);
first = false;
child_context.visit(d);
}
const length = child_context.measure() + 2 * (declarations.length - 1);
const multiline = child_context.multiline || (declarations.length > 1 && length > 50);
if (multiline) {
context.multiline = true;
if (declarations.length > 1) open.indent();
join.write(',');
join.newline();
if (declarations.length > 1) context.dedent();
} else {
join.write(', ');
}
context.write('}');
},
DebugTag(node, context) { DebugTag(node, context) {
context.write('{@debug '); context.write('{@debug ');
let started = false; let started = false;

@ -160,6 +160,18 @@ export namespace AST {
}; };
} }
/** A `{let ...}` or `{const ...}` tag */
export interface DeclarationTag extends BaseNode {
type: 'DeclarationTag';
declaration: VariableDeclaration;
/** @internal */
metadata: {
expression: ExpressionMetadata;
/** If this declaration tag contains an await expression, or needs to wait on other async, this is set */
promises_id?: Identifier;
};
}
/** A `{@debug ...}` tag */ /** A `{@debug ...}` tag */
export interface DebugTag extends BaseNode { export interface DebugTag extends BaseNode {
type: 'DebugTag'; type: 'DebugTag';
@ -622,6 +634,7 @@ export namespace AST {
export type Tag = export type Tag =
| AST.AttachTag | AST.AttachTag
| AST.ConstTag | AST.ConstTag
| AST.DeclarationTag
| AST.DebugTag | AST.DebugTag
| AST.ExpressionTag | AST.ExpressionTag
| AST.HtmlTag | AST.HtmlTag

@ -686,7 +686,8 @@ export {
if_builder as if, if_builder as if,
this_instance as this, this_instance as this,
null_instance as null, null_instance as null,
debugger_builder as debugger debugger_builder as debugger,
new_builder as new
}; };
/** /**

@ -166,11 +166,12 @@ export function a11y_autofocus(node) {
} }
/** /**
* Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate * Visible, non-interactive element `<%element%>` with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate
* @param {null | NodeLike} node * @param {null | NodeLike} node
* @param {string} element
*/ */
export function a11y_click_events_have_key_events(node) { export function a11y_click_events_have_key_events(node, element) {
w(node, 'a11y_click_events_have_key_events', `Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as \`<button type="button">\` or \`<a>\` might be more appropriate\nhttps://svelte.dev/e/a11y_click_events_have_key_events`); w(node, 'a11y_click_events_have_key_events', `Visible, non-interactive element \`<${element}>\` with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as \`<button type="button">\` or \`<a>\` might be more appropriate\nhttps://svelte.dev/e/a11y_click_events_have_key_events`);
} }
/** /**
@ -803,11 +804,11 @@ export function script_context_deprecated(node) {
} }
/** /**
* Unrecognized attribute should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it * Unrecognised attribute should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it
* @param {null | NodeLike} node * @param {null | NodeLike} node
*/ */
export function script_unknown_attribute(node) { export function script_unknown_attribute(node) {
w(node, 'script_unknown_attribute', `Unrecognized attribute — should be one of \`generics\`, \`lang\` or \`module\`. If this exists for a preprocessor, ensure that the preprocessor removes it\nhttps://svelte.dev/e/script_unknown_attribute`); w(node, 'script_unknown_attribute', `Unrecognised attribute — should be one of \`generics\`, \`lang\` or \`module\`. If this exists for a preprocessor, ensure that the preprocessor removes it\nhttps://svelte.dev/e/script_unknown_attribute`);
} }
/** /**

@ -9,6 +9,7 @@ import {
set_hydrating, set_hydrating,
skip_nodes skip_nodes
} from '../hydration.js'; } from '../hydration.js';
import { assign_nodes } from '../template.js';
/** /**
* @param {TemplateNode} node * @param {TemplateNode} node
@ -23,6 +24,7 @@ export function async(node, blockers = [], expressions = [], fn) {
if (was_hydrating) { if (was_hydrating) {
hydrate_next(); hydrate_next();
end = skip_nodes(false); end = skip_nodes(false);
assign_nodes(node, end); // Necessary if this wraps the sole child of a block, else end marker can be wrong
} }
if (expressions.length === 0 && blockers.every((b) => b.settled)) { if (expressions.length === 0 && blockers.every((b) => b.settled)) {

@ -7,7 +7,8 @@ import {
hydrating, hydrating,
skip_nodes, skip_nodes,
set_hydrate_node, set_hydrate_node,
set_hydrating set_hydrating,
hydrate_node
} from '../hydration.js'; } from '../hydration.js';
import { queue_micro_task } from '../task.js'; import { queue_micro_task } from '../task.js';
import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js';
@ -15,6 +16,7 @@ import { is_runes } from '../../context.js';
import { Batch, current_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 { BranchManager } from './branches.js';
import { capture, unset_context } from '../../reactivity/async.js'; import { capture, unset_context } from '../../reactivity/async.js';
import { DEV } from 'esm-env';
const PENDING = 0; const PENDING = 0;
const THEN = 1; const THEN = 1;
@ -42,16 +44,16 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
var value = runes ? source(v) : mutable_source(v, false, false); var value = runes ? source(v) : mutable_source(v, false, false);
var error = runes ? source(v) : mutable_source(v, false, false); var error = runes ? source(v) : mutable_source(v, false, false);
if (DEV) {
value.label = '{#await ...} value';
error.label = '{#await ...} error';
}
var branches = new BranchManager(node); var branches = new BranchManager(node);
block(() => { block(() => {
var batch = /** @type {Batch} */ (current_batch); var batch = /** @type {Batch} */ (current_batch);
// we null out `current_batch` because otherwise `save(...)` will incorrectly restore it —
// the batch will already have been committed by the time it resolves
batch.deactivate();
var input = get_input(); var input = get_input();
batch.activate();
var destroyed = false; var destroyed = false;
@ -79,15 +81,17 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
// We don't want to restore the previous batch here; {#await} blocks don't follow the async logic // We don't want to restore the previous batch here; {#await} blocks don't follow the async logic
// we have elsewhere, instead pending/resolve/fail states are each their own batch so to speak. // we have elsewhere, instead pending/resolve/fail states are each their own batch so to speak.
restore(false); restore(false);
// ...but it might still be set here. That means a `save(...)` has restored it — but that batch will
// likely already have been committed by the time it resolves, and this resolve should be processed
// in a separate batch. We're not using batch.deactivate()/activate() above because get_input()
// could write to sources, which would then incorrectly create a new batch or could mess with
// async_derived expecting a current_batch to exist.
if (current_batch === batch) {
batch.deactivate();
}
// Make sure we have a batch, since the branch manager expects one to exist // Make sure we have a batch, since the branch manager expects one to exist
Batch.ensure(); Batch.ensure();
if (hydrating) {
// `restore()` could set `hydrating` to `true`, which we very much
// don't want — we want to restore everything _except_ this
set_hydrating(false);
}
try { try {
fn(); fn();
} finally { } finally {

@ -396,7 +396,7 @@ export class Boundary {
if (this.#pending_effect) current_batch.skip_effect(this.#pending_effect); if (this.#pending_effect) current_batch.skip_effect(this.#pending_effect);
if (this.#failed_effect) current_batch.skip_effect(this.#failed_effect); if (this.#failed_effect) current_batch.skip_effect(this.#failed_effect);
current_batch.on_fork_commit(() => { current_batch.oncommit(() => {
this.#handle_error(error); this.#handle_error(error);
}); });
} else { } else {

@ -90,6 +90,8 @@ export class BranchManager {
var offscreen = this.#offscreen.get(key); var offscreen = this.#offscreen.get(key);
if (offscreen) { if (offscreen) {
// effect could have been outro'ed before through a prior batch — resume if necessary
resume_effect(offscreen.effect);
this.#onscreen.set(key, offscreen.effect); this.#onscreen.set(key, offscreen.effect);
this.#offscreen.delete(key); this.#offscreen.delete(key);

@ -203,7 +203,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var each_array = derived_safe_equal(() => { var each_array = derived_safe_equal(() => {
var collection = get_collection(); var collection = get_collection();
return is_array(collection) ? collection : collection == null ? [] : array_from(collection); return /** @type {V[]} */ (
is_array(collection) ? collection : collection == null ? [] : array_from(collection)
);
}); });
if (DEV) { if (DEV) {

@ -88,9 +88,11 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
assign_nodes(element, element); assign_nodes(element, element);
if (render_fn) { if (render_fn) {
var tmp_comment = null;
if (hydrating && is_raw_text_element(next_tag)) { if (hydrating && is_raw_text_element(next_tag)) {
// prevent hydration glitches // prevent hydration glitches (code just below expects an anchor)
element.append(document.createComment('')); element.append((tmp_comment = document.createComment('')));
} }
// If hydrating, use the existing ssr comment as the anchor so that the // If hydrating, use the existing ssr comment as the anchor so that the
@ -114,7 +116,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
// contains children, it's a user error (which is warned on elsewhere) // contains children, it's a user error (which is warned on elsewhere)
// and the DOM will be silently discarded // and the DOM will be silently discarded
render_fn(element, child_anchor); render_fn(element, child_anchor);
tmp_comment?.remove();
set_animation_effect_override(null); set_animation_effect_override(null);
} }

@ -332,6 +332,15 @@ function set_attributes(
var setters = get_setters(element); var setters = get_setters(element);
if (element.nodeName === INPUT_TAG && 'type' in next && ('value' in next || '__value' in next)) {
var type = next.type;
if (type !== current.type || (type === undefined && element.hasAttribute('type'))) {
current.type = type;
set_attribute(element, 'type', type, skip_warning);
}
}
// since key is captured we use const // since key is captured we use const
for (const key in next) { for (const key in next) {
// let instead of var because referenced in a closure // let instead of var because referenced in a closure

@ -7,7 +7,6 @@ import { is } from '../../../proxy.js';
import { queue_micro_task } from '../../task.js'; import { queue_micro_task } from '../../task.js';
import { hydrating } from '../../hydration.js'; import { hydrating } from '../../hydration.js';
import { tick, untrack } from '../../../runtime.js'; import { tick, untrack } from '../../../runtime.js';
import { is_runes } from '../../../context.js';
import { current_batch, previous_batch } from '../../../reactivity/batch.js'; import { current_batch, previous_batch } from '../../../reactivity/batch.js';
import { async_mode_flag } from '../../../../flags/index.js'; import { async_mode_flag } from '../../../../flags/index.js';

@ -257,12 +257,7 @@ export function handle_event_propagation(event) {
var other_errors = []; var other_errors = [];
while (current_target !== null) { while (current_target !== null) {
/** @type {null | Element} */ if (current_target === handler_element) break;
var parent_element =
current_target.assignedSlot ||
current_target.parentNode ||
/** @type {any} */ (current_target).host ||
null;
try { try {
// @ts-expect-error // @ts-expect-error
@ -284,10 +279,10 @@ export function handle_event_propagation(event) {
throw_error = error; throw_error = error;
} }
} }
if (event.cancelBubble || parent_element === handler_element || parent_element === null) { if (event.cancelBubble) break;
break;
} path_idx++;
current_target = parent_element; current_target = path_idx < path.length ? /** @type {Element} */ (path[path_idx]) : null;
} }
if (throw_error) { if (throw_error) {

@ -233,6 +233,12 @@ export function should_defer_append() {
} }
/** /**
* Branching here is intentional and load-bearing for perf. `createElement(tag)`
* hits a fast path in Blink that `createElementNS(NAMESPACE_HTML, tag)` doesn't,
* and passing an explicit `undefined` as the trailing options arg measurably
* slows both APIs. Funnelling every case through a single `createElementNS(ns,
* tag, options)` call would be smaller but slower on the HTML path.
*
* @template {keyof HTMLElementTagNameMap | string} T * @template {keyof HTMLElementTagNameMap | string} T
* @param {T} tag * @param {T} tag
* @param {string} [namespace] * @param {string} [namespace]
@ -240,9 +246,13 @@ export function should_defer_append() {
* @returns {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} * @returns {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element}
*/ */
export function create_element(tag, namespace, is) { export function create_element(tag, namespace, is) {
let options = is ? { is } : undefined; if (namespace == null || namespace === NAMESPACE_HTML) {
return /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ ( return /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ (
document.createElementNS(namespace ?? NAMESPACE_HTML, tag, options) is ? document.createElement(tag, { is }) : document.createElement(tag)
);
}
return /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ (
is ? document.createElementNS(namespace, tag, { is }) : document.createElementNS(namespace, tag)
); );
} }

@ -3,7 +3,7 @@
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { FILENAME } from '../../constants.js'; import { FILENAME } from '../../constants.js';
import { is_firefox } from './dom/operations.js'; import { is_firefox } from './dom/operations.js';
import { ERROR_VALUE, BOUNDARY_EFFECT, REACTION_RAN, EFFECT } from './constants.js'; import { ERROR_VALUE, BOUNDARY_EFFECT, REACTION_RAN, EFFECT, DESTROYED } from './constants.js';
import { define_property, get_descriptor } from '../shared/utils.js'; import { define_property, get_descriptor } from '../shared/utils.js';
import { active_effect, active_reaction } from './runtime.js'; import { active_effect, active_reaction } from './runtime.js';
@ -45,6 +45,10 @@ export function handle_error(error) {
* @param {Effect | null} effect * @param {Effect | null} effect
*/ */
export function invoke_error_boundary(error, effect) { export function invoke_error_boundary(error, effect) {
if (effect !== null && (effect.f & DESTROYED) !== 0) {
return;
}
while (effect !== null) { while (effect !== null) {
if ((effect.f & BOUNDARY_EFFECT) !== 0) { if ((effect.f & BOUNDARY_EFFECT) !== 0) {
if ((effect.f & REACTION_RAN) === 0) { if ((effect.f & REACTION_RAN) === 0) {

@ -180,4 +180,3 @@ export {
} from '../shared/validate.js'; } from '../shared/validate.js';
export { strict_equals, equals } from './dev/equality.js'; export { strict_equals, equals } from './dev/equality.js';
export { log_if_contains_state } from './dev/console-log.js'; export { log_if_contains_state } from './dev/console-log.js';
export { invoke_error_boundary } from './error-handling.js';

@ -1,4 +1,4 @@
/** @import { Blocker, Effect, Value } from '#client' */ /** @import { Blocker, Effect, Source, Value } from '#client' */
import { DESTROYED, STALE_REACTION } from '#client/constants'; import { DESTROYED, STALE_REACTION } from '#client/constants';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { import {
@ -38,8 +38,22 @@ export function flatten(blockers, sync, async, fn) {
// Filter out already-settled blockers - no need to wait for them // Filter out already-settled blockers - no need to wait for them
var pending = blockers.filter((b) => !b.settled); var pending = blockers.filter((b) => !b.settled);
var deriveds = sync.map(d);
if (DEV) {
deriveds.forEach((d, i) => {
// TODO this is kinda useful for debugging but a lousy implementation —
// maybe the compiler could pass through the template string
d.label = sync[i]
.toString()
.replace('() => ', '')
.replaceAll('$.eager(() => ', '$state.eager(')
.replace(/\$\.get\((.+?)\)/g, (_, id) => id);
});
}
if (async.length === 0 && pending.length === 0) { if (async.length === 0 && pending.length === 0) {
fn(sync.map(d)); fn(deriveds);
return; return;
} }
@ -53,8 +67,10 @@ export function flatten(blockers, sync, async, fn) {
? Promise.all(pending.map((b) => b.promise)) ? Promise.all(pending.map((b) => b.promise))
: null; : null;
/** @param {Value[]} values */ /**
function finish(values) { * @param {Source[]} async
*/
function finish(async) {
if ((parent.f & DESTROYED) !== 0) { if ((parent.f & DESTROYED) !== 0) {
return; return;
} }
@ -62,7 +78,7 @@ export function flatten(blockers, sync, async, fn) {
restore(); restore();
try { try {
fn(values); fn([...deriveds, ...async]);
} catch (error) { } catch (error) {
invoke_error_boundary(error, parent); invoke_error_boundary(error, parent);
} }
@ -74,17 +90,14 @@ export function flatten(blockers, sync, async, fn) {
// Fast path: blockers but no async expressions // Fast path: blockers but no async expressions
if (async.length === 0) { if (async.length === 0) {
/** @type {Promise<any>} */ (blocker_promise) /** @type {Promise<any>} */ (blocker_promise).then(() => finish([])).finally(decrement_pending);
.then(() => finish(sync.map(d)))
.finally(decrement_pending);
return; return;
} }
// Full path: has async expressions // Full path: has async expressions
function run() { function run() {
Promise.all(async.map((expression) => async_derived(expression))) Promise.all(async.map((expression) => async_derived(expression)))
.then((result) => finish([...sync.map(d), ...result])) .then(finish)
.catch((error) => invoke_error_boundary(error, parent)) .catch((error) => invoke_error_boundary(error, parent))
.finally(decrement_pending); .finally(decrement_pending);
} }
@ -168,10 +181,10 @@ export async function save(promise) {
* @returns {Promise<() => T>} * @returns {Promise<() => T>}
*/ */
export async function track_reactivity_loss(promise) { export async function track_reactivity_loss(promise) {
var previous_async_effect = reactivity_loss_tracker; var previous_reactivity_loss_tracker = reactivity_loss_tracker;
// Ensure that unrelated reads after an async operation is kicked off don't cause false positives // Ensure that unrelated reads after an async operation is kicked off don't cause false positives
queueMicrotask(() => { queueMicrotask(() => {
if (reactivity_loss_tracker === previous_async_effect) { if (reactivity_loss_tracker === previous_reactivity_loss_tracker) {
set_reactivity_loss_tracker(null); set_reactivity_loss_tracker(null);
} }
}); });
@ -179,12 +192,12 @@ export async function track_reactivity_loss(promise) {
var value = await promise; var value = await promise;
return () => { return () => {
set_reactivity_loss_tracker(previous_async_effect); set_reactivity_loss_tracker(previous_reactivity_loss_tracker);
// While this can result in false negatives it also guards against the more important // While this can result in false negatives it also guards against the more important
// false positives that would occur if this is the last in a chain of async operations, // false positives that would occur if this is the last in a chain of async operations,
// and the reactivity_loss_tracker would then stay around until the next async operation happens. // and the reactivity_loss_tracker would then stay around until the next async operation happens.
queueMicrotask(() => { queueMicrotask(() => {
if (reactivity_loss_tracker === previous_async_effect) { if (reactivity_loss_tracker === previous_reactivity_loss_tracker) {
set_reactivity_loss_tracker(null); set_reactivity_loss_tracker(null);
} }
}); });
@ -352,15 +365,15 @@ export function wait(blockers) {
*/ */
export function increment_pending() { export function increment_pending() {
var effect = /** @type {Effect} */ (active_effect); var effect = /** @type {Effect} */ (active_effect);
var boundary = /** @type {Boundary} */ (effect.b); var boundary = effect.b; // undefined if called outside the render tree, e.g. a standalone $effect.root
var batch = /** @type {Batch} */ (current_batch); var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered(); var blocking = !!boundary?.is_rendered();
boundary.update_pending_count(1, batch); boundary?.update_pending_count(1, batch);
batch.increment(blocking, effect); batch.increment(blocking, effect);
return () => { return () => {
boundary.update_pending_count(-1, batch); boundary?.update_pending_count(-1, batch);
batch.decrement(blocking, effect); batch.decrement(blocking, effect);
}; };
} }

@ -41,6 +41,7 @@ import { set_signal_status } from './status.js';
import { legacy_is_updating_store } from './store.js'; import { legacy_is_updating_store } from './store.js';
import { invariant } from '../../shared/dev.js'; import { invariant } from '../../shared/dev.js';
import { log_effect_tree } from '../dev/debug.js'; import { log_effect_tree } from '../dev/debug.js';
import { OBSOLETE } from './deriveds.js';
/** @type {Batch | null} */ /** @type {Batch | null} */
let first_batch = null; let first_batch = null;
@ -127,13 +128,6 @@ export class Batch {
*/ */
previous = new Map(); previous = new Map();
/**
* Async effects which this batch doesn't take into account anymore when calculating blockers,
* as it has a value for it already.
* @type {Set<Effect>}
*/
unblocked = new Set();
/** /**
* When the batch is committed (and the DOM is updated), we need to remove old branches * 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 * and append new ones by calling the functions added inside (if/each/key/etc) blocks
@ -147,12 +141,6 @@ export class Batch {
*/ */
#discard_callbacks = new Set(); #discard_callbacks = new Set();
/**
* Callbacks that should run only when a fork is committed.
* @type {Set<(batch: Batch) => void>}
*/
#fork_commit_callbacks = new Set();
/** /**
* The number of async effects that are currently in flight * The number of async effects that are currently in flight
*/ */
@ -214,6 +202,18 @@ export class Batch {
#decrement_queued = false; #decrement_queued = false;
constructor() {
// link batch
if (last_batch === null) {
first_batch = last_batch = this;
} else {
last_batch.#next = this;
this.#prev = last_batch;
}
last_batch = this;
}
#is_deferred() { #is_deferred() {
if (this.is_fork) return true; if (this.is_fork) return true;
@ -289,9 +289,10 @@ export class Batch {
} }
} }
// we only reschedule previously-deferred effects if we expect // We always reschedule previously-deferred effects, not just when
// to be able to run them after processing the batch // #is_deferred() is true, because traversing the tree could make
if (!this.#is_deferred()) { // an if block that contains the last blocking pending effect falsy,
// causing the block to no longer be deferred.
for (const e of this.#dirty_effects) { for (const e of this.#dirty_effects) {
this.#maybe_dirty_effects.delete(e); this.#maybe_dirty_effects.delete(e);
set_signal_status(e, DIRTY); set_signal_status(e, DIRTY);
@ -302,7 +303,6 @@ export class Batch {
set_signal_status(e, MAYBE_DIRTY); set_signal_status(e, MAYBE_DIRTY);
this.schedule(e); this.schedule(e);
} }
}
const roots = this.#roots; const roots = this.#roots;
this.#roots = []; this.#roots = [];
@ -326,6 +326,12 @@ export class Batch {
this.#traverse(root, effects, render_effects); this.#traverse(root, effects, render_effects);
} catch (e) { } catch (e) {
reset_all(root); reset_all(root);
// If there's no async work left, this branch is now dead and needs
// to be discarded to not become a zombie that is never cleaned up.
// See https://github.com/sveltejs/svelte/issues/18221#issuecomment-4497918414
// for a (non-minimal) reproduction that demonstrates a case where this is necessary
// to not get follow-up false-positives via "batch has scheduled roots" invariant errors.
if (!this.#is_deferred()) this.discard();
throw e; throw e;
} }
} }
@ -362,6 +368,10 @@ export class Batch {
const earlier_batch = this.#find_earlier_batch(); const earlier_batch = this.#find_earlier_batch();
if (earlier_batch) { if (earlier_batch) {
// If this batch collected deferred effects during traversal, they still need
// to run after being merged into the earlier batch.
this.#defer_effects(render_effects);
this.#defer_effects(effects);
earlier_batch.#merge(this); earlier_batch.#merge(this);
return; return;
} }
@ -383,31 +393,30 @@ export class Batch {
var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
if (this.linked && this.#pending === 0) { if (this.#pending === 0 && (this.#roots.length === 0 || next_batch !== null)) {
this.#unlink(); this.#unlink();
}
// Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around,
// else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong.
// In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode
// TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed
if (async_mode_flag && !this.linked) { if (async_mode_flag) {
this.#commit(); this.#commit();
// Rebases can activate other batches or null it out, therefore restore the new one here // Rebases can activate other batches or null it out, therefore restore the new one here
current_batch = next_batch; current_batch = next_batch;
} }
}
// Edge case: During traversal new branches might create effects that run immediately and set state, // Edge case: During traversal new branches might create effects that run immediately and set state,
// causing an effect and therefore a root to be scheduled again. We need to traverse the current batch // causing an effect and therefore a root to be scheduled again. We need to traverse the current batch
// once more in that case - most of the time this will just clean up dirty branches. // once more in that case - most of the time this will just clean up dirty branches.
if (this.#roots.length > 0) { if (this.#roots.length > 0) {
if (next_batch === null) { if (next_batch !== null) {
next_batch = this;
this.#link();
}
const batch = next_batch; const batch = next_batch;
batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r)));
} else {
next_batch = this;
}
} }
if (next_batch !== null) { if (next_batch !== null) {
@ -500,9 +509,16 @@ export class Batch {
for (const [effect, deferred] of batch.async_deriveds) { for (const [effect, deferred] of batch.async_deriveds) {
const d = this.async_deriveds.get(effect); const d = this.async_deriveds.get(effect);
if (d) deferred.promise.then(d.resolve); if (d) deferred.promise.then(d.resolve).catch(d.reject);
} }
// Clear them or else those that are still pending might get rejected on discard (after merged-into batch is done).
// This can happen when batch Y merged into X and Y has a pending boundary and therefore still-pending async deriveds inside.
batch.async_deriveds.clear();
// Mark is not guaranteed not touch these, so we transfer them
this.transfer_effects(batch.#dirty_effects, batch.#maybe_dirty_effects);
/** /**
* mark all effects that depend on `batch.current`, except the * mark all effects that depend on `batch.current`, except the
* async effects that we just resolved (TODO unless they depend * async effects that we just resolved (TODO unless they depend
@ -617,9 +633,13 @@ export class Batch {
discard() { discard() {
for (const fn of this.#discard_callbacks) fn(this); for (const fn of this.#discard_callbacks) fn(this);
this.#discard_callbacks.clear(); this.#discard_callbacks.clear();
this.#fork_commit_callbacks.clear();
for (const deferred of this.async_deriveds.values()) {
deferred.reject(OBSOLETE);
}
this.#unlink(); this.#unlink();
this.#deferred?.resolve();
} }
/** /**
@ -630,8 +650,6 @@ export class Batch {
} }
#commit() { #commit() {
this.#unlink();
// If there are other pending batches, they now need to be 'rebased' — // If there are other pending batches, they now need to be 'rebased' —
// in other words, we re-run block/async effects with the newly // in other words, we re-run block/async effects with the newly
// committed state, unless the batch in question has a more // committed state, unless the batch in question has a more
@ -664,14 +682,19 @@ export class Batch {
// immediately resolving them? Likely not because of how this.apply() works. // immediately resolving them? Likely not because of how this.apply() works.
for (const [effect, deferred] of this.async_deriveds) { for (const [effect, deferred] of this.async_deriveds) {
const d = batch.async_deriveds.get(effect); const d = batch.async_deriveds.get(effect);
if (d) deferred.promise.then(d.resolve); if (d) deferred.promise.then(d.resolve).catch(d.reject);
} }
} }
if (!batch.#started) continue; var current = [...batch.current.keys()].filter(
(source) => !(/** @type {[any, boolean]} */ (batch.current.get(source))[1])
);
// Re-run async/block effects that depend on distinct values changed in both batches // If not started yet or no sources to update (which is e.g. possible for the very first batch) then bail
var others = [...batch.current.keys()].filter((s) => !this.current.has(s)); if (!batch.#started || current.length === 0) continue;
// Re-run async/block effects that depend on distinct values changed in both batches (ignoring deriveds)
var others = current.filter((source) => !this.current.has(source));
if (others.length === 0) { if (others.length === 0) {
if (is_earlier) { if (is_earlier) {
@ -679,7 +702,9 @@ export class Batch {
batch.discard(); batch.discard();
} }
} else if (sources.length > 0) { } else if (sources.length > 0) {
if (DEV) { // The microtask queue can contain the batch already scheduled to run right
// after this one is finished, so throwing the invariant would be wrong here.
if (DEV && !batch.#decrement_queued) {
invariant(batch.#roots.length === 0, 'Batch has scheduled roots'); invariant(batch.#roots.length === 0, 'Batch has scheduled roots');
} }
@ -709,11 +734,14 @@ export class Batch {
} }
checked = new Map(); checked = new Map();
var current_unequal = [...batch.current.keys()].filter((c) => var current_unequal = [...batch.current]
this.current.has(c) .filter(([c, v1]) => {
? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c.v const v2 = this.current.get(c);
: true if (!v2) return true;
); // Either their values are different or one is a derived but not the other
return v2[0] !== v1[0] || v2[1] !== v1[1];
})
.map(([c]) => c);
if (current_unequal.length > 0) { if (current_unequal.length > 0) {
for (const effect of this.#new_effects) { for (const effect of this.#new_effects) {
@ -732,7 +760,8 @@ export class Batch {
} }
// Only apply and traverse when we know we triggered async work with marking the effects // Only apply and traverse when we know we triggered async work with marking the effects
if (batch.#roots.length > 0) { // and know this won't run anyway right afterwards
if (batch.#roots.length > 0 && !batch.#decrement_queued) {
batch.apply(); batch.apply();
for (var root of batch.#roots) { for (var root of batch.#roots) {
@ -816,16 +845,6 @@ export class Batch {
this.#discard_callbacks.add(fn); this.#discard_callbacks.add(fn);
} }
/** @param {(batch: Batch) => void} fn */
on_fork_commit(fn) {
this.#fork_commit_callbacks.add(fn);
}
run_fork_commit_callbacks() {
for (const fn of this.#fork_commit_callbacks) fn(this);
this.#fork_commit_callbacks.clear();
}
settled() { settled() {
return (this.#deferred ??= deferred()).promise; return (this.#deferred ??= deferred()).promise;
} }
@ -833,7 +852,6 @@ export class Batch {
static ensure() { static ensure() {
if (current_batch === null) { if (current_batch === null) {
const batch = (current_batch = new Batch()); const batch = (current_batch = new Batch());
batch.#link();
if (!is_processing && !is_flushing_sync) { if (!is_processing && !is_flushing_sync) {
queue_micro_task(() => { queue_micro_task(() => {
@ -953,18 +971,11 @@ export class Batch {
this.#roots.push(e); this.#roots.push(e);
} }
#link() {
if (last_batch === null) {
first_batch = last_batch = this;
} else {
last_batch.#next = this;
this.#prev = last_batch;
}
last_batch = this;
}
#unlink() { #unlink() {
// #merge calls #unlink, discard later on does it again - prevent
// running it multiple times to not corrupt the linked list
if (!this.linked) return;
var prev = this.#prev; var prev = this.#prev;
var next = this.#next; var next = this.#next;
@ -1394,10 +1405,6 @@ export function fork(fn) {
source.wv = increment_write_version(); source.wv = increment_write_version();
} }
batch.activate();
batch.run_fork_commit_callbacks();
batch.deactivate();
// trigger any `$state.eager(...)` expressions with the new state. // trigger any `$state.eager(...)` expressions with the new state.
// eager effects don't get scheduled like other effects, so we // eager effects don't get scheduled like other effects, so we
// can't just encounter them during traversal, we need to // can't just encounter them during traversal, we need to

@ -187,7 +187,10 @@ export function async_derived(fn, label, location) {
var decrement_pending = increment_pending(); var decrement_pending = increment_pending();
} }
if (/** @type {Boundary} */ (parent.b).is_rendered()) { if (
// boundary can be null if the async derived is inside an $effect.root not connected to the component render tree
parent.b?.is_rendered()
) {
batch.async_deriveds.get(effect)?.reject(OBSOLETE); batch.async_deriveds.get(effect)?.reject(OBSOLETE);
} else { } else {
// While the boundary is still showing pending, a new run supersedes all older in-flight runs // While the boundary is still showing pending, a new run supersedes all older in-flight runs
@ -227,9 +230,7 @@ export function async_derived(fn, label, location) {
signal.f ^= ERROR_VALUE; signal.f ^= ERROR_VALUE;
} }
internal_set(signal, value); if (DEV && location !== undefined && !signal.equals(value)) {
if (DEV && location !== undefined) {
recent_async_deriveds.add(signal); recent_async_deriveds.add(signal);
setTimeout(() => { setTimeout(() => {
@ -239,6 +240,8 @@ export function async_derived(fn, label, location) {
} }
}); });
} }
internal_set(signal, value);
} }
batch.deactivate(); batch.deactivate();

@ -20,7 +20,6 @@ import {
EFFECT, EFFECT,
DESTROYED, DESTROYED,
INERT, INERT,
REACTION_RAN,
BLOCK_EFFECT, BLOCK_EFFECT,
ROOT_EFFECT, ROOT_EFFECT,
EFFECT_TRANSPARENT, EFFECT_TRANSPARENT,
@ -213,7 +212,11 @@ export function user_effect(fn) {
// Non-nested `$effect(...)` in a component should be deferred // Non-nested `$effect(...)` in a component should be deferred
// until the component is mounted // until the component is mounted
var flags = /** @type {Effect} */ (active_effect).f; var flags = /** @type {Effect} */ (active_effect).f;
var defer = !active_reaction && (flags & BRANCH_EFFECT) !== 0 && (flags & REACTION_RAN) === 0; var defer =
!active_reaction &&
(flags & BRANCH_EFFECT) !== 0 &&
component_context !== null &&
!component_context.i;
if (defer) { if (defer) {
// Top-level `$effect(...)` in an unmounted component — defer until mount // Top-level `$effect(...)` in an unmounted component — defer until mount
@ -384,7 +387,9 @@ export function render_effect(fn, flags = 0) {
*/ */
export function template_effect(fn, sync = [], async = [], blockers = []) { export function template_effect(fn, sync = [], async = [], blockers = []) {
flatten(blockers, sync, async, (values) => { flatten(blockers, sync, async, (values) => {
create_effect(RENDER_EFFECT, () => fn(...values.map(get))); create_effect(RENDER_EFFECT, () => {
fn(...values.map(get));
});
}); });
} }
@ -515,7 +520,7 @@ export function destroy_effect(effect, remove_dom = true) {
removed = true; removed = true;
} }
set_signal_status(effect, DESTROYING); effect.f |= DESTROYING;
destroy_effect_children(effect, remove_dom && !removed); destroy_effect_children(effect, remove_dom && !removed);
remove_reactions(effect, 0); remove_reactions(effect, 0);

@ -49,11 +49,11 @@ export function update_pre_prop(fn, d = 1) {
/** /**
* The proxy handler for rest props (i.e. `const { x, ...rest } = $props()`). * The proxy handler for rest props (i.e. `const { x, ...rest } = $props()`).
* Is passed the full `$$props` object and excludes the named props. * Is passed the full `$$props` object and excludes the named props.
* @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Array<string | symbol>, name?: string }>}} * @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Set<string | symbol>, name?: string }>}}
*/ */
const rest_props_handler = { const rest_props_handler = {
get(target, key) { get(target, key) {
if (target.exclude.includes(key)) return; if (target.exclude.has(key)) return;
return target.props[key]; return target.props[key];
}, },
set(target, key) { set(target, key) {
@ -65,7 +65,7 @@ const rest_props_handler = {
return false; return false;
}, },
getOwnPropertyDescriptor(target, key) { getOwnPropertyDescriptor(target, key) {
if (target.exclude.includes(key)) return; if (target.exclude.has(key)) return;
if (key in target.props) { if (key in target.props) {
return { return {
enumerable: true, enumerable: true,
@ -75,17 +75,17 @@ const rest_props_handler = {
} }
}, },
has(target, key) { has(target, key) {
if (target.exclude.includes(key)) return false; if (target.exclude.has(key)) return false;
return key in target.props; return key in target.props;
}, },
ownKeys(target) { ownKeys(target) {
return Reflect.ownKeys(target.props).filter((key) => !target.exclude.includes(key)); return Reflect.ownKeys(target.props).filter((key) => !target.exclude.has(key));
} }
}; };
/** /**
* @param {Record<string, unknown>} props * @param {Record<string, unknown>} props
* @param {string[]} exclude * @param {Set<string>} exclude
* @param {string} [name] * @param {string} [name]
* @returns {Record<string, unknown>} * @returns {Record<string, unknown>}
*/ */

@ -32,7 +32,6 @@ import {
} from '#client/constants'; } from '#client/constants';
import * as e from '../errors.js'; import * as e from '../errors.js';
import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { includes } from '../../shared/utils.js';
import { tag_proxy } from '../dev/tracing.js'; import { tag_proxy } from '../dev/tracing.js';
import { get_error } from '../../shared/dev.js'; import { get_error } from '../../shared/dev.js';
import { component_context, is_runes } from '../context.js'; import { component_context, is_runes } from '../context.js';
@ -158,7 +157,7 @@ export function set(source, value, should_proxy = false) {
(!untracking || (active_reaction.f & EAGER_EFFECT) !== 0) && (!untracking || (active_reaction.f & EAGER_EFFECT) !== 0) &&
is_runes() && is_runes() &&
(active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | EAGER_EFFECT)) !== 0 && (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | EAGER_EFFECT)) !== 0 &&
(current_sources === null || !includes.call(current_sources, source)) (current_sources === null || !current_sources.has(source))
) { ) {
e.state_unsafe_mutation(); e.state_unsafe_mutation();
} }

@ -51,6 +51,7 @@ import {
batch_values, batch_values,
current_batch, current_batch,
flushSync, flushSync,
previous_batch,
schedule_effect schedule_effect
} from './reactivity/batch.js'; } from './reactivity/batch.js';
import { handle_error } from './error-handling.js'; import { handle_error } from './error-handling.js';
@ -90,18 +91,14 @@ export function set_active_effect(effect) {
/** /**
* When sources are created within a reaction, reading and writing * When sources are created within a reaction, reading and writing
* them within that reaction should not cause a re-run * them within that reaction should not cause a re-run
* @type {null | Source[]} * @type {null | Set<Source>}
*/ */
export let current_sources = null; export let current_sources = null;
/** @param {Value} value */ /** @param {Value} value */
export function push_reaction_value(value) { export function push_reaction_value(value) {
if (active_reaction !== null && (!async_mode_flag || (active_reaction.f & DERIVED) !== 0)) { if (active_reaction !== null && (!async_mode_flag || (active_reaction.f & DERIVED) !== 0)) {
if (current_sources === null) { (current_sources ??= new Set()).add(value);
current_sources = [value];
} else {
current_sources.push(value);
}
} }
} }
@ -202,7 +199,7 @@ function schedule_possible_effect_self_invalidation(signal, effect, root = true)
var reactions = signal.reactions; var reactions = signal.reactions;
if (reactions === null) return; if (reactions === null) return;
if (!async_mode_flag && current_sources !== null && includes.call(current_sources, signal)) { if (!async_mode_flag && current_sources !== null && current_sources.has(signal)) {
return; return;
} }
@ -540,7 +537,7 @@ export function get(signal) {
// we don't add the dependency, because that would create a memory leak // we don't add the dependency, because that would create a memory leak
var destroyed = active_effect !== null && (active_effect.f & DESTROYED) !== 0; var destroyed = active_effect !== null && (active_effect.f & DESTROYED) !== 0;
if (!destroyed && (current_sources === null || !includes.call(current_sources, signal))) { if (!destroyed && (current_sources === null || !current_sources.has(signal))) {
var deps = active_reaction.deps; var deps = active_reaction.deps;
if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) { if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) {
@ -560,9 +557,15 @@ export function get(signal) {
} }
} }
} else { } else {
// we're adding a dependency outside the init/update cycle // We're adding a dependency outside the init/update cycle (i.e. after an `await`).
// (i.e. after an `await`) // We have to deduplicate deps/reactions in this case or remove_reactions could
(active_reaction.deps ??= []).push(signal); // disconnect deps/reactions that are actually still in use (if skip_deps says
// "disconnect all after this index" and some of the signals are also present in
// list prior to the cutoff index, i.e. that should be kept).
active_reaction.deps ??= [];
if (!includes.call(active_reaction.deps, signal)) {
active_reaction.deps.push(signal);
}
var reactions = signal.reactions; var reactions = signal.reactions;
@ -579,6 +582,11 @@ export function get(signal) {
if ( if (
!untracking && !untracking &&
reactivity_loss_tracker && reactivity_loss_tracker &&
// By checking that current/previous batch are null we filter out false positives.
// reactivity_loss_tracker is only reset after a microtask, so if a flush happens
// before that, we get warnings for things we shouldn't warn on.
current_batch === null &&
previous_batch === null &&
!reactivity_loss_tracker.warned && !reactivity_loss_tracker.warned &&
(reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0 && (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0 &&
!reactivity_loss_tracker.effect_deps.has(signal) !reactivity_loss_tracker.effect_deps.has(signal)

@ -132,11 +132,12 @@ export class SvelteURLSearchParams extends URLSearchParams {
* @returns {void} * @returns {void}
*/ */
set(name, value) { set(name, value) {
var previous = super.getAll(name).join(''); var previous = super.getAll(name);
super.set(name, value); super.set(name, value);
// can't use has(name, value), because for something like https://svelte.dev?foo=1&bar=2&foo=3 // can't use has(name, value), because for something like https://svelte.dev?foo=1&bar=2&foo=3
// if you set `foo` to 1, then foo=3 gets deleted whilst `has("foo", "1")` returns true // if you set `foo` to 1, then foo=3 gets deleted whilst `has("foo", "1")` returns true
if (previous !== super.getAll(name).join('')) { var current = super.getAll(name);
if (previous.length !== current.length || previous.some((value, i) => value !== current[i])) {
this.#update_url(); this.#update_url();
increment(this.#version); increment(this.#version);
} }

@ -55,6 +55,44 @@ test('URLSearchParams.set', () => {
cleanup(); cleanup();
}); });
test('URLSearchParams.set updates when duplicate values collapse to the same joined string', () => {
const params = new SvelteURLSearchParams('a=ab&a=c');
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(params.toString());
});
});
flushSync(() => {
params.set('a', 'abc');
});
assert.deepEqual(log, ['a=ab&a=c', 'a=abc']);
cleanup();
});
test('URLSearchParams.set updates when duplicate values collapse to the same comma-joined string', () => {
const params = new SvelteURLSearchParams('a=a&a=b');
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(params.toString());
});
});
flushSync(() => {
params.set('a', 'a,b');
});
assert.deepEqual(log, ['a=a&a=b', 'a=a%2Cb']);
cleanup();
});
test('URLSearchParams.append', () => { test('URLSearchParams.append', () => {
const params = new SvelteURLSearchParams(); const params = new SvelteURLSearchParams();
const log: any = []; const log: any = [];

@ -115,6 +115,25 @@ test('url.searchParams', () => {
cleanup(); cleanup();
}); });
test('url.searchParams.set updates url when duplicate values collapse to the same joined string', () => {
const url = new SvelteURL('https://svelte.dev?a=ab&a=c');
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(url.href);
});
});
flushSync(() => {
url.searchParams.set('a', 'abc');
});
assert.deepEqual(log, ['https://svelte.dev/?a=ab&a=c', 'https://svelte.dev/?a=abc']);
cleanup();
});
test('url.search normalizes value', () => { test('url.search normalizes value', () => {
const url = new SvelteURL('https://svelte.dev'); const url = new SvelteURL('https://svelte.dev');
const log: any = []; const log: any = [];

@ -434,7 +434,7 @@ const STATE_CREATION_RUNES = /** @type {const} */ ([
'$derived.by' '$derived.by'
]); ]);
const RUNES = /** @type {const} */ ([ export const RUNES = /** @type {const} */ ([
...STATE_CREATION_RUNES, ...STATE_CREATION_RUNES,
'$state.eager', '$state.eager',
'$state.snapshot', '$state.snapshot',

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

@ -0,0 +1,11 @@
import { test } from '../../test';
// A tag whose expression ends with a bare `/` at the end of the input used to
// make `find_matching_bracket` loop forever; it should error instead of hanging.
export default test({
error: {
code: 'unexpected_eof',
message: 'Unexpected end of input',
position: [12, 12]
}
});

@ -1 +1 @@
<!--[--><!----><script>{}<!----></script><!----><!--]--> <!--[--><!----><script>{}</script><!----><!--]-->

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
props: {
css: 'body { color: red; }'
}
});

@ -0,0 +1,9 @@
<script>
let { css } = $props();
</script>
<svelte:head>
<svelte:element this="style" type="text/css">{css}</svelte:element>
</svelte:head>
<p>content</p>

@ -25,7 +25,21 @@ interface HydrationTest extends BaseTest {
expect_hydration_error?: true; expect_hydration_error?: true;
snapshot?: (target: HTMLElement) => any; snapshot?: (target: HTMLElement) => any;
test?: ( test?: (
assert: typeof import('vitest').assert & { // `_config.js` test callbacks rely on inferred parameter types, which
// TS treats as non-explicit and rejects for chai 5's assertion-function
// signatures (TS2775). Override the assertion methods we actually use
// with non-assertion equivalents.
assert: Omit<
typeof import('vitest').assert,
'ok' | 'isOk' | 'isTrue' | 'isFalse' | 'exists' | 'notExists' | 'instanceOf'
> & {
ok(value: unknown, message?: string): void;
isOk(value: unknown, message?: string): void;
isTrue(value: unknown, message?: string): void;
isFalse(value: unknown, message?: string): void;
exists(value: unknown, message?: string): void;
notExists(value: unknown, message?: string): void;
instanceOf(value: unknown, type: Function, message?: string): void;
htmlEqual(a: string, b: string, description?: string): void; htmlEqual(a: string, b: string, description?: string): void;
}, },
target: HTMLElement, target: HTMLElement,
@ -58,7 +72,7 @@ const { test, run } = suite<HydrationTest>(async (config, cwd) => {
const target = window.document.body; const target = window.document.body;
const head = window.document.head; const head = window.document.head;
const rendered = render((await import(`${cwd}/_output/server/main.svelte.js`)).default, { const rendered = await render((await import(`${cwd}/_output/server/main.svelte.js`)).default, {
props: config.server_props ?? config.props ?? {}, props: config.server_props ?? config.props ?? {},
idPrefix: config?.id_prefix idPrefix: config?.id_prefix
}); });
@ -66,8 +80,8 @@ const { test, run } = suite<HydrationTest>(async (config, cwd) => {
const override = read(`${cwd}/_override.html`); const override = read(`${cwd}/_override.html`);
const override_head = read(`${cwd}/_override_head.html`); const override_head = read(`${cwd}/_override_head.html`);
fs.writeFileSync(`${cwd}/_output/body.html`, rendered.html + '\n'); fs.writeFileSync(`${cwd}/_output/body.html`, rendered.body + '\n');
target.innerHTML = override ?? rendered.html; target.innerHTML = override ?? rendered.body;
if (rendered.head) { if (rendered.head) {
fs.writeFileSync(`${cwd}/_output/head.html`, rendered.head + '\n'); fs.writeFileSync(`${cwd}/_output/head.html`, rendered.head + '\n');
@ -131,7 +145,7 @@ const { test, run } = suite<HydrationTest>(async (config, cwd) => {
flushSync(); flushSync();
const expected = read(`${cwd}/_expected.html`) ?? rendered.html; const expected = read(`${cwd}/_expected.html`) ?? rendered.body;
assert_html_equal(target.innerHTML, expected); assert_html_equal(target.innerHTML, expected);
if (rendered.head) { if (rendered.head) {
@ -152,7 +166,6 @@ const { test, run } = suite<HydrationTest>(async (config, cwd) => {
if (config.test) { if (config.test) {
await config.test( await config.test(
// @ts-expect-error TS doesn't get it
{ {
...assert, ...assert,
htmlEqual: assert_html_equal htmlEqual: assert_html_equal

@ -0,0 +1,5 @@
{#if true}
{let }
{const x = }
{let x = a / }
{/if}

@ -0,0 +1,145 @@
{
"css": null,
"js": [],
"start": 0,
"end": 54,
"type": "Root",
"fragment": {
"type": "Fragment",
"nodes": [
{
"type": "IfBlock",
"elseif": false,
"start": 0,
"end": 54,
"test": {
"type": "Literal",
"start": 5,
"end": 9,
"loc": {
"start": {
"line": 1,
"column": 5
},
"end": {
"line": 1,
"column": 9
}
},
"value": true,
"raw": "true"
},
"consequent": {
"type": "Fragment",
"nodes": [
{
"type": "Text",
"start": 10,
"end": 12,
"raw": "\n\t",
"data": "\n\t"
},
{
"type": "DeclarationTag",
"start": 12,
"end": 18,
"declaration": {
"type": "VariableDeclaration",
"kind": "let",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "",
"start": 17,
"end": 17
},
"init": null,
"start": 17,
"end": 17
}
],
"start": 13,
"end": 17
}
},
{
"type": "Text",
"start": 18,
"end": 20,
"raw": "\n\t",
"data": "\n\t"
},
{
"type": "DeclarationTag",
"start": 20,
"end": 32,
"declaration": {
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "",
"start": 31,
"end": 31
},
"init": null,
"start": 31,
"end": 31
}
],
"start": 21,
"end": 31
}
},
{
"type": "Text",
"start": 32,
"end": 34,
"raw": "\n\t",
"data": "\n\t"
},
{
"type": "DeclarationTag",
"start": 34,
"end": 48,
"declaration": {
"type": "VariableDeclaration",
"kind": "let",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "",
"start": 47,
"end": 47
},
"init": null,
"start": 47,
"end": 47
}
],
"start": 35,
"end": 47
}
},
{
"type": "Text",
"start": 48,
"end": 49,
"raw": "\n",
"data": "\n"
}
]
},
"alternate": null
}
]
},
"options": null
}

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

Loading…
Cancel
Save