diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0e1d36760..046ad335f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,23 @@ jobs: - run: pnpm test env: CI: true + TestNoAsync: + permissions: {} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm playwright install chromium + - run: pnpm test runtime-runes + env: + CI: true + SVELTE_NO_ASYNC: true Lint: permissions: {} runs-on: ubuntu-latest diff --git a/.github/workflows/ecosystem-ci-trigger.yml b/.github/workflows/ecosystem-ci-trigger.yml index 71df3242e8..9be1f00104 100644 --- a/.github/workflows/ecosystem-ci-trigger.yml +++ b/.github/workflows/ecosystem-ci-trigger.yml @@ -8,9 +8,17 @@ jobs: trigger: runs-on: ubuntu-latest if: github.repository == 'sveltejs/svelte' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/ecosystem-ci run') + permissions: + issues: write # to add / delete reactions + pull-requests: write # to read PR data, and to add labels + actions: read # to check workflow status + contents: read # to clone the repo steps: - - uses: GitHubSecurityLab/actions-permissions/monitor@v1 - - uses: actions/github-script@v6 + - name: monitor action permissions + uses: GitHubSecurityLab/actions-permissions/monitor@v1 + - name: check user authorization # user needs triage permission + uses: actions/github-script@v7 + id: check-permissions with: script: | const user = context.payload.sender.login @@ -29,7 +37,7 @@ jobs: } if (hasTriagePermission) { - console.log('Allowed') + console.log('User is allowed. Adding +1 reaction.') await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, @@ -37,16 +45,18 @@ jobs: content: '+1', }) } else { - console.log('Not allowed') + console.log('User is not allowed. Adding -1 reaction.') await github.rest.reactions.createForIssueComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, content: '-1', }) - throw new Error('not allowed') + throw new Error('User does not have the necessary permissions.') } - - uses: actions/github-script@v6 + + - name: Get PR Data + uses: actions/github-script@v7 id: get-pr-data with: script: | @@ -59,21 +69,27 @@ jobs: return { num: context.issue.number, branchName: pr.head.ref, + commit: pr.head.sha, repo: pr.head.repo.full_name } - - id: generate-token - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 #keep pinned for security reasons, currently 1.8.0 + + - name: Generate Token + id: generate-token + uses: actions/create-github-app-token@v2 with: - app_id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }} - private_key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }} - repository: '${{ github.repository_owner }}/svelte-ecosystem-ci' - - uses: actions/github-script@v6 + app-id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }} + private-key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }} + repositories: | + svelte + svelte-ecosystem-ci + + - name: Trigger Downstream Workflow + uses: actions/github-script@v7 id: trigger env: COMMENT: ${{ github.event.comment.body }} with: github-token: ${{ steps.generate-token.outputs.token }} - result-encoding: string script: | const comment = process.env.COMMENT.trim() const prData = ${{ steps.get-pr-data.outputs.result }} @@ -89,6 +105,7 @@ jobs: prNumber: '' + prData.num, branchName: prData.branchName, repo: prData.repo, + commit: prData.commit, suite: suite === '' ? '-' : suite } }) diff --git a/.prettierignore b/.prettierignore index d5c124353c..72cd10aca8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,7 @@ packages/**/config/*.js # packages/svelte packages/svelte/messages/**/*.md +packages/svelte/scripts/_bundle.js packages/svelte/src/compiler/errors.js packages/svelte/src/compiler/warnings.js packages/svelte/src/internal/client/errors.js @@ -25,8 +26,7 @@ packages/svelte/tests/hydration/samples/*/_expected.html packages/svelte/tests/hydration/samples/*/_override.html packages/svelte/types packages/svelte/compiler/index.js -playgrounds/sandbox/input/**.svelte -playgrounds/sandbox/output +playgrounds/sandbox/src/* # sites/svelte.dev sites/svelte.dev/static/svelte-app.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e2628f84f..c2d3e45049 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,10 +105,10 @@ Test samples are kept in `/test/xxx/samples` folder. pnpm test validator ``` -1. To filter tests _within_ a test suite, use `pnpm test -- -t `, for example: +1. To filter tests _within_ a test suite, use `pnpm test -t `, for example: ```bash - pnpm test validator -- -t a11y-alt-text + pnpm test validator -t a11y-alt-text ``` (You can also do `FILTER= pnpm test ` which removes other tests rather than simply skipping them — this will result in faster and more compact test results, but it's non-idiomatic. Choose your fighter.) diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md index 8e6c91fad7..aea427a8ec 100644 --- a/documentation/docs/02-runes/02-$state.md +++ b/documentation/docs/02-runes/02-$state.md @@ -50,7 +50,7 @@ todos.push({ }); ``` -> [!NOTE] When you update properties of proxies, the original object is _not_ mutated. +> [!NOTE] When you update properties of proxies, the original object is _not_ mutated. If you need to use your own proxy handlers in a state proxy, [you should wrap the object _after_ wrapping it in `$state`](https://svelte.dev/playground/hello-world?version=latest#H4sIAAAAAAAACpWR3WoDIRCFX2UqhWyIJL3erAulL9C7XnQLMe5ksbUqOpsfln33YuyGFNJC8UKdc2bOhw7Myk9kJXsJ0nttO9jcR5KEG9AWJDwHdzwxznbaYGTl68Do5JM_FRifuh-9X8Y9Gkq1rYx4q66cJbQUWcmqqIL2VDe2IYMEbvuOikBADi-GJDSkXG-phId0G-frye2DO2psQYDFQ0Ys8gQO350dUkEydEg82T0GOs0nsSG9g2IqgxACZueo2ZUlpdvoDC6N64qsg1QKY8T2bpZp8gpIfbCQ85Zn50Ud82HkeY83uDjspenxv3jXcSDyjPWf9L1vJf0GH666J-jLu1ery4dV257IWXBWGa0-xFDMQdTTn2ScxWKsn86ROsLwQxqrVR5QM84Ij8TKFD2-cUZSm4O2LSt30kQcvwCgCmfZnAIAAA==). Note that if you destructure a reactive value, the references are not reactive — as in normal JavaScript, they are evaluated at the point of destructuring: @@ -119,7 +119,7 @@ class Todo { } ``` -> Svelte provides reactive implementations of built-in classes like `Set` and `Map` that can be imported from [`svelte/reactivity`](svelte-reactivity). +> [NOTE!] Svelte provides reactive implementations of built-in classes like `Set` and `Map` that can be imported from [`svelte/reactivity`](svelte-reactivity). ## `$state.raw` diff --git a/documentation/docs/02-runes/03-$derived.md b/documentation/docs/02-runes/03-$derived.md index 2464aa9295..5f253cf6d1 100644 --- a/documentation/docs/02-runes/03-$derived.md +++ b/documentation/docs/02-runes/03-$derived.md @@ -94,6 +94,23 @@ let selected = $derived(items[index]); ...you can change (or `bind:` to) properties of `selected` and it will affect the underlying `items` array. If `items` was _not_ deeply reactive, mutating `selected` would have no effect. +## Destructuring + +If you use destructuring with a `$derived` declaration, the resulting variables will all be reactive — this... + +```js +let { a, b, c } = $derived(stuff()); +``` + +...is roughly equivalent to this: + +```js +let _stuff = $derived(stuff()); +let a = $derived(_stuff.a); +let b = $derived(_stuff.b); +let c = $derived(_stuff.c); +``` + ## Update propagation Svelte uses something called _push-pull reactivity_ — when state is updated, everything that depends on the state (whether directly or indirectly) is immediately notified of the change (the 'push'), but derived values are not re-evaluated until they are actually read (the 'pull'). diff --git a/documentation/docs/02-runes/04-$effect.md b/documentation/docs/02-runes/04-$effect.md index 0e129973d5..5820e178a0 100644 --- a/documentation/docs/02-runes/04-$effect.md +++ b/documentation/docs/02-runes/04-$effect.md @@ -221,6 +221,21 @@ The `$effect.tracking` rune is an advanced feature that tells you whether or not It is used to implement abstractions like [`createSubscriber`](/docs/svelte/svelte-reactivity#createSubscriber), which will create listeners to update reactive values but _only_ if those values are being tracked (rather than, for example, read inside an event handler). +## `$effect.pending` + +When using [`await`](await-expressions) in components, the `$effect.pending()` rune tells you how many promises are pending in the current [boundary](svelte-boundary), not including child boundaries ([demo](/playground/untitled#H4sIAAAAAAAAE3WRMU_DMBCF_8rJdHDUqilILGkaiY2RgY0yOPYZWbiOFV8IleX_jpMUEAIWS_7u-d27c2ROnJBV7B6t7WDsequAozKEqmAbpo3FwKqnyOjsJ90EMr-8uvN-G97Q0sRaEfAvLjtH6CjbsDrI3nhqju5IFgkEHGAVSBDy62L_SdtvejPTzEU4Owl6cJJM50AoxcUG2gLiVM31URgChyM89N3JBORcF3BoICA9mhN2A3G9gdvdrij2UJYgejLaSCMsKLTivNj0SEOf7WEN7ZwnHV1dfqd2dTsQ5QCdk9bI10PkcxexXqcmH3W51Jt_le2kbH8os9Y3UaTcNLYpDx-Xab6GTHXpZ128MhpWqDVK2np0yrgXXqQpaLa4APDLBkIF8bd2sYql0Sn_DeE7sYr6AdNzvgljR-MUq7SwAdMHeUtgHR4CAAA=)): + +```svelte + + + +

{a} + {b} = {await add(a, b)}

+ +{#if $effect.pending()} +

pending promises: {$effect.pending()}

+{/if} +``` + ## `$effect.root` The `$effect.root` rune is an advanced feature that creates a non-tracked scope that doesn't auto-cleanup. This is useful for nested effects that you want to manually control. This rune also allows for the creation of effects outside of the component initialisation phase. diff --git a/documentation/docs/03-template-syntax/19-await-expressions.md b/documentation/docs/03-template-syntax/19-await-expressions.md new file mode 100644 index 0000000000..4e5ec28b26 --- /dev/null +++ b/documentation/docs/03-template-syntax/19-await-expressions.md @@ -0,0 +1,144 @@ +--- +title: await +--- + +As of Svelte 5.36, you can use the `await` keyword inside your components in three places where it was previously unavailable: + +- at the top level of your component's ` + + + + +

{a} + {b} = {await add(a, b)}

+``` + +...if you increment `a`, the contents of the `

` will _not_ immediately update to read this — + +```html +

2 + 2 = 3

+``` + +— instead, the text will update to `2 + 2 = 4` when `add(a, b)` resolves. + +Updates can overlap — a fast update will be reflected in the UI while an earlier slow update is still ongoing. + +## Concurrency + +Svelte will do as much asynchronous work as it can in parallel. For example if you have two `await` expressions in your markup... + +```svelte +

{await one()}

+

{await two()}

+``` + +...both functions will run at the same time, as they are independent expressions, even though they are _visually_ sequential. + +This does not apply to sequential `await` expressions inside your ` + * ``` + */ +export function getAbortSignal() { + if (active_reaction === null) { + e.get_abort_signal_outside_reaction(); + } + + return (active_reaction.ac ??= new AbortController()).signal; +} + /** * `onMount`, like [`$effect`](https://svelte.dev/docs/svelte/$effect), schedules a function to run as soon as the component has been mounted to the DOM. * Unlike `$effect`, the provided function only runs once. @@ -60,7 +90,7 @@ if (DEV) { */ export function onMount(fn) { if (component_context === null) { - lifecycle_outside_component('onMount'); + e.lifecycle_outside_component('onMount'); } if (legacy_mode_flag && component_context.l !== null) { @@ -84,7 +114,7 @@ export function onMount(fn) { */ export function onDestroy(fn) { if (component_context === null) { - lifecycle_outside_component('onDestroy'); + e.lifecycle_outside_component('onDestroy'); } onMount(() => () => untrack(fn)); @@ -127,7 +157,7 @@ function create_custom_event(type, detail, { bubbles = false, cancelable = false export function createEventDispatcher() { const active_component_context = component_context; if (active_component_context === null) { - lifecycle_outside_component('createEventDispatcher'); + e.lifecycle_outside_component('createEventDispatcher'); } return (type, detail, options) => { @@ -165,7 +195,7 @@ export function createEventDispatcher() { */ export function beforeUpdate(fn) { if (component_context === null) { - lifecycle_outside_component('beforeUpdate'); + e.lifecycle_outside_component('beforeUpdate'); } if (component_context.l === null) { @@ -188,7 +218,7 @@ export function beforeUpdate(fn) { */ export function afterUpdate(fn) { if (component_context === null) { - lifecycle_outside_component('afterUpdate'); + e.lifecycle_outside_component('afterUpdate'); } if (component_context.l === null) { @@ -207,8 +237,8 @@ function init_update_callbacks(context) { return (l.u ??= { a: [], b: [], m: [] }); } -export { flushSync } from './internal/client/runtime.js'; +export { flushSync } from './internal/client/reactivity/batch.js'; export { getContext, getAllContexts, hasContext, setContext } from './internal/client/context.js'; export { hydrate, mount, unmount } from './internal/client/render.js'; -export { tick, untrack } from './internal/client/runtime.js'; +export { tick, untrack, settled } from './internal/client/runtime.js'; export { createRawSnippet } from './internal/client/dom/blocks/snippet.js'; diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 0f1aff8f5a..1342e502d7 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -35,6 +35,10 @@ export function unmount() { export async function tick() {} +export async function settled() {} + +export { getAbortSignal } from './internal/server/abort-signal.js'; + export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js'; export { createRawSnippet } from './internal/server/blocks/snippet.js'; diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 98cef658bf..50a7a21ae8 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -15,14 +15,29 @@ export const DESTROYED = 1 << 14; export const EFFECT_RAN = 1 << 15; /** 'Transparent' effects do not create a transition boundary */ export const EFFECT_TRANSPARENT = 1 << 16; -/** Svelte 4 legacy mode props need to be handled with deriveds and be recognized elsewhere, hence the dedicated flag */ -export const LEGACY_DERIVED_PROP = 1 << 17; -export const INSPECT_EFFECT = 1 << 18; -export const HEAD_EFFECT = 1 << 19; -export const EFFECT_HAS_DERIVED = 1 << 20; -export const EFFECT_IS_UPDATING = 1 << 21; +export const INSPECT_EFFECT = 1 << 17; +export const HEAD_EFFECT = 1 << 18; +export const EFFECT_PRESERVED = 1 << 19; +export const USER_EFFECT = 1 << 20; + +// Flags used for async +export const REACTION_IS_UPDATING = 1 << 21; +export const ASYNC = 1 << 22; + +export const ERROR_VALUE = 1 << 23; export const STATE_SYMBOL = Symbol('$state'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); export const PROXY_PATH_SYMBOL = Symbol('proxy path'); + +/** allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError` */ +export const STALE_REACTION = new (class StaleReactionError extends Error { + name = 'StaleReactionError'; + message = 'The reaction that called `getAbortSignal()` was re-run or destroyed'; +})(); + +export const ELEMENT_NODE = 1; +export const TEXT_NODE = 3; +export const COMMENT_NODE = 8; +export const DOCUMENT_FRAGMENT_NODE = 11; diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 7c7213b7a2..cad75546d4 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -1,16 +1,11 @@ -/** @import { ComponentContext } from '#client' */ - +/** @import { ComponentContext, DevStackEntry, Effect } from '#client' */ import { DEV } from 'esm-env'; -import { lifecycle_outside_component } from '../shared/errors.js'; -import { source } from './reactivity/sources.js'; -import { - active_effect, - active_reaction, - set_active_effect, - set_active_reaction -} from './runtime.js'; -import { effect, teardown } from './reactivity/effects.js'; -import { legacy_mode_flag } from '../flags/index.js'; +import * as e from './errors.js'; +import { active_effect, active_reaction } from './runtime.js'; +import { create_user_effect } from './reactivity/effects.js'; +import { async_mode_flag, legacy_mode_flag } from '../flags/index.js'; +import { FILENAME } from '../../constants.js'; +import { BRANCH_EFFECT, EFFECT_RAN } from './constants.js'; /** @type {ComponentContext | null} */ export let component_context = null; @@ -20,6 +15,43 @@ export function set_component_context(context) { component_context = context; } +/** @type {DevStackEntry | null} */ +export let dev_stack = null; + +/** @param {DevStackEntry | null} stack */ +export function set_dev_stack(stack) { + dev_stack = stack; +} + +/** + * Execute a callback with a new dev stack entry + * @param {() => any} callback - Function to execute + * @param {DevStackEntry['type']} type - Type of block/component + * @param {any} component - Component function + * @param {number} line - Line number + * @param {number} column - Column number + * @param {Record} [additional] - Any additional properties to add to the dev stack entry + * @returns {any} + */ +export function add_svelte_meta(callback, type, component, line, column, additional) { + const parent = dev_stack; + + dev_stack = { + type, + file: component[FILENAME], + line, + column, + parent, + ...additional + }; + + try { + return callback(); + } finally { + dev_stack = parent; + } +} + /** * The current component function. Different from current component context: * ```html @@ -65,6 +97,16 @@ export function getContext(key) { */ export function setContext(key, context) { const context_map = get_or_init_context_map('setContext'); + + if (async_mode_flag) { + var flags = /** @type {Effect} */ (active_effect).f; + var valid = !active_reaction && (flags & BRANCH_EFFECT) !== 0 && (flags & EFFECT_RAN) === 0; + + if (!valid) { + e.set_context_after_init(); + } + } + context_map.set(key, context); return context; } @@ -101,29 +143,14 @@ export function getAllContexts() { * @returns {void} */ export function push(props, runes = false, fn) { - var ctx = (component_context = { + component_context = { p: component_context, c: null, - d: false, e: null, - m: false, s: props, x: null, - l: null - }); - - if (legacy_mode_flag && !runes) { - component_context.l = { - s: null, - u: null, - r1: [], - r2: source(false) - }; - } - - teardown(() => { - /** @type {ComponentContext} */ (ctx).d = true; - }); + l: legacy_mode_flag && !runes ? { s: null, u: null, $: [] } : null + }; if (DEV) { // component function @@ -138,37 +165,28 @@ export function push(props, runes = false, fn) { * @returns {T} */ export function pop(component) { - const context_stack_item = component_context; - if (context_stack_item !== null) { - if (component !== undefined) { - context_stack_item.x = component; - } - const component_effects = context_stack_item.e; - if (component_effects !== null) { - var previous_effect = active_effect; - var previous_reaction = active_reaction; - context_stack_item.e = null; - try { - for (var i = 0; i < component_effects.length; i++) { - var component_effect = component_effects[i]; - set_active_effect(component_effect.effect); - set_active_reaction(component_effect.reaction); - effect(component_effect.fn); - } - } finally { - set_active_effect(previous_effect); - set_active_reaction(previous_reaction); - } - } - component_context = context_stack_item.p; - if (DEV) { - dev_current_component_function = context_stack_item.p?.function ?? null; + var context = /** @type {ComponentContext} */ (component_context); + var effects = context.e; + + if (effects !== null) { + context.e = null; + + for (var fn of effects) { + create_user_effect(fn); } - context_stack_item.m = true; } - // Micro-optimization: Don't set .a above to the empty object - // so it can be garbage-collected when the return here is unused - return component || /** @type {T} */ ({}); + + if (component !== undefined) { + context.x = component; + } + + component_context = context.p; + + if (DEV) { + dev_current_component_function = component_context?.function ?? null; + } + + return component ?? /** @type {T} */ ({}); } /** @returns {boolean} */ @@ -182,7 +200,7 @@ export function is_runes() { */ function get_or_init_context_map(name) { if (component_context === null) { - lifecycle_outside_component(name); + e.lifecycle_outside_component(name); } return (component_context.c ??= new Map(get_parent_context(component_context) || undefined)); diff --git a/packages/svelte/src/internal/client/dev/debug.js b/packages/svelte/src/internal/client/dev/debug.js index fbde87a2d7..c47080ed2f 100644 --- a/packages/svelte/src/internal/client/dev/debug.js +++ b/packages/svelte/src/internal/client/dev/debug.js @@ -7,6 +7,7 @@ import { CLEAN, DERIVED, EFFECT, + ASYNC, MAYBE_DIRTY, RENDER_EFFECT, ROOT_EFFECT @@ -39,6 +40,8 @@ export function log_effect_tree(effect, depth = 0) { label = 'boundary'; } else if ((flags & BLOCK_EFFECT) !== 0) { label = 'block'; + } else if ((flags & ASYNC) !== 0) { + label = 'async'; } else if ((flags & BRANCH_EFFECT) !== 0) { label = 'branch'; } else if ((flags & RENDER_EFFECT) !== 0) { diff --git a/packages/svelte/src/internal/client/dev/elements.js b/packages/svelte/src/internal/client/dev/elements.js index 62ac09d784..8dd54e0a2a 100644 --- a/packages/svelte/src/internal/client/dev/elements.js +++ b/packages/svelte/src/internal/client/dev/elements.js @@ -1,6 +1,8 @@ /** @import { SourceLocation } from '#client' */ +import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE } from '#client/constants'; import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../constants.js'; import { hydrating } from '../dom/hydration.js'; +import { dev_stack } from '../context.js'; /** * @param {any} fn @@ -12,7 +14,7 @@ export function add_locations(fn, filename, locations) { return (/** @type {any[]} */ ...args) => { const dom = fn(...args); - var node = hydrating ? dom : dom.nodeType === 11 ? dom.firstChild : dom; + var node = hydrating ? dom : dom.nodeType === DOCUMENT_FRAGMENT_NODE ? dom.firstChild : dom; assign_locations(node, filename, locations); return dom; @@ -27,6 +29,7 @@ export function add_locations(fn, filename, locations) { function assign_location(element, filename, location) { // @ts-expect-error element.__svelte_meta = { + parent: dev_stack, loc: { file: filename, line: location[0], column: location[1] } }; @@ -45,13 +48,13 @@ function assign_locations(node, filename, locations) { var depth = 0; while (node && i < locations.length) { - if (hydrating && node.nodeType === 8) { + if (hydrating && node.nodeType === COMMENT_NODE) { var comment = /** @type {Comment} */ (node); if (comment.data === HYDRATION_START || comment.data === HYDRATION_START_ELSE) depth += 1; else if (comment.data[0] === HYDRATION_END) depth -= 1; } - if (depth === 0 && node.nodeType === 1) { + if (depth === 0 && node.nodeType === ELEMENT_NODE) { assign_location(/** @type {Element} */ (node), filename, locations[i++]); } diff --git a/packages/svelte/src/internal/client/dev/inspect.js b/packages/svelte/src/internal/client/dev/inspect.js index e13ef470cf..c593f2622c 100644 --- a/packages/svelte/src/internal/client/dev/inspect.js +++ b/packages/svelte/src/internal/client/dev/inspect.js @@ -1,6 +1,7 @@ import { UNINITIALIZED } from '../../../constants.js'; import { snapshot } from '../../shared/clone.js'; -import { inspect_effect, validate_effect } from '../reactivity/effects.js'; +import { inspect_effect, render_effect, validate_effect } from '../reactivity/effects.js'; +import { untrack } from '../runtime.js'; /** * @param {() => any[]} get_value @@ -11,26 +12,44 @@ export function inspect(get_value, inspector = console.log) { validate_effect('$inspect'); let initial = true; + let error = /** @type {any} */ (UNINITIALIZED); + // Inspect effects runs synchronously so that we can capture useful + // stack traces. As a consequence, reading the value might result + // in an error (an `$inspect(object.property)` will run before the + // `{#if object}...{/if}` that contains it) inspect_effect(() => { - /** @type {any} */ - var value = UNINITIALIZED; - - // Capturing the value might result in an exception due to the inspect effect being - // sync and thus operating on stale data. In the case we encounter an exception we - // can bail-out of reporting the value. Instead we simply console.error the error - // so at least it's known that an error occured, but we don't stop execution try { - value = get_value(); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); + var value = get_value(); + } catch (e) { + error = e; + return; } - if (value !== UNINITIALIZED) { - inspector(initial ? 'init' : 'update', ...snapshot(value, true)); - } + var snap = snapshot(value, true); + untrack(() => { + inspector(initial ? 'init' : 'update', ...snap); + }); initial = false; }); + + // If an error occurs, we store it (along with its stack trace). + // If the render effect subsequently runs, we log the error, + // but if it doesn't run it's because the `$inspect` was + // destroyed, meaning we don't need to bother + render_effect(() => { + try { + // call `get_value` so that this runs alongside the inspect effect + get_value(); + } catch { + // ignore + } + + if (error !== UNINITIALIZED) { + // eslint-disable-next-line no-console + console.error(error); + error = UNINITIALIZED; + } + }); } diff --git a/packages/svelte/src/internal/client/dev/tracing.js b/packages/svelte/src/internal/client/dev/tracing.js index 5834f5bffd..b7a6a38548 100644 --- a/packages/svelte/src/internal/client/dev/tracing.js +++ b/packages/svelte/src/internal/client/dev/tracing.js @@ -2,7 +2,7 @@ import { UNINITIALIZED } from '../../../constants.js'; import { snapshot } from '../../shared/clone.js'; import { define_property } from '../../shared/utils.js'; -import { DERIVED, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; +import { DERIVED, ASYNC, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; import { effect_tracking } from '../reactivity/effects.js'; import { active_reaction, captured_signals, set_captured_signals, untrack } from '../runtime.js'; @@ -26,7 +26,7 @@ function log_entry(signal, entry) { return; } - const type = (signal.f & DERIVED) !== 0 ? '$derived' : '$state'; + const type = (signal.f & (DERIVED | ASYNC)) !== 0 ? '$derived' : '$state'; const current_reaction = /** @type {Reaction} */ (active_reaction); const dirty = signal.wv > current_reaction.wv || current_reaction.wv === 0; const style = dirty @@ -56,8 +56,10 @@ function log_entry(signal, entry) { } if (dirty && signal.updated) { - // eslint-disable-next-line no-console - console.log(signal.updated); + for (const updated of signal.updated.values()) { + // eslint-disable-next-line no-console + console.log(updated.error); + } } if (entry) { @@ -120,44 +122,46 @@ export function trace(label, fn) { /** * @param {string} label + * @returns {Error & { stack: string } | null} */ export function get_stack(label) { let error = Error(); const stack = error.stack; - if (stack) { - const lines = stack.split('\n'); - const new_lines = ['\n']; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (line === 'Error') { - continue; - } - if (line.includes('validate_each_keys')) { - return null; - } - if (line.includes('svelte/src/internal')) { - continue; - } - new_lines.push(line); - } + if (!stack) return null; - if (new_lines.length === 1) { + const lines = stack.split('\n'); + const new_lines = ['\n']; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line === 'Error') { + continue; + } + if (line.includes('validate_each_keys')) { return null; } + if (line.includes('svelte/src/internal')) { + continue; + } + new_lines.push(line); + } - define_property(error, 'stack', { - value: new_lines.join('\n') - }); - - define_property(error, 'name', { - // 'Error' suffix is required for stack traces to be rendered properly - value: `${label}Error` - }); + if (new_lines.length === 1) { + return null; } - return error; + + define_property(error, 'stack', { + value: new_lines.join('\n') + }); + + define_property(error, 'name', { + // 'Error' suffix is required for stack traces to be rendered properly + value: `${label}Error` + }); + + return /** @type {Error & { stack: string }} */ (error); } /** diff --git a/packages/svelte/src/internal/client/dev/validation.js b/packages/svelte/src/internal/client/dev/validation.js index e41e4c4628..60d140c718 100644 --- a/packages/svelte/src/internal/client/dev/validation.js +++ b/packages/svelte/src/internal/client/dev/validation.js @@ -1,15 +1,16 @@ -import { invalid_snippet_arguments } from '../../shared/errors.js'; +import * as e from '../errors.js'; /** * @param {Node} anchor * @param {...(()=>any)[]} args */ export function validate_snippet_args(anchor, ...args) { if (typeof anchor !== 'object' || !(anchor instanceof Node)) { - invalid_snippet_arguments(); + e.invalid_snippet_arguments(); } + for (let arg of args) { if (typeof arg !== 'function') { - invalid_snippet_arguments(); + e.invalid_snippet_arguments(); } } } diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js new file mode 100644 index 0000000000..82f107ab29 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -0,0 +1,26 @@ +/** @import { TemplateNode, Value } from '#client' */ +import { flatten } from '../../reactivity/async.js'; +import { get } from '../../runtime.js'; +import { get_pending_boundary } from './boundary.js'; + +/** + * @param {TemplateNode} node + * @param {Array<() => Promise>} expressions + * @param {(anchor: TemplateNode, ...deriveds: Value[]) => void} fn + */ +export function async(node, expressions, fn) { + var boundary = get_pending_boundary(); + + boundary.update_pending_count(1); + + flatten([], expressions, (values) => { + try { + // get values eagerly to avoid creating blocks if they reject + for (const d of values) get(d); + + fn(node, ...values); + } finally { + boundary.update_pending_count(-1); + } + }); +} diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index 47df5fc9a5..4f68db57b1 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -3,7 +3,7 @@ import { DEV } from 'esm-env'; import { is_promise } from '../../../shared/utils.js'; import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js'; import { internal_set, mutable_source, source } from '../../reactivity/sources.js'; -import { flushSync, set_active_effect, set_active_reaction } from '../../runtime.js'; +import { set_active_effect, set_active_reaction } from '../../runtime.js'; import { hydrate_next, hydrate_node, @@ -16,10 +16,13 @@ import { queue_micro_task } from '../task.js'; import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; import { component_context, + dev_stack, is_runes, set_component_context, - set_dev_current_component_function + set_dev_current_component_function, + set_dev_stack } from '../../context.js'; +import { flushSync } from '../../reactivity/batch.js'; const PENDING = 0; const THEN = 1; @@ -45,6 +48,7 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { /** @type {any} */ var component_function = DEV ? component_context?.function : null; + var dev_original_stack = DEV ? dev_stack : null; /** @type {V | Promise | typeof UNINITIALIZED} */ var input = UNINITIALIZED; @@ -75,7 +79,10 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { set_active_effect(effect); set_active_reaction(effect); // TODO do we need both? set_component_context(active_component_context); - if (DEV) set_dev_current_component_function(component_function); + if (DEV) { + set_dev_current_component_function(component_function); + set_dev_stack(dev_original_stack); + } } try { @@ -107,7 +114,11 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { } } finally { if (restore) { - if (DEV) set_dev_current_component_function(null); + if (DEV) { + set_dev_current_component_function(null); + set_dev_stack(null); + } + set_component_context(null); set_active_reaction(null); set_active_effect(null); diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index f2decfe7d1..c9c0da347d 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,12 +1,17 @@ -/** @import { Effect, TemplateNode, } from '#client' */ - -import { BOUNDARY_EFFECT, EFFECT_RAN, EFFECT_TRANSPARENT } from '#client/constants'; +/** @import { Effect, Source, TemplateNode, } from '#client' */ +import { + BOUNDARY_EFFECT, + EFFECT_PRESERVED, + EFFECT_RAN, + EFFECT_TRANSPARENT +} from '#client/constants'; import { component_context, set_component_context } from '../../context.js'; import { handle_error, invoke_error_boundary } from '../../error-handling.js'; import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js'; import { active_effect, active_reaction, + get, set_active_effect, set_active_reaction } from '../../runtime.js'; @@ -18,145 +23,397 @@ import { remove_nodes, set_hydrate_node } from '../hydration.js'; +import { get_next_sibling } from '../operations.js'; import { queue_micro_task } from '../task.js'; -import * as w from '../../warnings.js'; import * as e from '../../errors.js'; +import * as w from '../../warnings.js'; +import { DEV } from 'esm-env'; +import { Batch, effect_pending_updates } from '../../reactivity/batch.js'; +import { internal_set, source } from '../../reactivity/sources.js'; +import { tag } from '../../dev/tracing.js'; +import { createSubscriber } from '../../../../reactivity/create-subscriber.js'; /** - * @param {Effect} boundary - * @param {() => void} fn + * @typedef {{ + * onerror?: (error: unknown, reset: () => void) => void; + * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void; + * pending?: (anchor: Node) => void; + * }} BoundaryProps */ -function with_boundary(boundary, fn) { - var previous_effect = active_effect; - var previous_reaction = active_reaction; - var previous_ctx = component_context; - - set_active_effect(boundary); - set_active_reaction(boundary); - set_component_context(boundary.ctx); - - try { - fn(); - } catch (e) { - handle_error(e); - } finally { - set_active_effect(previous_effect); - set_active_reaction(previous_reaction); - set_component_context(previous_ctx); - } -} + +var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT; /** * @param {TemplateNode} node - * @param {{ - * onerror?: (error: unknown, reset: () => void) => void, - * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void - * }} props - * @param {((anchor: Node) => void)} boundary_fn + * @param {BoundaryProps} props + * @param {((anchor: Node) => void)} children * @returns {void} */ -export function boundary(node, props, boundary_fn) { - var anchor = node; +export function boundary(node, props, children) { + new Boundary(node, props, children); +} + +export class Boundary { + pending = false; + + /** @type {Boundary | null} */ + parent; + + /** @type {TemplateNode} */ + #anchor; + + /** @type {TemplateNode} */ + #hydrate_open; + + /** @type {BoundaryProps} */ + #props; + + /** @type {((anchor: Node) => void)} */ + #children; /** @type {Effect} */ - var boundary_effect; + #effect; - block(() => { - var boundary = /** @type {Effect} */ (active_effect); - var hydrate_open = hydrate_node; - var is_creating_fallback = false; + /** @type {Effect | null} */ + #main_effect = null; - // We re-use the effect's fn property to avoid allocation of an additional field - boundary.fn = (/** @type {unknown}} */ error) => { - var onerror = props.onerror; - let failed = props.failed; + /** @type {Effect | null} */ + #pending_effect = null; - // If we have nothing to capture the error, or if we hit an error while - // rendering the fallback, re-throw for another boundary to handle - if ((!onerror && !failed) || is_creating_fallback) { - throw error; - } + /** @type {Effect | null} */ + #failed_effect = null; - if (boundary_effect) { - destroy_effect(boundary_effect); - } else if (hydrating) { - set_hydrate_node(hydrate_open); - next(); - set_hydrate_node(remove_nodes()); - } + /** @type {DocumentFragment | null} */ + #offscreen_fragment = null; - var did_reset = false; - var calling_on_error = false; + #pending_count = 0; + #is_creating_fallback = false; - var reset = () => { - if (did_reset) { - w.svelte_boundary_reset_noop(); - return; - } + /** + * A source containing the number of pending async deriveds/expressions. + * Only created if `$effect.pending()` is used inside the boundary, + * otherwise updating the source results in needless `Batch.ensure()` + * calls followed by no-op flushes + * @type {Source | null} + */ + #effect_pending = null; + + #effect_pending_update = () => { + if (this.#effect_pending) { + internal_set(this.#effect_pending, this.#pending_count); + } + }; - did_reset = true; + #effect_pending_subscriber = createSubscriber(() => { + this.#effect_pending = source(this.#pending_count); - if (calling_on_error) { - e.svelte_boundary_reset_onerror(); - } + if (DEV) { + tag(this.#effect_pending, '$effect.pending()'); + } + + return () => { + this.#effect_pending = null; + }; + }); + + /** + * @param {TemplateNode} node + * @param {BoundaryProps} props + * @param {((anchor: Node) => void)} children + */ + constructor(node, props, children) { + this.#anchor = node; + this.#props = props; + this.#children = children; + + this.#hydrate_open = hydrate_node; + + this.parent = /** @type {Effect} */ (active_effect).b; + + this.pending = !!this.#props.pending; + + this.#effect = block(() => { + /** @type {Effect} */ (active_effect).b = this; + + if (hydrating) { + hydrate_next(); + } + + const pending = this.#props.pending; + + if (hydrating && pending) { + this.#pending_effect = branch(() => pending(this.#anchor)); + + // future work: when we have some form of async SSR, we will + // need to use hydration boundary comments to report whether + // the pending or main block was rendered for a given + // boundary, and hydrate accordingly + queueMicrotask(() => { + this.#main_effect = this.#run(() => { + Batch.ensure(); + return branch(() => this.#children(this.#anchor)); + }); - pause_effect(boundary_effect); + if (this.#pending_count > 0) { + this.#show_pending_snippet(); + } else { + pause_effect(/** @type {Effect} */ (this.#pending_effect), () => { + this.#pending_effect = null; + }); - with_boundary(boundary, () => { - is_creating_fallback = false; - boundary_effect = branch(() => boundary_fn(anchor)); + this.pending = false; + } }); - }; - - var previous_reaction = active_reaction; - - try { - set_active_reaction(null); - calling_on_error = true; - onerror?.(error, reset); - calling_on_error = false; - } catch (error) { - if ((boundary.f & EFFECT_RAN) !== 0) { - invoke_error_boundary(error, /** @type {Effect} */ (boundary.parent)); + } else { + try { + this.#main_effect = branch(() => children(this.#anchor)); + } catch (error) { + this.error(error); + } + + if (this.#pending_count > 0) { + this.#show_pending_snippet(); } else { - throw error; + this.pending = false; } - } finally { - set_active_reaction(previous_reaction); } + }, flags); - if (failed) { - // Render the `failed` snippet in a microtask - queue_micro_task(() => { - with_boundary(boundary, () => { - is_creating_fallback = true; - - try { - boundary_effect = branch(() => { - failed( - anchor, - () => error, - () => reset - ); - }); - } catch (error) { - invoke_error_boundary(error, /** @type {Effect} */ (boundary.parent)); - } - - is_creating_fallback = false; - }); + if (hydrating) { + this.#anchor = hydrate_node; + } + } + + has_pending_snippet() { + return !!this.#props.pending; + } + + /** + * @param {() => Effect | null} fn + */ + #run(fn) { + var previous_effect = active_effect; + var previous_reaction = active_reaction; + var previous_ctx = component_context; + + set_active_effect(this.#effect); + set_active_reaction(this.#effect); + set_component_context(this.#effect.ctx); + + try { + return fn(); + } catch (e) { + handle_error(e); + return null; + } finally { + set_active_effect(previous_effect); + set_active_reaction(previous_reaction); + set_component_context(previous_ctx); + } + } + + #show_pending_snippet() { + const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); + + if (this.#main_effect !== null) { + this.#offscreen_fragment = document.createDocumentFragment(); + move_effect(this.#main_effect, this.#offscreen_fragment); + } + + if (this.#pending_effect === null) { + this.#pending_effect = branch(() => pending(this.#anchor)); + } + } + + /** @param {1 | -1} d */ + #update_pending_count(d) { + this.#pending_count += d; + + if (this.#pending_count === 0) { + this.pending = false; + + if (this.#pending_effect) { + pause_effect(this.#pending_effect, () => { + this.#pending_effect = null; }); } - }; + + if (this.#offscreen_fragment) { + this.#anchor.before(this.#offscreen_fragment); + this.#offscreen_fragment = null; + } + } + } + + /** @param {1 | -1} d */ + update_pending_count(d) { + if (this.has_pending_snippet()) { + this.#update_pending_count(d); + } else if (this.parent) { + this.parent.#update_pending_count(d); + } + + effect_pending_updates.add(this.#effect_pending_update); + } + + get_effect_pending() { + this.#effect_pending_subscriber(); + return get(/** @type {Source} */ (this.#effect_pending)); + } + + /** @param {unknown} error */ + error(error) { + var onerror = this.#props.onerror; + let failed = this.#props.failed; + + if (this.#main_effect) { + destroy_effect(this.#main_effect); + this.#main_effect = null; + } + + if (this.#pending_effect) { + destroy_effect(this.#pending_effect); + this.#pending_effect = null; + } + + if (this.#failed_effect) { + destroy_effect(this.#failed_effect); + this.#failed_effect = null; + } if (hydrating) { - hydrate_next(); + set_hydrate_node(this.#hydrate_open); + next(); + set_hydrate_node(remove_nodes()); + } + + var did_reset = false; + var calling_on_error = false; + + const reset = () => { + if (did_reset) { + w.svelte_boundary_reset_noop(); + return; + } + + did_reset = true; + + if (calling_on_error) { + e.svelte_boundary_reset_onerror(); + } + + this.#pending_count = 0; + + if (this.#failed_effect !== null) { + pause_effect(this.#failed_effect, () => { + this.#failed_effect = null; + }); + } + + this.pending = true; + + this.#main_effect = this.#run(() => { + this.#is_creating_fallback = false; + return branch(() => this.#children(this.#anchor)); + }); + + if (this.#pending_count > 0) { + this.#show_pending_snippet(); + } else { + this.pending = false; + } + }; + + // If we have nothing to capture the error, or if we hit an error while + // rendering the fallback, re-throw for another boundary to handle + if (this.#is_creating_fallback || (!onerror && !failed)) { + throw error; + } + + var previous_reaction = active_reaction; + + try { + set_active_reaction(null); + calling_on_error = true; + onerror?.(error, reset); + calling_on_error = false; + } catch (error) { + if (this.#effect !== null) { + invoke_error_boundary(error, this.#effect.parent); + } else { + throw error; + } + } finally { + set_active_reaction(previous_reaction); } - boundary_effect = branch(() => boundary_fn(anchor)); - }, EFFECT_TRANSPARENT | BOUNDARY_EFFECT); + if (failed) { + queue_micro_task(() => { + this.#failed_effect = this.#run(() => { + this.#is_creating_fallback = true; + + try { + return branch(() => { + failed( + this.#anchor, + () => error, + () => reset + ); + }); + } catch (error) { + invoke_error_boundary(error, /** @type {Effect} */ (this.#effect.parent)); + return null; + } finally { + this.#is_creating_fallback = false; + } + }); + }); + } + } +} + +/** + * + * @param {Effect} effect + * @param {DocumentFragment} fragment + */ +function move_effect(effect, fragment) { + var node = effect.nodes_start; + var end = effect.nodes_end; + + while (node !== null) { + /** @type {TemplateNode | null} */ + var next = node === end ? null : /** @type {TemplateNode} */ (get_next_sibling(node)); + + fragment.append(node); + node = next; + } +} + +export function get_pending_boundary() { + var boundary = /** @type {Effect} */ (active_effect).b; + + while (boundary !== null && !boundary.has_pending_snippet()) { + boundary = boundary.parent; + } - if (hydrating) { - anchor = hydrate_node; + if (boundary === null) { + e.await_outside_boundary(); } + + return boundary; +} + +export function pending() { + if (active_effect === null) { + e.effect_pending_outside_reaction(); + } + + var boundary = active_effect.b; + + if (boundary === null) { + return 0; // TODO eventually we will need this to be global + } + + return boundary.get_effect_pending(); } diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index b638a6d2da..43c75e2a37 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -1,4 +1,5 @@ /** @import { EachItem, EachState, Effect, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */ +/** @import { Batch } from '../../reactivity/batch.js'; */ import { EACH_INDEX_REACTIVE, EACH_IS_ANIMATED, @@ -21,7 +22,8 @@ import { clear_text_content, create_text, get_first_child, - get_next_sibling + get_next_sibling, + should_defer_append } from '../operations.js'; import { block, @@ -34,11 +36,12 @@ import { } from '../../reactivity/effects.js'; import { source, mutable_source, internal_set } from '../../reactivity/sources.js'; import { array_from, is_array } from '../../../shared/utils.js'; -import { INERT } from '#client/constants'; +import { COMMENT_NODE, INERT } from '#client/constants'; import { queue_micro_task } from '../task.js'; import { active_effect, get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; +import { current_batch } from '../../reactivity/batch.js'; /** * The row of a keyed each block that is currently updating. We track this @@ -66,9 +69,10 @@ export function index(_, i) { * @param {EachState} state * @param {EachItem[]} items * @param {null | Node} controlled_anchor - * @param {Map} items_map */ -function pause_effects(state, items, controlled_anchor, items_map) { +function pause_effects(state, items, controlled_anchor) { + var items_map = state.items; + /** @type {TransitionManager[]} */ var transitions = []; var length = items.length; @@ -137,6 +141,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var was_empty = false; + /** @type {Map} */ + var offscreen_items = new Map(); + // TODO: ideally we could use derived for runes mode but because of the ability // to use a store which can be mutated, we can't do that here as mutating a store // will still result in the collection array being the same from the store @@ -146,8 +153,45 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f return is_array(collection) ? collection : collection == null ? [] : array_from(collection); }); + /** @type {V[]} */ + var array; + + /** @type {Effect} */ + var each_effect; + + function commit() { + reconcile( + each_effect, + array, + state, + offscreen_items, + anchor, + render_fn, + flags, + get_key, + get_collection + ); + + if (fallback_fn !== null) { + if (array.length === 0) { + if (fallback) { + resume_effect(fallback); + } else { + fallback = branch(() => fallback_fn(anchor)); + } + } else if (fallback !== null) { + pause_effect(fallback, () => { + fallback = null; + }); + } + } + } + block(() => { - var array = get(each_array); + // store a reference to the effect so that we can update the start/end nodes in reconciliation + each_effect ??= /** @type {Effect} */ (active_effect); + + array = get(each_array); var length = array.length; if (was_empty && length === 0) { @@ -183,7 +227,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f for (var i = 0; i < length; i++) { if ( - hydrate_node.nodeType === 8 && + hydrate_node.nodeType === COMMENT_NODE && /** @type {Comment} */ (hydrate_node).data === HYDRATION_END ) { // The server rendered fewer items than expected, @@ -219,21 +263,56 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } - if (!hydrating) { - reconcile(array, state, anchor, render_fn, flags, get_key, get_collection); - } + if (hydrating) { + if (length === 0 && fallback_fn) { + fallback = branch(() => fallback_fn(anchor)); + } + } else { + if (should_defer_append()) { + var keys = new Set(); + var batch = /** @type {Batch} */ (current_batch); + + for (i = 0; i < length; i += 1) { + value = array[i]; + key = get_key(value, i); + + var existing = state.items.get(key) ?? offscreen_items.get(key); + + if (existing) { + // update before reconciliation, to trigger any async updates + if ((flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0) { + update_item(existing, value, i, flags); + } + } else { + item = create_item( + null, + state, + null, + null, + value, + key, + i, + render_fn, + flags, + get_collection, + true + ); + + offscreen_items.set(key, item); + } - if (fallback_fn !== null) { - if (length === 0) { - if (fallback) { - resume_effect(fallback); - } else { - fallback = branch(() => fallback_fn(anchor)); + keys.add(key); } - } else if (fallback !== null) { - pause_effect(fallback, () => { - fallback = null; - }); + + for (const [key, item] of state.items) { + if (!keys.has(key)) { + batch.skipped_effects.add(item.e); + } + } + + batch.add_callback(commit); + } else { + commit(); } } @@ -259,8 +338,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f /** * Add, remove, or reorder items output by an each block as its input changes * @template V + * @param {Effect} each_effect * @param {Array} array * @param {EachState} state + * @param {Map} offscreen_items * @param {Element | Comment | Text} anchor * @param {(anchor: Node, item: MaybeSource, index: number | Source, collection: () => V[]) => void} render_fn * @param {number} flags @@ -268,7 +349,17 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f * @param {() => V[]} get_collection * @returns {void} */ -function reconcile(array, state, anchor, render_fn, flags, get_key, get_collection) { +function reconcile( + each_effect, + array, + state, + offscreen_items, + anchor, + render_fn, + flags, + get_key, + get_collection +) { var is_animated = (flags & EACH_IS_ANIMATED) !== 0; var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; @@ -320,23 +411,39 @@ function reconcile(array, state, anchor, render_fn, flags, get_key, get_collecti for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); + item = items.get(key); if (item === undefined) { - var child_anchor = current ? /** @type {TemplateNode} */ (current.e.nodes_start) : anchor; - - prev = create_item( - child_anchor, - state, - prev, - prev === null ? state.first : prev.next, - value, - key, - i, - render_fn, - flags, - get_collection - ); + var pending = offscreen_items.get(key); + + if (pending !== undefined) { + offscreen_items.delete(key); + items.set(key, pending); + + var next = prev ? prev.next : current; + + link(state, prev, pending); + link(state, pending, next); + + move(pending, next, anchor); + prev = pending; + } else { + var child_anchor = current ? /** @type {TemplateNode} */ (current.e.nodes_start) : anchor; + + prev = create_item( + child_anchor, + state, + prev, + prev === null ? state.first : prev.next, + value, + key, + i, + render_fn, + flags, + get_collection + ); + } items.set(key, prev); @@ -455,7 +562,7 @@ function reconcile(array, state, anchor, render_fn, flags, get_key, get_collecti } } - pause_effects(state, to_destroy, controlled_anchor, items); + pause_effects(state, to_destroy, controlled_anchor); } } @@ -468,8 +575,14 @@ function reconcile(array, state, anchor, render_fn, flags, get_key, get_collecti }); } - /** @type {Effect} */ (active_effect).first = state.first && state.first.e; - /** @type {Effect} */ (active_effect).last = prev && prev.e; + each_effect.first = state.first && state.first.e; + each_effect.last = prev && prev.e; + + for (var unused of offscreen_items.values()) { + destroy_effect(unused.e); + } + + offscreen_items.clear(); } /** @@ -493,7 +606,7 @@ function update_item(item, value, index, type) { /** * @template V - * @param {Node} anchor + * @param {Node | null} anchor * @param {EachState} state * @param {EachItem | null} prev * @param {EachItem | null} next @@ -503,6 +616,7 @@ function update_item(item, value, index, type) { * @param {(anchor: Node, item: V | Source, index: number | Value, collection: () => V[]) => void} render_fn * @param {number} flags * @param {() => V[]} get_collection + * @param {boolean} [deferred] * @returns {EachItem} */ function create_item( @@ -515,7 +629,8 @@ function create_item( index, render_fn, flags, - get_collection + get_collection, + deferred ) { var previous_each_item = current_each_item; var reactive = (flags & EACH_ITEM_REACTIVE) !== 0; @@ -549,13 +664,20 @@ function create_item( current_each_item = item; try { - item.e = branch(() => render_fn(anchor, v, i, get_collection), hydrating); + if (anchor === null) { + var fragment = document.createDocumentFragment(); + fragment.append((anchor = create_text())); + } + + item.e = branch(() => render_fn(/** @type {Node} */ (anchor), v, i, get_collection), hydrating); item.e.prev = prev && prev.e; item.e.next = next && next.e; if (prev === null) { - state.first = item; + if (!deferred) { + state.first = item; + } } else { prev.next = item; prev.e.next = item.e; @@ -583,7 +705,7 @@ function move(item, next, anchor) { var dest = next ? /** @type {TemplateNode} */ (next.e.nodes_start) : anchor; var node = /** @type {TemplateNode} */ (item.e.nodes_start); - while (node !== end) { + while (node !== null && node !== end) { var next_node = /** @type {TemplateNode} */ (get_next_sibling(node)); dest.before(node); node = next_node; diff --git a/packages/svelte/src/internal/client/dom/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js index 92c8243478..d7190abc66 100644 --- a/packages/svelte/src/internal/client/dom/blocks/html.js +++ b/packages/svelte/src/internal/client/dom/blocks/html.js @@ -10,6 +10,7 @@ import { DEV } from 'esm-env'; import { dev_current_component_function } from '../../context.js'; import { get_first_child, get_next_sibling } from '../operations.js'; import { active_effect } from '../../runtime.js'; +import { COMMENT_NODE } from '#client/constants'; /** * @param {Element} element @@ -67,7 +68,10 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning var next = hydrate_next(); var last = next; - while (next !== null && (next.nodeType !== 8 || /** @type {Comment} */ (next).data !== '')) { + while ( + next !== null && + (next.nodeType !== COMMENT_NODE || /** @type {Comment} */ (next).data !== '') + ) { last = next; next = /** @type {TemplateNode} */ (get_next_sibling(next)); } diff --git a/packages/svelte/src/internal/client/dom/blocks/if.js b/packages/svelte/src/internal/client/dom/blocks/if.js index bf1098c3f4..6ba9ad4936 100644 --- a/packages/svelte/src/internal/client/dom/blocks/if.js +++ b/packages/svelte/src/internal/client/dom/blocks/if.js @@ -1,4 +1,5 @@ /** @import { Effect, TemplateNode } from '#client' */ +/** @import { Batch } from '../../reactivity/batch.js'; */ import { EFFECT_TRANSPARENT } from '#client/constants'; import { hydrate_next, @@ -10,16 +11,20 @@ import { set_hydrating } from '../hydration.js'; import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js'; -import { HYDRATION_START, HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; +import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; +import { create_text, should_defer_append } from '../operations.js'; +import { current_batch } from '../../reactivity/batch.js'; + +// TODO reinstate https://github.com/sveltejs/svelte/pull/15250 /** * @param {TemplateNode} node - * @param {(branch: (fn: (anchor: Node, elseif?: [number,number]) => void, flag?: boolean) => void) => void} fn - * @param {[number,number]} [elseif] + * @param {(branch: (fn: (anchor: Node) => void, flag?: boolean) => void) => void} fn + * @param {boolean} [elseif] True if this is an `{:else if ...}` block rather than an `{#if ...}`, as that affects which transitions are considered 'local' * @returns {void} */ -export function if_block(node, fn, [root_index, hydrate_index] = [0, 0]) { - if (hydrating && root_index === 0) { +export function if_block(node, fn, elseif = false) { + if (hydrating) { hydrate_next(); } @@ -34,45 +39,56 @@ export function if_block(node, fn, [root_index, hydrate_index] = [0, 0]) { /** @type {UNINITIALIZED | boolean | null} */ var condition = UNINITIALIZED; - var flags = root_index > 0 ? EFFECT_TRANSPARENT : 0; + var flags = elseif ? EFFECT_TRANSPARENT : 0; var has_branch = false; - const set_branch = ( - /** @type {(anchor: Node, elseif?: [number,number]) => void} */ fn, - flag = true - ) => { + const set_branch = (/** @type {(anchor: Node) => void} */ fn, flag = true) => { has_branch = true; update_branch(flag, fn); }; + /** @type {DocumentFragment | null} */ + var offscreen_fragment = null; + + function commit() { + if (offscreen_fragment !== null) { + // remove the anchor + /** @type {Text} */ (offscreen_fragment.lastChild).remove(); + + anchor.before(offscreen_fragment); + offscreen_fragment = null; + } + + var active = condition ? consequent_effect : alternate_effect; + var inactive = condition ? alternate_effect : consequent_effect; + + if (active) { + resume_effect(active); + } + + if (inactive) { + pause_effect(inactive, () => { + if (condition) { + alternate_effect = null; + } else { + consequent_effect = null; + } + }); + } + } + const update_branch = ( /** @type {boolean | null} */ new_condition, - /** @type {null | ((anchor: Node, elseif?: [number,number]) => void)} */ fn + /** @type {null | ((anchor: Node) => void)} */ fn ) => { if (condition === (condition = new_condition)) return; /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ let mismatch = false; - if (hydrating && hydrate_index !== -1) { - if (root_index === 0) { - const data = read_hydration_instruction(anchor); - - if (data === HYDRATION_START) { - hydrate_index = 0; - } else if (data === HYDRATION_START_ELSE) { - hydrate_index = Infinity; - } else { - hydrate_index = parseInt(data.substring(1)); - if (hydrate_index !== hydrate_index) { - // if hydrate_index is NaN - // we set an invalid index to force mismatch - hydrate_index = condition ? Infinity : -1; - } - } - } - const is_else = hydrate_index > root_index; + if (hydrating) { + const is_else = read_hydration_instruction(anchor) === HYDRATION_START_ELSE; if (!!condition === is_else) { // Hydration mismatch: remove everything inside the anchor and start fresh. @@ -82,34 +98,35 @@ export function if_block(node, fn, [root_index, hydrate_index] = [0, 0]) { set_hydrate_node(anchor); set_hydrating(false); mismatch = true; - hydrate_index = -1; // ignore hydration in next else if } } - if (condition) { - if (consequent_effect) { - resume_effect(consequent_effect); - } else if (fn) { - consequent_effect = branch(() => fn(anchor)); - } + var defer = should_defer_append(); + var target = anchor; - if (alternate_effect) { - pause_effect(alternate_effect, () => { - alternate_effect = null; - }); - } + if (defer) { + offscreen_fragment = document.createDocumentFragment(); + offscreen_fragment.append((target = create_text())); + } + + if (condition) { + consequent_effect ??= fn && branch(() => fn(target)); } else { - if (alternate_effect) { - resume_effect(alternate_effect); - } else if (fn) { - alternate_effect = branch(() => fn(anchor, [root_index + 1, hydrate_index])); - } + alternate_effect ??= fn && branch(() => fn(target)); + } - if (consequent_effect) { - pause_effect(consequent_effect, () => { - consequent_effect = null; - }); - } + if (defer) { + var batch = /** @type {Batch} */ (current_batch); + + var active = condition ? consequent_effect : alternate_effect; + var inactive = condition ? alternate_effect : consequent_effect; + + if (active) batch.skipped_effects.delete(active); + if (inactive) batch.skipped_effects.add(inactive); + + batch.add_callback(commit); + } else { + commit(); } if (mismatch) { diff --git a/packages/svelte/src/internal/client/dom/blocks/key.js b/packages/svelte/src/internal/client/dom/blocks/key.js index a697163548..5e3c42019f 100644 --- a/packages/svelte/src/internal/client/dom/blocks/key.js +++ b/packages/svelte/src/internal/client/dom/blocks/key.js @@ -1,9 +1,12 @@ /** @import { Effect, TemplateNode } from '#client' */ +/** @import { Batch } from '../../reactivity/batch.js'; */ import { UNINITIALIZED } from '../../../../constants.js'; import { block, branch, pause_effect } from '../../reactivity/effects.js'; import { not_equal, safe_not_equal } from '../../reactivity/equality.js'; import { is_runes } from '../../context.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; +import { create_text, should_defer_append } from '../operations.js'; +import { current_batch } from '../../reactivity/batch.js'; /** * @template V @@ -12,7 +15,7 @@ import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; * @param {(anchor: Node) => TemplateNode | void} render_fn * @returns {void} */ -export function key_block(node, get_key, render_fn) { +export function key(node, get_key, render_fn) { if (hydrating) { hydrate_next(); } @@ -25,15 +28,48 @@ export function key_block(node, get_key, render_fn) { /** @type {Effect} */ var effect; + /** @type {Effect} */ + var pending_effect; + + /** @type {DocumentFragment | null} */ + var offscreen_fragment = null; + var changed = is_runes() ? not_equal : safe_not_equal; + function commit() { + if (effect) { + pause_effect(effect); + } + + if (offscreen_fragment !== null) { + // remove the anchor + /** @type {Text} */ (offscreen_fragment.lastChild).remove(); + + anchor.before(offscreen_fragment); + offscreen_fragment = null; + } + + effect = pending_effect; + } + block(() => { if (changed(key, (key = get_key()))) { - if (effect) { - pause_effect(effect); + var target = anchor; + + var defer = should_defer_append(); + + if (defer) { + offscreen_fragment = document.createDocumentFragment(); + offscreen_fragment.append((target = create_text())); } - effect = branch(() => render_fn(anchor)); + pending_effect = branch(() => render_fn(target)); + + if (defer) { + /** @type {Batch} */ (current_batch).add_callback(commit); + } else { + commit(); + } } }); diff --git a/packages/svelte/src/internal/client/dom/blocks/snippet.js b/packages/svelte/src/internal/client/dom/blocks/snippet.js index c6dce26bfe..32d88d4c60 100644 --- a/packages/svelte/src/internal/client/dom/blocks/snippet.js +++ b/packages/svelte/src/internal/client/dom/blocks/snippet.js @@ -1,7 +1,7 @@ /** @import { Snippet } from 'svelte' */ /** @import { Effect, TemplateNode } from '#client' */ /** @import { Getters } from '#shared' */ -import { EFFECT_TRANSPARENT } from '#client/constants'; +import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants'; import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js'; import { dev_current_component_function, @@ -102,7 +102,7 @@ export function createRawSnippet(fn) { var fragment = create_fragment_from_html(html); element = /** @type {Element} */ (get_first_child(fragment)); - if (DEV && (get_next_sibling(element) !== null || element.nodeType !== 1)) { + if (DEV && (get_next_sibling(element) !== null || element.nodeType !== ELEMENT_NODE)) { w.invalid_raw_snippet_render(); } diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js index ad21436505..f16da9c427 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js @@ -1,7 +1,10 @@ /** @import { TemplateNode, Dom, Effect } from '#client' */ +/** @import { Batch } from '../../reactivity/batch.js'; */ import { EFFECT_TRANSPARENT } from '#client/constants'; import { block, branch, pause_effect } from '../../reactivity/effects.js'; +import { current_batch } from '../../reactivity/batch.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; +import { create_text, should_defer_append } from '../operations.js'; /** * @template P @@ -24,16 +27,50 @@ export function component(node, get_component, render_fn) { /** @type {Effect | null} */ var effect; - block(() => { - if (component === (component = get_component())) return; + /** @type {DocumentFragment | null} */ + var offscreen_fragment = null; + /** @type {Effect | null} */ + var pending_effect = null; + + function commit() { if (effect) { pause_effect(effect); effect = null; } + if (offscreen_fragment) { + // remove the anchor + /** @type {Text} */ (offscreen_fragment.lastChild).remove(); + + anchor.before(offscreen_fragment); + offscreen_fragment = null; + } + + effect = pending_effect; + pending_effect = null; + } + + block(() => { + if (component === (component = get_component())) return; + + var defer = should_defer_append(); + if (component) { - effect = branch(() => render_fn(anchor, component)); + var target = anchor; + + if (defer) { + offscreen_fragment = document.createDocumentFragment(); + offscreen_fragment.append((target = create_text())); + } + + pending_effect = branch(() => render_fn(target, component)); + } + + if (defer) { + /** @type {Batch} */ (current_batch).add_callback(commit); + } else { + commit(); } }, EFFECT_TRANSPARENT); diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js index 43f669e844..231a3621b1 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js @@ -18,9 +18,9 @@ import { import { set_should_intro } from '../../render.js'; import { current_each_item, set_current_each_item } from './each.js'; import { active_effect } from '../../runtime.js'; -import { component_context } from '../../context.js'; +import { component_context, dev_stack } from '../../context.js'; import { DEV } from 'esm-env'; -import { EFFECT_TRANSPARENT } from '#client/constants'; +import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants'; import { assign_nodes } from '../template.js'; import { is_raw_text_element } from '../../../../utils.js'; @@ -51,7 +51,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio /** @type {null | Element} */ var element = null; - if (hydrating && hydrate_node.nodeType === 1) { + if (hydrating && hydrate_node.nodeType === ELEMENT_NODE) { element = /** @type {Element} */ (hydrate_node); hydrate_next(); } @@ -107,6 +107,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio if (DEV && location) { // @ts-expect-error element.__svelte_meta = { + parent: dev_stack, loc: { file: filename, line: location[0], diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index db2a0c4ef1..66d3371836 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -2,7 +2,7 @@ import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js'; import { create_text, get_first_child, get_next_sibling } from '../operations.js'; import { block } from '../../reactivity/effects.js'; -import { HEAD_EFFECT } from '#client/constants'; +import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants'; import { HYDRATION_START } from '../../../../constants.js'; /** @@ -37,7 +37,8 @@ export function head(render_fn) { while ( head_anchor !== null && - (head_anchor.nodeType !== 8 || /** @type {Comment} */ (head_anchor).data !== HYDRATION_START) + (head_anchor.nodeType !== COMMENT_NODE || + /** @type {Comment} */ (head_anchor).data !== HYDRATION_START) ) { head_anchor = /** @type {TemplateNode} */ (get_next_sibling(head_anchor)); } diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index fcce0b444f..22e532f5e4 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -20,9 +20,9 @@ import { clsx } from '../../../shared/attributes.js'; import { set_class } from './class.js'; import { set_style } from './style.js'; import { ATTACHMENT_KEY, NAMESPACE_HTML } from '../../../../constants.js'; -import { block, branch, destroy_effect } from '../../reactivity/effects.js'; -import { derived } from '../../reactivity/deriveds.js'; +import { block, branch, destroy_effect, effect } from '../../reactivity/effects.js'; import { init_select, select_option } from './bindings/select.js'; +import { flatten } from '../../reactivity/async.js'; export const CLASS = Symbol('class'); export const STYLE = Symbol('style'); @@ -345,7 +345,11 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal } var prev_value = current[key]; - if (value === prev_value) continue; + + // Skip if value is unchanged, unless it's `undefined` and the element still has the attribute + if (value === prev_value && !(value === undefined && element.hasAttribute(key))) { + continue; + } current[key] = value; @@ -458,62 +462,67 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal /** * @param {Element & ElementCSSInlineStyle} element * @param {(...expressions: any) => Record} fn - * @param {Array<() => any>} thunks + * @param {Array<() => any>} sync + * @param {Array<() => Promise>} async * @param {string} [css_hash] * @param {boolean} [skip_warning] */ export function attribute_effect( element, fn, - thunks = [], + sync = [], + async = [], css_hash, - skip_warning = false, - d = derived + skip_warning = false ) { - const deriveds = thunks.map(d); - - /** @type {Record | undefined} */ - var prev = undefined; + flatten(sync, async, (values) => { + /** @type {Record | undefined} */ + var prev = undefined; - /** @type {Record} */ - var effects = {}; + /** @type {Record} */ + var effects = {}; - var is_select = element.nodeName === 'SELECT'; - var inited = false; + var is_select = element.nodeName === 'SELECT'; + var inited = false; - block(() => { - var next = fn(...deriveds.map(get)); + block(() => { + var next = fn(...values.map(get)); + /** @type {Record} */ + var current = set_attributes(element, prev, next, css_hash, skip_warning); - set_attributes(element, prev, next, css_hash, skip_warning); + if (inited && is_select && 'value' in next) { + select_option(/** @type {HTMLSelectElement} */ (element), next.value); + } - if (inited && is_select && 'value' in next) { - select_option(/** @type {HTMLSelectElement} */ (element), next.value, false); - } + for (let symbol of Object.getOwnPropertySymbols(effects)) { + if (!next[symbol]) destroy_effect(effects[symbol]); + } - for (let symbol of Object.getOwnPropertySymbols(effects)) { - if (!next[symbol]) destroy_effect(effects[symbol]); - } + for (let symbol of Object.getOwnPropertySymbols(next)) { + var n = next[symbol]; - for (let symbol of Object.getOwnPropertySymbols(next)) { - var n = next[symbol]; + if (symbol.description === ATTACHMENT_KEY && (!prev || n !== prev[symbol])) { + if (effects[symbol]) destroy_effect(effects[symbol]); + effects[symbol] = branch(() => attach(element, () => n)); + } - if (symbol.description === ATTACHMENT_KEY && (!prev || n !== prev[symbol])) { - if (effects[symbol]) destroy_effect(effects[symbol]); - effects[symbol] = branch(() => attach(element, () => n)); + current[symbol] = n; } - } - prev = next; - }); + prev = current; + }); - if (is_select) { - init_select( - /** @type {HTMLSelectElement} */ (element), - () => /** @type {Record} */ (prev).value - ); - } + if (is_select) { + var select = /** @type {HTMLSelectElement} */ (element); - inited = true; + effect(() => { + select_option(select, /** @type {Record} */ (prev).value, true); + init_select(select); + }); + } + + inited = true; + }); } /** diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index f1992007ed..7c1fccea0f 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -1,3 +1,4 @@ +/** @import { Batch } from '../../../reactivity/batch.js' */ import { DEV } from 'esm-env'; import { render_effect, teardown } from '../../../reactivity/effects.js'; import { listen_to_event_and_reset_event } from './shared.js'; @@ -7,6 +8,7 @@ import { queue_micro_task } from '../../task.js'; import { hydrating } from '../../hydration.js'; import { untrack } from '../../../runtime.js'; import { is_runes } from '../../../context.js'; +import { current_batch } from '../../../reactivity/batch.js'; /** * @param {HTMLInputElement} input @@ -17,6 +19,8 @@ import { is_runes } from '../../../context.js'; export function bind_value(input, get, set = get) { var runes = is_runes(); + var batches = new WeakSet(); + listen_to_event_and_reset_event(input, 'input', (is_reset) => { if (DEV && input.type === 'checkbox') { // TODO should this happen in prod too? @@ -28,6 +32,10 @@ export function bind_value(input, get, set = get) { value = is_numberlike_input(input) ? to_number(value) : value; set(value); + if (current_batch !== null) { + batches.add(current_batch); + } + // In runes mode, respect any validation in accessors (doesn't apply in legacy mode, // because we use mutable state which ensures the render effect always runs) if (runes && value !== (value = get())) { @@ -54,6 +62,10 @@ export function bind_value(input, get, set = get) { (untrack(get) == null && input.value) ) { set(is_numberlike_input(input) ? to_number(input.value) : input.value); + + if (current_batch !== null) { + batches.add(current_batch); + } } render_effect(() => { @@ -64,6 +76,15 @@ export function bind_value(input, get, set = get) { var value = get(); + if (input === document.activeElement && batches.has(/** @type {Batch} */ (current_batch))) { + // Never rewrite the contents of a focused input. We can get here if, for example, + // an update is deferred because of async work depending on the input: + // + // + //

{await find(query)}

+ return; + } + if (is_numberlike_input(input) && value === to_number(input.value)) { // handles 0 vs 00 case (see https://github.com/sveltejs/svelte/issues/9959) return; diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js index e3263c65af..e39fb865cd 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js @@ -1,6 +1,5 @@ -import { effect } from '../../../reactivity/effects.js'; +import { effect, teardown } from '../../../reactivity/effects.js'; import { listen_to_event_and_reset_event } from './shared.js'; -import { untrack } from '../../../runtime.js'; import { is } from '../../../proxy.js'; import { is_array } from '../../../../shared/utils.js'; import * as w from '../../../warnings.js'; @@ -10,9 +9,9 @@ import * as w from '../../../warnings.js'; * @template V * @param {HTMLSelectElement} select * @param {V} value - * @param {boolean} [mounting] + * @param {boolean} mounting */ -export function select_option(select, value, mounting) { +export function select_option(select, value, mounting = false) { if (select.multiple) { // If value is null or undefined, keep the selection as is if (value == undefined) { @@ -51,40 +50,29 @@ export function select_option(select, value, mounting) { * current selection to the dom when it changes. Such * changes could for example occur when options are * inside an `#each` block. - * @template V * @param {HTMLSelectElement} select - * @param {() => V} [get_value] */ -export function init_select(select, get_value) { - let mounting = true; - effect(() => { - if (get_value) { - select_option(select, untrack(get_value), mounting); - } - mounting = false; +export function init_select(select) { + var observer = new MutationObserver(() => { + // @ts-ignore + select_option(select, select.__value); + // Deliberately don't update the potential binding value, + // the model should be preserved unless explicitly changed + }); + + observer.observe(select, { + // Listen to option element changes + childList: true, + subtree: true, // because of + // Listen to option element value attribute changes + // (doesn't get notified of select value changes, + // because that property is not reflected as an attribute) + attributes: true, + attributeFilter: ['value'] + }); - var observer = new MutationObserver(() => { - // @ts-ignore - var value = select.__value; - select_option(select, value); - // Deliberately don't update the potential binding value, - // the model should be preserved unless explicitly changed - }); - - observer.observe(select, { - // Listen to option element changes - childList: true, - subtree: true, // because of - // Listen to option element value attribute changes - // (doesn't get notified of select value changes, - // because that property is not reflected as an attribute) - attributes: true, - attributeFilter: ['value'] - }); - - return () => { - observer.disconnect(); - }; + teardown(() => { + observer.disconnect(); }); } @@ -136,7 +124,6 @@ export function bind_select_value(select, get, set = get) { mounting = false; }); - // don't pass get_value, we already initialize it in the effect above init_select(select); } diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index 38100e982c..00fad9ffdb 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -209,21 +209,14 @@ export function transition(flags, element, get_fn, get_params) { var outro; function get_options() { - var previous_reaction = active_reaction; - var previous_effect = active_effect; - set_active_reaction(null); - set_active_effect(null); - try { + return without_reactive_context(() => { // If a transition is still ongoing, we use the existing options rather than generating // new ones. This ensures that reversible transitions reverse smoothly, rather than // jumping to a new spot because (for example) a different `duration` was used return (current_options ??= get_fn()(element, get_params?.() ?? /** @type {P} */ ({}), { direction })); - } finally { - set_active_reaction(previous_reaction); - set_active_effect(previous_effect); - } + }); } /** @type {TransitionManager} */ diff --git a/packages/svelte/src/internal/client/dom/hydration.js b/packages/svelte/src/internal/client/dom/hydration.js index ab3256da82..1f80b7922b 100644 --- a/packages/svelte/src/internal/client/dom/hydration.js +++ b/packages/svelte/src/internal/client/dom/hydration.js @@ -1,5 +1,6 @@ /** @import { TemplateNode } from '#client' */ +import { COMMENT_NODE } from '#client/constants'; import { HYDRATION_END, HYDRATION_ERROR, @@ -87,7 +88,7 @@ export function remove_nodes() { var node = hydrate_node; while (true) { - if (node.nodeType === 8) { + if (node.nodeType === COMMENT_NODE) { var data = /** @type {Comment} */ (node).data; if (data === HYDRATION_END) { @@ -109,7 +110,7 @@ export function remove_nodes() { * @param {TemplateNode} node */ export function read_hydration_instruction(node) { - if (!node || node.nodeType !== 8) { + if (!node || node.nodeType !== COMMENT_NODE) { w.hydration_mismatch(); throw HYDRATION_ERROR; } diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 97062f04e3..fb269e47e0 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -1,8 +1,11 @@ -/** @import { TemplateNode } from '#client' */ +/** @import { Effect, TemplateNode } from '#client' */ import { hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; import { DEV } from 'esm-env'; import { init_array_prototype_warnings } from '../dev/equality.js'; import { get_descriptor, is_extensible } from '../../shared/utils.js'; +import { active_effect } from '../runtime.js'; +import { async_mode_flag } from '../../flags/index.js'; +import { TEXT_NODE, EFFECT_RAN } from '#client/constants'; // export these for reference in the compiled code, making global name deduplication unnecessary /** @type {Window} */ @@ -113,7 +116,7 @@ export function child(node, is_text) { // Child can be null if we have an element with a single child, like `

{text}

`, where `text` is empty if (child === null) { child = hydrate_node.appendChild(create_text()); - } else if (is_text && child.nodeType !== 3) { + } else if (is_text && child.nodeType !== TEXT_NODE) { var text = create_text(); child?.before(text); set_hydrate_node(text); @@ -143,7 +146,7 @@ export function first_child(fragment, is_text) { // if an {expression} is empty during SSR, there might be no // text node to hydrate — we must therefore create one - if (is_text && hydrate_node?.nodeType !== 3) { + if (is_text && hydrate_node?.nodeType !== TEXT_NODE) { var text = create_text(); hydrate_node?.before(text); @@ -174,11 +177,9 @@ export function sibling(node, count = 1, is_text = false) { return next_sibling; } - var type = next_sibling?.nodeType; - // if a sibling {expression} is empty during SSR, there might be no // text node to hydrate — we must therefore create one - if (is_text && type !== 3) { + if (is_text && next_sibling?.nodeType !== TEXT_NODE) { var text = create_text(); // If the next sibling is `null` and we're handling text then it's because // the SSR content was empty for the text, so we need to generate a new text @@ -205,6 +206,19 @@ export function clear_text_content(node) { node.textContent = ''; } +/** + * Returns `true` if we're updating the current block, for example `condition` in + * an `{#if condition}` block just changed. In this case, the branch should be + * appended (or removed) at the same time as other updates within the + * current `` + */ +export function should_defer_append() { + if (!async_mode_flag) return false; + + var flags = /** @type {Effect} */ (active_effect).f; + return (flags & EFFECT_RAN) !== 0; +} + /** * * @param {string} tag diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index 0b77ab1396..ebbf0039b2 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -20,6 +20,7 @@ import { TEMPLATE_USE_MATHML, TEMPLATE_USE_SVG } from '../../../constants.js'; +import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, TEXT_NODE } from '#client/constants'; /** * @param {TemplateNode} start @@ -264,7 +265,7 @@ function run_scripts(node) { // scripts were SSR'd, in which case they will run if (hydrating) return node; - const is_fragment = node.nodeType === 11; + const is_fragment = node.nodeType === DOCUMENT_FRAGMENT_NODE; const scripts = /** @type {HTMLElement} */ (node).tagName === 'SCRIPT' ? [/** @type {HTMLScriptElement} */ (node)] @@ -305,7 +306,7 @@ export function text(value = '') { var node = hydrate_node; - if (node.nodeType !== 3) { + if (node.nodeType !== TEXT_NODE) { // if an {expression} is empty during SSR, we need to insert an empty text node node.before((node = create_text())); set_hydrate_node(node); @@ -360,7 +361,7 @@ export function props_id() { if ( hydrating && hydrate_node && - hydrate_node.nodeType === 8 && + hydrate_node.nodeType === COMMENT_NODE && hydrate_node.textContent?.startsWith(`#`) ) { const id = hydrate_node.textContent.substring(1); diff --git a/packages/svelte/src/internal/client/error-handling.js b/packages/svelte/src/internal/client/error-handling.js index 74d95b1e6a..6c83a453d5 100644 --- a/packages/svelte/src/internal/client/error-handling.js +++ b/packages/svelte/src/internal/client/error-handling.js @@ -1,30 +1,42 @@ -/** @import { Effect } from '#client' */ +/** @import { Derived, Effect } from '#client' */ +/** @import { Boundary } from './dom/blocks/boundary.js' */ import { DEV } from 'esm-env'; import { FILENAME } from '../../constants.js'; import { is_firefox } from './dom/operations.js'; -import { BOUNDARY_EFFECT, EFFECT_RAN } from './constants.js'; +import { ERROR_VALUE, BOUNDARY_EFFECT, EFFECT_RAN } from './constants.js'; import { define_property, get_descriptor } from '../shared/utils.js'; -import { active_effect } from './runtime.js'; +import { active_effect, active_reaction } from './runtime.js'; + +const adjustments = new WeakMap(); /** * @param {unknown} error */ export function handle_error(error) { - var effect = /** @type {Effect} */ (active_effect); + var effect = active_effect; + + // for unowned deriveds, don't throw until we read the value + if (effect === null) { + /** @type {Derived} */ (active_reaction).f |= ERROR_VALUE; + return error; + } - if (DEV && error instanceof Error) { - adjust_error(error, effect); + if (DEV && error instanceof Error && !adjustments.has(error)) { + adjustments.set(error, get_adjustments(error, effect)); } if ((effect.f & EFFECT_RAN) === 0) { // if the error occurred while creating this subtree, we let it // bubble up until it hits a boundary that can handle it if ((effect.f & BOUNDARY_EFFECT) === 0) { + if (!effect.parent && error instanceof Error) { + apply_adjustments(error); + } + throw error; } - // @ts-expect-error - effect.fn(error); + /** @type {Boundary} */ (effect.b).error(error); } else { // otherwise we bubble up the effect tree ourselves invoke_error_boundary(error, effect); @@ -39,14 +51,9 @@ export function invoke_error_boundary(error, effect) { while (effect !== null) { if ((effect.f & BOUNDARY_EFFECT) !== 0) { try { - // @ts-expect-error - effect.fn(error); + /** @type {Boundary} */ (effect.b).error(error); return; } catch (e) { - if (DEV && e instanceof Error) { - adjust_error(e, effect); - } - error = e; } } @@ -54,21 +61,19 @@ export function invoke_error_boundary(error, effect) { effect = effect.parent; } + if (error instanceof Error) { + apply_adjustments(error); + } + throw error; } -/** @type {WeakSet} */ -const adjusted_errors = new WeakSet(); - /** * Add useful information to the error message/stack in development * @param {Error} error * @param {Effect} effect */ -function adjust_error(error, effect) { - if (adjusted_errors.has(error)) return; - adjusted_errors.add(error); - +function get_adjustments(error, effect) { const message_descriptor = get_descriptor(error, 'message'); // if the message was already changed and it's not configurable we can't change it @@ -84,17 +89,28 @@ function adjust_error(error, effect) { context = context.p; } - define_property(error, 'message', { - value: error.message + `\n${component_stack}\n` - }); + return { + message: error.message + `\n${component_stack}\n`, + stack: error.stack + ?.split('\n') + .filter((line) => !line.includes('svelte/src/internal')) + .join('\n') + }; +} + +/** + * @param {Error} error + */ +function apply_adjustments(error) { + const adjusted = adjustments.get(error); + + if (adjusted) { + define_property(error, 'message', { + value: adjusted.message + }); - if (error.stack) { - // Filter out internal modules define_property(error, 'stack', { - value: error.stack - .split('\n') - .filter((line) => !line.includes('svelte/src/internal')) - .join('\n') + value: adjusted.stack }); } } diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index b66f929d29..42b1889cbd 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -2,6 +2,24 @@ import { DEV } from 'esm-env'; +export * from '../shared/errors.js'; + +/** + * Cannot create a `$derived(...)` with an `await` expression outside of an effect tree + * @returns {never} + */ +export function async_derived_orphan() { + if (DEV) { + const error = new Error(`async_derived_orphan\nCannot create a \`$derived(...)\` with an \`await\` expression outside of an effect tree\nhttps://svelte.dev/e/async_derived_orphan`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/async_derived_orphan`); + } +} + /** * Using `bind:value` together with a checkbox input is not allowed. Use `bind:checked` instead * @returns {never} @@ -11,6 +29,7 @@ export function bind_invalid_checkbox_value() { const error = new Error(`bind_invalid_checkbox_value\nUsing \`bind:value\` together with a checkbox input is not allowed. Use \`bind:checked\` instead\nhttps://svelte.dev/e/bind_invalid_checkbox_value`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/bind_invalid_checkbox_value`); @@ -29,6 +48,7 @@ export function bind_invalid_export(component, key, name) { const error = new Error(`bind_invalid_export\nComponent ${component} has an export named \`${key}\` that a consumer component is trying to access using \`bind:${key}\`, which is disallowed. Instead, use \`bind:this\` (e.g. \`<${name} bind:this={component} />\`) and then access the property on the bound component instance (e.g. \`component.${key}\`)\nhttps://svelte.dev/e/bind_invalid_export`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/bind_invalid_export`); @@ -47,6 +67,7 @@ export function bind_not_bindable(key, component, name) { const error = new Error(`bind_not_bindable\nA component is attempting to bind to a non-bindable property \`${key}\` belonging to ${component} (i.e. \`<${name} bind:${key}={...}>\`). To mark a property as bindable: \`let { ${key} = $bindable() } = $props()\`\nhttps://svelte.dev/e/bind_not_bindable`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/bind_not_bindable`); @@ -64,6 +85,7 @@ export function component_api_changed(method, component) { const error = new Error(`component_api_changed\nCalling \`${method}\` on a component instance (of ${component}) is no longer valid in Svelte 5\nhttps://svelte.dev/e/component_api_changed`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/component_api_changed`); @@ -81,6 +103,7 @@ export function component_api_invalid_new(component, name) { const error = new Error(`component_api_invalid_new\nAttempted to instantiate ${component} with \`new ${name}\`, which is no longer valid in Svelte 5. If this component is not under your control, set the \`compatibility.componentApi\` compiler option to \`4\` to keep it working.\nhttps://svelte.dev/e/component_api_invalid_new`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/component_api_invalid_new`); @@ -96,6 +119,7 @@ export function derived_references_self() { const error = new Error(`derived_references_self\nA derived value cannot reference itself recursively\nhttps://svelte.dev/e/derived_references_self`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/derived_references_self`); @@ -111,9 +135,12 @@ export function derived_references_self() { */ export function each_key_duplicate(a, b, value) { if (DEV) { - const error = new Error(`each_key_duplicate\n${value ? `Keyed each block has duplicate key \`${value}\` at indexes ${a} and ${b}` : `Keyed each block has duplicate key at indexes ${a} and ${b}`}\nhttps://svelte.dev/e/each_key_duplicate`); + const error = new Error(`each_key_duplicate\n${value + ? `Keyed each block has duplicate key \`${value}\` at indexes ${a} and ${b}` + : `Keyed each block has duplicate key at indexes ${a} and ${b}`}\nhttps://svelte.dev/e/each_key_duplicate`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/each_key_duplicate`); @@ -130,6 +157,7 @@ export function effect_in_teardown(rune) { const error = new Error(`effect_in_teardown\n\`${rune}\` cannot be used inside an effect cleanup function\nhttps://svelte.dev/e/effect_in_teardown`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/effect_in_teardown`); @@ -145,6 +173,7 @@ export function effect_in_unowned_derived() { const error = new Error(`effect_in_unowned_derived\nEffect cannot be created inside a \`$derived\` value that was not itself created inside an effect\nhttps://svelte.dev/e/effect_in_unowned_derived`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/effect_in_unowned_derived`); @@ -161,6 +190,7 @@ export function effect_orphan(rune) { const error = new Error(`effect_orphan\n\`${rune}\` can only be used inside an effect (e.g. during component initialisation)\nhttps://svelte.dev/e/effect_orphan`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/effect_orphan`); @@ -168,20 +198,69 @@ export function effect_orphan(rune) { } /** - * Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops + * `$effect.pending()` can only be called inside an effect or derived + * @returns {never} + */ +export function effect_pending_outside_reaction() { + if (DEV) { + const error = new Error(`effect_pending_outside_reaction\n\`$effect.pending()\` can only be called inside an effect or derived\nhttps://svelte.dev/e/effect_pending_outside_reaction`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/effect_pending_outside_reaction`); + } +} + +/** + * Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state * @returns {never} */ export function effect_update_depth_exceeded() { if (DEV) { - const error = new Error(`effect_update_depth_exceeded\nMaximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops\nhttps://svelte.dev/e/effect_update_depth_exceeded`); + const error = new Error(`effect_update_depth_exceeded\nMaximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state\nhttps://svelte.dev/e/effect_update_depth_exceeded`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/effect_update_depth_exceeded`); } } +/** + * Cannot use `flushSync` inside an effect + * @returns {never} + */ +export function flush_sync_in_effect() { + if (DEV) { + const error = new Error(`flush_sync_in_effect\nCannot use \`flushSync\` inside an effect\nhttps://svelte.dev/e/flush_sync_in_effect`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/flush_sync_in_effect`); + } +} + +/** + * `getAbortSignal()` can only be called inside an effect or derived + * @returns {never} + */ +export function get_abort_signal_outside_reaction() { + if (DEV) { + const error = new Error(`get_abort_signal_outside_reaction\n\`getAbortSignal()\` can only be called inside an effect or derived\nhttps://svelte.dev/e/get_abort_signal_outside_reaction`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/get_abort_signal_outside_reaction`); + } +} + /** * Failed to hydrate the application * @returns {never} @@ -191,6 +270,7 @@ export function hydration_failed() { const error = new Error(`hydration_failed\nFailed to hydrate the application\nhttps://svelte.dev/e/hydration_failed`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/hydration_failed`); @@ -206,6 +286,7 @@ export function invalid_snippet() { const error = new Error(`invalid_snippet\nCould not \`{@render}\` snippet due to the expression being \`null\` or \`undefined\`. Consider using optional chaining \`{@render snippet?.()}\`\nhttps://svelte.dev/e/invalid_snippet`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/invalid_snippet`); @@ -222,6 +303,7 @@ export function lifecycle_legacy_only(name) { const error = new Error(`lifecycle_legacy_only\n\`${name}(...)\` cannot be used in runes mode\nhttps://svelte.dev/e/lifecycle_legacy_only`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/lifecycle_legacy_only`); @@ -238,6 +320,7 @@ export function props_invalid_value(key) { const error = new Error(`props_invalid_value\nCannot do \`bind:${key}={undefined}\` when \`${key}\` has a fallback value\nhttps://svelte.dev/e/props_invalid_value`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/props_invalid_value`); @@ -254,6 +337,7 @@ export function props_rest_readonly(property) { const error = new Error(`props_rest_readonly\nRest element properties of \`$props()\` such as \`${property}\` are readonly\nhttps://svelte.dev/e/props_rest_readonly`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/props_rest_readonly`); @@ -270,12 +354,29 @@ export function rune_outside_svelte(rune) { const error = new Error(`rune_outside_svelte\nThe \`${rune}\` rune is only available inside \`.svelte\` and \`.svelte.js/ts\` files\nhttps://svelte.dev/e/rune_outside_svelte`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/rune_outside_svelte`); } } +/** + * `setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression + * @returns {never} + */ +export function set_context_after_init() { + if (DEV) { + const error = new Error(`set_context_after_init\n\`setContext\` must be called when a component first initializes, not in a subsequent effect or after an \`await\` expression\nhttps://svelte.dev/e/set_context_after_init`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/set_context_after_init`); + } +} + /** * Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`. * @returns {never} @@ -285,6 +386,7 @@ export function state_descriptors_fixed() { const error = new Error(`state_descriptors_fixed\nProperty descriptors defined on \`$state\` objects must contain \`value\` and always be \`enumerable\`, \`configurable\` and \`writable\`.\nhttps://svelte.dev/e/state_descriptors_fixed`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/state_descriptors_fixed`); @@ -300,6 +402,7 @@ export function state_prototype_fixed() { const error = new Error(`state_prototype_fixed\nCannot set prototype of \`$state\` object\nhttps://svelte.dev/e/state_prototype_fixed`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/state_prototype_fixed`); @@ -307,14 +410,15 @@ export function state_prototype_fixed() { } /** - * Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state` + * Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state` * @returns {never} */ export function state_unsafe_mutation() { if (DEV) { - const error = new Error(`state_unsafe_mutation\nUpdating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without \`$state\`\nhttps://svelte.dev/e/state_unsafe_mutation`); + const error = new Error(`state_unsafe_mutation\nUpdating state inside \`$derived(...)\`, \`$inspect(...)\` or a template expression is forbidden. If the value should not be reactive, declare it without \`$state\`\nhttps://svelte.dev/e/state_unsafe_mutation`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/state_unsafe_mutation`); diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 60f9af9120..cddb432a98 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -1,6 +1,6 @@ export { createAttachmentKey as attachment } from '../../attachments/index.js'; export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js'; -export { push, pop } from './context.js'; +export { push, pop, add_svelte_meta } from './context.js'; export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js'; export { cleanup_styles } from './dev/css.js'; export { add_locations } from './dev/elements.js'; @@ -9,10 +9,11 @@ export { create_ownership_validator } from './dev/ownership.js'; export { check_target, legacy_api } from './dev/legacy.js'; export { trace, tag, tag_proxy } from './dev/tracing.js'; export { inspect } from './dev/inspect.js'; +export { async } from './dom/blocks/async.js'; export { validate_snippet_args } from './dev/validation.js'; export { await_block as await } from './dom/blocks/await.js'; export { if_block as if } from './dom/blocks/if.js'; -export { key_block as key } from './dom/blocks/key.js'; +export { key } from './dom/blocks/key.js'; export { css_props } from './dom/blocks/css-props.js'; export { index, each } from './dom/blocks/each.js'; export { html } from './dom/blocks/html.js'; @@ -97,8 +98,15 @@ export { props_id, with_script } from './dom/template.js'; -export { user_derived as derived, derived_safe_equal } from './reactivity/deriveds.js'; +export { save, track_reactivity_loss } from './reactivity/async.js'; +export { flushSync as flush, suspend } from './reactivity/batch.js'; export { + async_derived, + user_derived as derived, + derived_safe_equal +} from './reactivity/deriveds.js'; +export { + aborted, effect_tracking, effect_root, legacy_pre_effect, @@ -129,13 +137,12 @@ export { update_store, mark_store_binding } from './reactivity/store.js'; -export { boundary } from './dom/blocks/boundary.js'; +export { boundary, pending } from './dom/blocks/boundary.js'; export { set_text } from './render.js'; export { get, safe_get, invalidate_inner_signals, - flushSync as flush, tick, untrack, exclude_from_object, diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index 4870506699..3ae4b87ed5 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -1,6 +1,13 @@ /** @import { Source } from '#client' */ import { DEV } from 'esm-env'; -import { get, active_effect, active_reaction, set_active_reaction } from './runtime.js'; +import { + get, + active_effect, + update_version, + active_reaction, + set_update_version, + set_active_reaction +} from './runtime.js'; import { array_prototype, get_descriptor, @@ -8,7 +15,13 @@ import { is_array, object_prototype } from '../shared/utils.js'; -import { state as source, set } from './reactivity/sources.js'; +import { + state as source, + set, + increment, + flush_inspect_effects, + set_inspect_effects_deferred +} from './reactivity/sources.js'; import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; import { UNINITIALIZED } from '../../constants.js'; import * as e from './errors.js'; @@ -41,20 +54,31 @@ export function proxy(value) { var version = source(0); var stack = DEV && tracing_mode_flag ? get_stack('CreatedAt') : null; - var reaction = active_reaction; + var parent_version = update_version; /** + * Executes the proxy in the context of the reaction it was originally created in, if any * @template T * @param {() => T} fn */ var with_parent = (fn) => { - var previous_reaction = active_reaction; - set_active_reaction(reaction); + if (update_version === parent_version) { + return fn(); + } + + // child source is being created after the initial proxy — + // prevent it from being associated with the current reaction + var reaction = active_reaction; + var version = update_version; + + set_active_reaction(null); + set_update_version(parent_version); - /** @type {T} */ var result = fn(); - set_active_reaction(previous_reaction); + set_active_reaction(reaction); + set_update_version(version); + return result; }; @@ -62,6 +86,9 @@ export function proxy(value) { // We need to create the length source eagerly to ensure that // mutations to the array are properly synced with our proxy sources.set('length', source(/** @type {any[]} */ (value).length, stack)); + if (DEV) { + value = /** @type {any} */ (inspectable_array(/** @type {any[]} */ (value))); + } } /** Used in dev for $inspect.trace() */ @@ -93,21 +120,19 @@ export function proxy(value) { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/getOwnPropertyDescriptor#invariants e.state_descriptors_fixed(); } - - with_parent(() => { - var s = sources.get(prop); - - if (s === undefined) { - s = source(descriptor.value, stack); + var s = sources.get(prop); + if (s === undefined) { + s = with_parent(() => { + var s = source(descriptor.value, stack); sources.set(prop, s); - if (DEV && typeof prop === 'string') { tag(s, get_label(path, prop)); } - } else { - set(s, descriptor.value, true); - } - }); + return s; + }); + } else { + set(s, descriptor.value, true); + } return true; }, @@ -119,25 +144,15 @@ export function proxy(value) { if (prop in target) { const s = with_parent(() => source(UNINITIALIZED, stack)); sources.set(prop, s); - update_version(version); + increment(version); if (DEV) { tag(s, get_label(path, prop)); } } } else { - // When working with arrays, we need to also ensure we update the length when removing - // an indexed property - if (is_proxied_array && typeof prop === 'string') { - var ls = /** @type {Source} */ (sources.get('length')); - var n = Number(prop); - - if (Number.isInteger(n) && n < ls.v) { - set(ls, n); - } - } set(s, UNINITIALIZED); - update_version(version); + increment(version); } return true; @@ -268,11 +283,8 @@ export function proxy(value) { // object property before writing to that property. if (s === undefined) { if (!has || get_descriptor(target, prop)?.writable) { - s = with_parent(() => { - var s = source(undefined, stack); - set(s, proxy(value)); - return s; - }); + s = with_parent(() => source(undefined, stack)); + set(s, proxy(value)); sources.set(prop, s); @@ -308,7 +320,7 @@ export function proxy(value) { } } - update_version(version); + increment(version); } return true; @@ -347,14 +359,6 @@ function get_label(path, prop) { return /^\d+$/.test(prop) ? `${path}[${prop}]` : `${path}['${prop}']`; } -/** - * @param {Source} signal - * @param {1 | -1} [d] - */ -function update_version(signal, d = 1) { - set(signal, signal.v + d); -} - /** * @param {any} value */ @@ -383,3 +387,42 @@ export function get_proxied_value(value) { export function is(a, b) { return Object.is(get_proxied_value(a), get_proxied_value(b)); } + +const ARRAY_MUTATING_METHODS = new Set([ + 'copyWithin', + 'fill', + 'pop', + 'push', + 'reverse', + 'shift', + 'sort', + 'splice', + 'unshift' +]); + +/** + * Wrap array mutating methods so $inspect is triggered only once and + * to prevent logging an array in intermediate state (e.g. with an empty slot) + * @param {any[]} array + */ +function inspectable_array(array) { + return new Proxy(array, { + get(target, prop, receiver) { + var value = Reflect.get(target, prop, receiver); + if (!ARRAY_MUTATING_METHODS.has(/** @type {string} */ (prop))) { + return value; + } + + /** + * @this {any[]} + * @param {any[]} args + */ + return function (...args) { + set_inspect_effects_deferred(); + var result = value.apply(this, args); + flush_inspect_effects(); + return result; + }; + } + }); +} diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js new file mode 100644 index 0000000000..c200f10dba --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -0,0 +1,127 @@ +/** @import { Effect, Value } from '#client' */ + +import { DESTROYED } from '#client/constants'; +import { DEV } from 'esm-env'; +import { component_context, is_runes, set_component_context } from '../context.js'; +import { get_pending_boundary } from '../dom/blocks/boundary.js'; +import { invoke_error_boundary } from '../error-handling.js'; +import { + active_effect, + active_reaction, + set_active_effect, + set_active_reaction +} from '../runtime.js'; +import { current_batch } from './batch.js'; +import { + async_derived, + current_async_effect, + derived, + derived_safe_equal, + set_from_async_derived +} from './deriveds.js'; + +/** + * + * @param {Array<() => any>} sync + * @param {Array<() => Promise>} async + * @param {(values: Value[]) => any} fn + */ +export function flatten(sync, async, fn) { + const d = is_runes() ? derived : derived_safe_equal; + + if (async.length === 0) { + fn(sync.map(d)); + return; + } + + var batch = current_batch; + var parent = /** @type {Effect} */ (active_effect); + + var restore = capture(); + var boundary = get_pending_boundary(); + + Promise.all(async.map((expression) => async_derived(expression))) + .then((result) => { + batch?.activate(); + + restore(); + + try { + fn([...sync.map(d), ...result]); + } catch (error) { + // ignore errors in blocks that have already been destroyed + if ((parent.f & DESTROYED) === 0) { + invoke_error_boundary(error, parent); + } + } + + batch?.deactivate(); + unset_context(); + }) + .catch((error) => { + boundary.error(error); + }); +} + +/** + * Captures the current effect context so that we can restore it after + * some asynchronous work has happened (so that e.g. `await a + b` + * causes `b` to be registered as a dependency). + */ +function capture() { + var previous_effect = active_effect; + var previous_reaction = active_reaction; + var previous_component_context = component_context; + + return function restore() { + set_active_effect(previous_effect); + set_active_reaction(previous_reaction); + set_component_context(previous_component_context); + + if (DEV) { + set_from_async_derived(null); + } + }; +} + +/** + * Wraps an `await` expression in such a way that the effect context that was + * active before the expression evaluated can be reapplied afterwards — + * `await a + b` becomes `(await $.save(a))() + b` + * @template T + * @param {Promise} promise + * @returns {Promise<() => T>} + */ +export async function save(promise) { + var restore = capture(); + var value = await promise; + + return () => { + restore(); + return value; + }; +} + +/** + * Reset `current_async_effect` after the `promise` resolves, so + * that we can emit `await_reactivity_loss` warnings + * @template T + * @param {Promise} promise + * @returns {Promise<() => T>} + */ +export async function track_reactivity_loss(promise) { + var previous_async_effect = current_async_effect; + var value = await promise; + + return () => { + set_from_async_derived(previous_async_effect); + return value; + }; +} + +export function unset_context() { + set_active_effect(null); + set_active_reaction(null); + set_component_context(null); + if (DEV) set_from_async_derived(null); +} diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js new file mode 100644 index 0000000000..cdce971b18 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -0,0 +1,605 @@ +/** @import { Derived, Effect, Source } from '#client' */ +import { + BLOCK_EFFECT, + BRANCH_EFFECT, + CLEAN, + DESTROYED, + DIRTY, + EFFECT, + ASYNC, + INERT, + RENDER_EFFECT, + ROOT_EFFECT, + USER_EFFECT +} from '#client/constants'; +import { async_mode_flag } from '../../flags/index.js'; +import { deferred, define_property } from '../../shared/utils.js'; +import { get_pending_boundary } from '../dom/blocks/boundary.js'; +import { + active_effect, + is_dirty, + is_updating_effect, + set_is_updating_effect, + set_signal_status, + update_effect, + write_version +} from '../runtime.js'; +import * as e from '../errors.js'; +import { flush_tasks } from '../dom/task.js'; +import { DEV } from 'esm-env'; +import { invoke_error_boundary } from '../error-handling.js'; +import { old_values } from './sources.js'; +import { unlink_effect } from './effects.js'; +import { unset_context } from './async.js'; + +/** @type {Set} */ +const batches = new Set(); + +/** @type {Batch | null} */ +export let current_batch = null; + +/** + * When time travelling, we re-evaluate deriveds based on the temporary + * values of their dependencies rather than their actual values, and cache + * the results in this map rather than on the deriveds themselves + * @type {Map | null} + */ +export let batch_deriveds = null; + +/** @type {Set<() => void>} */ +export let effect_pending_updates = new Set(); + +/** @type {Effect[]} */ +let queued_root_effects = []; + +/** @type {Effect | null} */ +let last_scheduled_effect = null; + +let is_flushing = false; + +export class Batch { + /** + * The current values of any sources that are updated in this batch + * They keys of this map are identical to `this.#previous` + * @type {Map} + */ + #current = new Map(); + + /** + * The values of any sources that are updated in this batch _before_ those updates took place. + * They keys of this map are identical to `this.#current` + * @type {Map} + */ + #previous = new Map(); + + /** + * When the batch is committed (and the DOM is updated), we need to remove old branches + * and append new ones by calling the functions added inside (if/each/key/etc) blocks + * @type {Set<() => void>} + */ + #callbacks = new Set(); + + /** + * The number of async effects that are currently in flight + */ + #pending = 0; + + /** + * A deferred that resolves when the batch is committed, used with `settled()` + * TODO replace with Promise.withResolvers once supported widely enough + * @type {{ promise: Promise, resolve: (value?: any) => void, reject: (reason: unknown) => void } | null} + */ + #deferred = null; + + /** + * True if an async effect inside this batch resolved and + * its parent branch was already deleted + */ + #neutered = false; + + /** + * Async effects (created inside `async_derived`) encountered during processing. + * These run after the rest of the batch has updated, since they should + * always have the latest values + * @type {Effect[]} + */ + #async_effects = []; + + /** + * The same as `#async_effects`, but for effects inside a newly-created + * `` — these do not prevent the batch from committing + * @type {Effect[]} + */ + #boundary_async_effects = []; + + /** + * Template effects and `$effect.pre` effects, which run when + * a batch is committed + * @type {Effect[]} + */ + #render_effects = []; + + /** + * The same as `#render_effects`, but for `$effect` (which runs after) + * @type {Effect[]} + */ + #effects = []; + + /** + * Block effects, which may need to re-run on subsequent flushes + * in order to update internal sources (e.g. each block items) + * @type {Effect[]} + */ + #block_effects = []; + + /** + * A set of branches that still exist, but will be destroyed when this batch + * is committed — we skip over these during `process` + * @type {Set} + */ + skipped_effects = new Set(); + + /** + * + * @param {Effect[]} root_effects + */ + #process(root_effects) { + queued_root_effects = []; + + /** @type {Map | null} */ + var current_values = null; + + // if there are multiple batches, we are 'time travelling' — + // we need to undo the changes belonging to any batch + // other than the current one + if (batches.size > 1) { + current_values = new Map(); + batch_deriveds = new Map(); + + for (const [source, current] of this.#current) { + current_values.set(source, { v: source.v, wv: source.wv }); + source.v = current; + } + + for (const batch of batches) { + if (batch === this) continue; + + for (const [source, previous] of batch.#previous) { + if (!current_values.has(source)) { + current_values.set(source, { v: source.v, wv: source.wv }); + source.v = previous; + } + } + } + } + + for (const root of root_effects) { + this.#traverse_effect_tree(root); + } + + // if we didn't start any new async work, and no async work + // is outstanding from a previous flush, commit + if (this.#async_effects.length === 0 && this.#pending === 0) { + var render_effects = this.#render_effects; + var effects = this.#effects; + + this.#render_effects = []; + this.#effects = []; + this.#block_effects = []; + + this.#commit(); + + flush_queued_effects(render_effects); + flush_queued_effects(effects); + + this.#deferred?.resolve(); + } else { + // otherwise mark effects clean so they get scheduled on the next run + for (const e of this.#render_effects) set_signal_status(e, CLEAN); + for (const e of this.#effects) set_signal_status(e, CLEAN); + for (const e of this.#block_effects) set_signal_status(e, CLEAN); + } + + if (current_values) { + for (const [source, { v, wv }] of current_values) { + // reset the source to the current value (unless + // it got a newer value as a result of effects running) + if (source.wv <= wv) { + source.v = v; + } + } + + batch_deriveds = null; + } + + for (const effect of this.#async_effects) { + update_effect(effect); + } + + for (const effect of this.#boundary_async_effects) { + update_effect(effect); + } + + this.#async_effects = []; + this.#boundary_async_effects = []; + } + + /** + * Traverse the effect tree, executing effects or stashing + * them for later execution as appropriate + * @param {Effect} root + */ + #traverse_effect_tree(root) { + root.f ^= CLEAN; + + var effect = root.first; + + while (effect !== null) { + var flags = effect.f; + var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; + var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; + + var skip = is_skippable_branch || (flags & INERT) !== 0 || this.skipped_effects.has(effect); + + if (!skip && effect.fn !== null) { + if (is_branch) { + effect.f ^= CLEAN; + } else if ((flags & EFFECT) !== 0) { + this.#effects.push(effect); + } else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) { + this.#render_effects.push(effect); + } else if (is_dirty(effect)) { + if ((flags & ASYNC) !== 0) { + var effects = effect.b?.pending ? this.#boundary_async_effects : this.#async_effects; + effects.push(effect); + } else { + if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); + update_effect(effect); + } + } + + var child = effect.first; + + if (child !== null) { + effect = child; + continue; + } + } + + var parent = effect.parent; + effect = effect.next; + + while (effect === null && parent !== null) { + effect = parent.next; + parent = parent.parent; + } + } + } + + /** + * Associate a change to a given source with the current + * batch, noting its previous and current values + * @param {Source} source + * @param {any} value + */ + capture(source, value) { + if (!this.#previous.has(source)) { + this.#previous.set(source, value); + } + + this.#current.set(source, source.v); + } + + activate() { + current_batch = this; + } + + deactivate() { + current_batch = null; + + for (const update of effect_pending_updates) { + effect_pending_updates.delete(update); + update(); + + if (current_batch !== null) { + // only do one at a time + break; + } + } + } + + neuter() { + this.#neutered = true; + } + + flush() { + if (queued_root_effects.length > 0) { + this.flush_effects(); + } else { + this.#commit(); + } + + if (current_batch !== this) { + // this can happen if a `flushSync` occurred during `this.flush_effects()`, + // which is permitted in legacy mode despite being a terrible idea + return; + } + + if (this.#pending === 0) { + batches.delete(this); + } + + this.deactivate(); + } + + flush_effects() { + var was_updating_effect = is_updating_effect; + is_flushing = true; + + try { + var flush_count = 0; + set_is_updating_effect(true); + + while (queued_root_effects.length > 0) { + if (flush_count++ > 1000) { + if (DEV) { + var updates = new Map(); + + for (const source of this.#current.keys()) { + for (const [stack, update] of source.updated ?? []) { + var entry = updates.get(stack); + + if (!entry) { + entry = { error: update.error, count: 0 }; + updates.set(stack, entry); + } + + entry.count += update.count; + } + } + + for (const update of updates.values()) { + // eslint-disable-next-line no-console + console.error(update.error); + } + } + + infinite_loop_guard(); + } + + this.#process(queued_root_effects); + old_values.clear(); + } + } finally { + is_flushing = false; + set_is_updating_effect(was_updating_effect); + + last_scheduled_effect = null; + } + } + + /** + * Append and remove branches to/from the DOM + */ + #commit() { + if (!this.#neutered) { + for (const fn of this.#callbacks) { + fn(); + } + } + + this.#callbacks.clear(); + } + + increment() { + this.#pending += 1; + } + + decrement() { + this.#pending -= 1; + + if (this.#pending === 0) { + for (const e of this.#render_effects) { + set_signal_status(e, DIRTY); + schedule_effect(e); + } + + for (const e of this.#effects) { + set_signal_status(e, DIRTY); + schedule_effect(e); + } + + for (const e of this.#block_effects) { + set_signal_status(e, DIRTY); + schedule_effect(e); + } + + this.#render_effects = []; + this.#effects = []; + + this.flush(); + } else { + this.deactivate(); + } + } + + /** @param {() => void} fn */ + add_callback(fn) { + this.#callbacks.add(fn); + } + + settled() { + return (this.#deferred ??= deferred()).promise; + } + + static ensure(autoflush = true) { + if (current_batch === null) { + const batch = (current_batch = new Batch()); + batches.add(current_batch); + + if (autoflush) { + queueMicrotask(() => { + if (current_batch !== batch) { + // a flushSync happened in the meantime + return; + } + + batch.flush(); + }); + } + } + + return current_batch; + } +} + +/** + * Synchronously flush any pending updates. + * Returns void if no callback is provided, otherwise returns the result of calling the callback. + * @template [T=void] + * @param {(() => T) | undefined} [fn] + * @returns {T} + */ +export function flushSync(fn) { + if (async_mode_flag && active_effect !== null) { + e.flush_sync_in_effect(); + } + + var result; + + const batch = Batch.ensure(false); + + if (fn) { + batch.flush_effects(); + + result = fn(); + } + + while (true) { + flush_tasks(); + + if (queued_root_effects.length === 0) { + if (batch === current_batch) { + batch.flush(); + } + + // this would be reset in `batch.flush_effects()` but since we are early returning here, + // we need to reset it here as well in case the first time there's 0 queued root effects + last_scheduled_effect = null; + + return /** @type {T} */ (result); + } + + batch.flush_effects(); + } +} + +function infinite_loop_guard() { + try { + e.effect_update_depth_exceeded(); + } catch (error) { + if (DEV) { + // stack contains no useful information, replace it + define_property(error, 'stack', { value: '' }); + } + + // Best effort: invoke the boundary nearest the most recent + // effect and hope that it's relevant to the infinite loop + invoke_error_boundary(error, last_scheduled_effect); + } +} + +/** + * @param {Array} effects + * @returns {void} + */ +function flush_queued_effects(effects) { + var length = effects.length; + if (length === 0) return; + + for (var i = 0; i < length; i++) { + var effect = effects[i]; + + if ((effect.f & (DESTROYED | INERT)) === 0) { + if (is_dirty(effect)) { + var wv = write_version; + + update_effect(effect); + + // Effects with no dependencies or teardown do not get added to the effect tree. + // Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we + // don't know if we need to keep them until they are executed. Doing the check + // here (rather than in `update_effect`) allows us to skip the work for + // immediate effects. + if (effect.deps === null && effect.first === null && effect.nodes_start === null) { + if (effect.teardown === null) { + // remove this effect from the graph + unlink_effect(effect); + } else { + // keep the effect in the graph, but free up some memory + effect.fn = null; + } + } + + // if state is written in a user effect, abort and re-schedule, lest we run + // effects that should be removed as a result of the state change + if (write_version > wv && (effect.f & USER_EFFECT) !== 0) { + break; + } + } + } + } + + for (; i < length; i += 1) { + schedule_effect(effects[i]); + } +} + +/** + * @param {Effect} signal + * @returns {void} + */ +export function schedule_effect(signal) { + var effect = (last_scheduled_effect = signal); + + while (effect.parent !== null) { + effect = effect.parent; + var flags = effect.f; + + // if the effect is being scheduled because a parent (each/await/etc) block + // updated an internal source, bail out or we'll cause a second flush + if (is_flushing && effect === active_effect && (flags & BLOCK_EFFECT) !== 0) { + return; + } + + if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) { + if ((flags & CLEAN) === 0) return; + effect.f ^= CLEAN; + } + } + + queued_root_effects.push(effect); +} + +export function suspend() { + var boundary = get_pending_boundary(); + var batch = /** @type {Batch} */ (current_batch); + var pending = boundary.pending; + + boundary.update_pending_count(1); + if (!pending) batch.increment(); + + return function unsuspend() { + boundary.update_pending_count(-1); + if (!pending) batch.decrement(); + + unset_context(); + }; +} + +/** + * Forcibly remove all current batches, to prevent cross-talk between tests + */ +export function clear() { + batches.clear(); +} diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index e9cea0df3e..fa6a9e02a1 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -1,6 +1,17 @@ -/** @import { Derived, Effect } from '#client' */ +/** @import { Derived, Effect, Source } from '#client' */ +/** @import { Batch } from './batch.js'; */ import { DEV } from 'esm-env'; -import { CLEAN, DERIVED, DIRTY, EFFECT_HAS_DERIVED, MAYBE_DIRTY, UNOWNED } from '#client/constants'; +import { + ERROR_VALUE, + CLEAN, + DERIVED, + DIRTY, + EFFECT_PRESERVED, + MAYBE_DIRTY, + STALE_REACTION, + UNOWNED, + ASYNC +} from '#client/constants'; import { active_reaction, active_effect, @@ -14,11 +25,26 @@ import { } from '../runtime.js'; import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; -import { destroy_effect } from './effects.js'; -import { inspect_effects, set_inspect_effects } from './sources.js'; +import * as w from '../warnings.js'; +import { async_effect, destroy_effect } from './effects.js'; +import { inspect_effects, internal_set, set_inspect_effects, source } from './sources.js'; import { get_stack } from '../dev/tracing.js'; import { tracing_mode_flag } from '../../flags/index.js'; +import { Boundary } from '../dom/blocks/boundary.js'; import { component_context } from '../context.js'; +import { UNINITIALIZED } from '../../../constants.js'; +import { batch_deriveds, current_batch } from './batch.js'; +import { unset_context } from './async.js'; + +/** @type {Effect | null} */ +export let current_async_effect = null; + +/** @param {Effect | null} v */ +export function set_from_async_derived(v) { + current_async_effect = v; +} + +export const recent_async_deriveds = new Set(); /** * @template V @@ -38,7 +64,7 @@ export function derived(fn) { } else { // Since deriveds are evaluated lazily, any effects created inside them are // created too late to ensure that the parent effect is added to the tree - active_effect.f |= EFFECT_HAS_DERIVED; + active_effect.f |= EFFECT_PRESERVED; } /** @type {Derived} */ @@ -51,9 +77,10 @@ export function derived(fn) { fn, reactions: null, rv: 0, - v: /** @type {V} */ (null), + v: /** @type {V} */ (UNINITIALIZED), wv: 0, - parent: parent_derived ?? active_effect + parent: parent_derived ?? active_effect, + ac: null }; if (DEV && tracing_mode_flag) { @@ -63,6 +90,135 @@ export function derived(fn) { return signal; } +/** + * @template V + * @param {() => V | Promise} fn + * @param {string} [location] If provided, print a warning if the value is not read immediately after update + * @returns {Promise>} + */ +/*#__NO_SIDE_EFFECTS__*/ +export function async_derived(fn, location) { + let parent = /** @type {Effect | null} */ (active_effect); + + if (parent === null) { + e.async_derived_orphan(); + } + + var boundary = /** @type {Boundary} */ (parent.b); + + var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined)); + var signal = source(/** @type {V} */ (UNINITIALIZED)); + + /** @type {Promise | null} */ + var prev = null; + + // only suspend in async deriveds created on initialisation + var should_suspend = !active_reaction; + + async_effect(() => { + if (DEV) current_async_effect = active_effect; + + try { + var p = fn(); + } catch (error) { + p = Promise.reject(error); + } + + if (DEV) current_async_effect = null; + + var r = () => p; + promise = prev?.then(r, r) ?? Promise.resolve(p); + + prev = promise; + + var batch = /** @type {Batch} */ (current_batch); + var pending = boundary.pending; + + if (should_suspend) { + boundary.update_pending_count(1); + if (!pending) batch.increment(); + } + + /** + * @param {any} value + * @param {unknown} error + */ + const handler = (value, error = undefined) => { + prev = null; + + current_async_effect = null; + + if (!pending) batch.activate(); + + if (error) { + if (error !== STALE_REACTION) { + signal.f |= ERROR_VALUE; + + // @ts-expect-error the error is the wrong type, but we don't care + internal_set(signal, error); + } + } else { + if ((signal.f & ERROR_VALUE) !== 0) { + signal.f ^= ERROR_VALUE; + } + + internal_set(signal, value); + + if (DEV && location !== undefined) { + recent_async_deriveds.add(signal); + + setTimeout(() => { + if (recent_async_deriveds.has(signal)) { + w.await_waterfall(/** @type {string} */ (signal.label), location); + recent_async_deriveds.delete(signal); + } + }); + } + } + + if (should_suspend) { + boundary.update_pending_count(-1); + if (!pending) batch.decrement(); + } + + unset_context(); + }; + + promise.then(handler, (e) => handler(null, e || 'unknown')); + + if (batch) { + return () => { + queueMicrotask(() => batch.neuter()); + }; + } + }); + + if (DEV) { + // add a flag that lets this be printed as a derived + // when using `$inspect.trace()` + signal.f |= ASYNC; + } + + return new Promise((fulfil) => { + /** @param {Promise} p */ + function next(p) { + function go() { + if (p === promise) { + fulfil(signal); + } else { + // if the effect re-runs before the initial promise + // resolves, delay resolution until we have a value + next(promise); + } + } + + p.then(go, go); + } + + next(promise); + }); +} + /** * @template V * @param {() => V} fn @@ -183,8 +339,12 @@ export function update_derived(derived) { // cleanup function, or it will cache a stale value if (is_destroying_effect) return; - var status = - (skip_reaction || (derived.f & UNOWNED) !== 0) && derived.deps !== null ? MAYBE_DIRTY : CLEAN; + if (batch_deriveds !== null) { + batch_deriveds.set(derived, derived.v); + } else { + var status = + (skip_reaction || (derived.f & UNOWNED) !== 0) && derived.deps !== null ? MAYBE_DIRTY : CLEAN; - set_signal_status(derived, status); + set_signal_status(derived, status); + } } diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 625c0d1822..c4edd2bf8d 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -1,13 +1,12 @@ /** @import { ComponentContext, ComponentContextLegacy, Derived, Effect, TemplateNode, TransitionManager } from '#client' */ import { - check_dirtiness, + is_dirty, active_effect, active_reaction, update_effect, get, is_destroying_effect, remove_reactions, - schedule_effect, set_active_reaction, set_is_destroying_effect, set_signal_status, @@ -31,16 +30,18 @@ import { INSPECT_EFFECT, HEAD_EFFECT, MAYBE_DIRTY, - EFFECT_HAS_DERIVED, - BOUNDARY_EFFECT + EFFECT_PRESERVED, + STALE_REACTION, + USER_EFFECT, + ASYNC } from '#client/constants'; -import { set } from './sources.js'; import * as e from '../errors.js'; import { DEV } from 'esm-env'; import { define_property } from '../../shared/utils.js'; import { get_next_sibling } from '../dom/operations.js'; -import { derived } from './deriveds.js'; -import { component_context, dev_current_component_function } from '../context.js'; +import { component_context, dev_current_component_function, dev_stack } from '../context.js'; +import { Batch, schedule_effect } from './batch.js'; +import { flatten } from './async.js'; /** * @param {'$effect' | '$effect.pre' | '$inspect'} rune @@ -91,6 +92,10 @@ function create_effect(type, fn, sync, push = true) { } } + if (parent !== null && (parent.f & INERT) !== 0) { + type |= INERT; + } + /** @type {Effect} */ var effect = { ctx: component_context, @@ -103,10 +108,12 @@ function create_effect(type, fn, sync, push = true) { last: null, next: null, parent, + b: parent && parent.b, prev: null, teardown: null, transitions: null, - wv: 0 + wv: 0, + ac: null }; if (DEV) { @@ -133,7 +140,7 @@ function create_effect(type, fn, sync, push = true) { effect.first === null && effect.nodes_start === null && effect.teardown === null && - (effect.f & (EFFECT_HAS_DERIVED | BOUNDARY_EFFECT)) === 0; + (effect.f & EFFECT_PRESERVED) === 0; if (!inert && push) { if (parent !== null) { @@ -175,33 +182,34 @@ export function teardown(fn) { export function user_effect(fn) { validate_effect('$effect'); - // Non-nested `$effect(...)` in a component should be deferred - // until the component is mounted - var defer = - active_effect !== null && - (active_effect.f & BRANCH_EFFECT) !== 0 && - component_context !== null && - !component_context.m; - if (DEV) { define_property(fn, 'name', { value: '$effect' }); } + // Non-nested `$effect(...)` in a component should be deferred + // until the component is mounted + var flags = /** @type {Effect} */ (active_effect).f; + var defer = !active_reaction && (flags & BRANCH_EFFECT) !== 0 && (flags & EFFECT_RAN) === 0; + if (defer) { + // Top-level `$effect(...)` in an unmounted component — defer until mount var context = /** @type {ComponentContext} */ (component_context); - (context.e ??= []).push({ - fn, - effect: active_effect, - reaction: active_reaction - }); + (context.e ??= []).push(fn); } else { - var signal = effect(fn); - return signal; + // Everything else — create immediately + return create_user_effect(fn); } } +/** + * @param {() => void | (() => void)} fn + */ +export function create_user_effect(fn) { + return create_effect(EFFECT | USER_EFFECT, fn, false); +} + /** * Internal representation of `$effect.pre(...)` * @param {() => void | (() => void)} fn @@ -214,7 +222,7 @@ export function user_pre_effect(fn) { value: '$effect.pre' }); } - return render_effect(fn); + return create_effect(RENDER_EFFECT | USER_EFFECT, fn, true); } /** @param {() => void | (() => void)} fn */ @@ -228,6 +236,7 @@ export function inspect_effect(fn) { * @returns {() => void} */ export function effect_root(fn) { + Batch.ensure(); const effect = create_effect(ROOT_EFFECT, fn, true); return () => { @@ -241,6 +250,7 @@ export function effect_root(fn) { * @returns {(options?: { outro?: boolean }) => Promise} */ export function component_root(fn) { + Batch.ensure(); const effect = create_effect(ROOT_EFFECT, fn, true); return (options = {}) => { @@ -274,9 +284,10 @@ export function effect(fn) { export function legacy_pre_effect(deps, fn) { var context = /** @type {ComponentContextLegacy} */ (component_context); - /** @type {{ effect: null | Effect, ran: boolean }} */ - var token = { effect: null, ran: false }; - context.l.r1.push(token); + /** @type {{ effect: null | Effect, ran: boolean, deps: () => any }} */ + var token = { effect: null, ran: false, deps }; + + context.l.$.push(token); token.effect = render_effect(() => { deps(); @@ -286,7 +297,6 @@ export function legacy_pre_effect(deps, fn) { if (token.ran) return; token.ran = true; - set(context.l.r2, true); untrack(fn); }); } @@ -295,10 +305,10 @@ export function legacy_pre_effect_reset() { var context = /** @type {ComponentContextLegacy} */ (component_context); render_effect(() => { - if (!get(context.l.r2)) return; - // Run dirty `$:` statements - for (var token of context.l.r1) { + for (var token of context.l.$) { + token.deps(); + var effect = token.effect; // If the effect is CLEAN, then make it MAYBE_DIRTY. This ensures we traverse through @@ -307,14 +317,12 @@ export function legacy_pre_effect_reset() { set_signal_status(effect, MAYBE_DIRTY); } - if (check_dirtiness(effect)) { + if (is_dirty(effect)) { update_effect(effect); } token.ran = false; } - - context.l.r2.v = false; // set directly to avoid rerunning this effect }); } @@ -322,34 +330,27 @@ export function legacy_pre_effect_reset() { * @param {() => void | (() => void)} fn * @returns {Effect} */ -export function render_effect(fn) { - return create_effect(RENDER_EFFECT, fn, true); +export function async_effect(fn) { + return create_effect(ASYNC | EFFECT_PRESERVED, fn, true); } /** - * @param {(...expressions: any) => void | (() => void)} fn - * @param {Array<() => any>} thunks - * @param {(fn: () => T) => Derived} d + * @param {() => void | (() => void)} fn * @returns {Effect} */ -export function template_effect(fn, thunks = [], d = derived) { - if (DEV) { - // wrap the effect so that we can decorate stack trace with `in {expression}` - // (TODO maybe there's a better approach?) - return render_effect(() => { - var outer = /** @type {Effect} */ (active_effect); - var inner = () => fn(...deriveds.map(get)); - - define_property(outer.fn, 'name', { value: '{expression}' }); - define_property(inner, 'name', { value: '{expression}' }); - - const deriveds = thunks.map(d); - block(inner); - }); - } +export function render_effect(fn, flags = 0) { + return create_effect(RENDER_EFFECT | flags, fn, true); +} - const deriveds = thunks.map(d); - return block(() => fn(...deriveds.map(get))); +/** + * @param {(...expressions: any) => void | (() => void)} fn + * @param {Array<() => any>} sync + * @param {Array<() => Promise>} async + */ +export function template_effect(fn, sync = [], async = []) { + flatten(sync, async, (values) => { + create_effect(RENDER_EFFECT, () => fn(...values.map(get)), true); + }); } /** @@ -357,7 +358,11 @@ export function template_effect(fn, thunks = [], d = derived) { * @param {number} flags */ export function block(fn, flags = 0) { - return create_effect(RENDER_EFFECT | BLOCK_EFFECT | flags, fn, true); + var effect = create_effect(BLOCK_EFFECT | flags, fn, true); + if (DEV) { + effect.dev_stack = dev_stack; + } + return effect; } /** @@ -365,7 +370,7 @@ export function block(fn, flags = 0) { * @param {boolean} [push] */ export function branch(fn, push = true) { - return create_effect(RENDER_EFFECT | BRANCH_EFFECT, fn, true, push); + return create_effect(BRANCH_EFFECT, fn, true, push); } /** @@ -397,6 +402,8 @@ export function destroy_effect_children(signal, remove_dom = false) { signal.first = signal.last = null; while (effect !== null) { + effect.ac?.abort(STALE_REACTION); + var next = effect.next; if ((effect.f & ROOT_EFFECT) !== 0) { @@ -478,6 +485,7 @@ export function destroy_effect(effect, remove_dom = true) { effect.fn = effect.nodes_start = effect.nodes_end = + effect.ac = null; } @@ -600,10 +608,10 @@ function resume_children(effect, local) { effect.f ^= INERT; // If a dependency of this effect changed while it was paused, - // schedule the effect to update. we don't use `check_dirtiness` + // schedule the effect to update. we don't use `is_dirty` // here because we don't want to eagerly recompute a derived like // `{#if foo}{foo.bar()}{/if}` if `foo` is now `undefined - if ((effect.f & CLEAN) !== 0) { + if ((effect.f & CLEAN) === 0) { set_signal_status(effect, DIRTY); schedule_effect(effect); } @@ -628,3 +636,8 @@ function resume_children(effect, local) { } } } + +export function aborted() { + var effect = /** @type {Effect} */ (active_effect); + return (effect.f & DESTROYED) !== 0; +} diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index f3111361c0..f39d45bb04 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -1,19 +1,26 @@ -/** @import { Derived, Source } from './types.js' */ +/** @import { ComponentContext } from '#client' */ +/** @import { Derived, Effect, Source } from './types.js' */ import { DEV } from 'esm-env'; import { PROPS_IS_BINDABLE, PROPS_IS_IMMUTABLE, PROPS_IS_LAZY_INITIAL, PROPS_IS_RUNES, - PROPS_IS_UPDATED + PROPS_IS_UPDATED, + UNINITIALIZED } from '../../../constants.js'; import { get_descriptor, is_function } from '../../shared/utils.js'; -import { mutable_source, set, source, update } from './sources.js'; +import { set, source, update } from './sources.js'; import { derived, derived_safe_equal } from './deriveds.js'; -import { get, captured_signals, untrack } from '../runtime.js'; -import { safe_equals } from './equality.js'; +import { + active_effect, + get, + is_destroying_effect, + set_active_effect, + untrack +} from '../runtime.js'; import * as e from '../errors.js'; -import { LEGACY_DERIVED_PROP, LEGACY_PROPS, STATE_SYMBOL } from '#client/constants'; +import { DESTROYED, LEGACY_PROPS, STATE_SYMBOL } from '#client/constants'; import { proxy } from '../proxy.js'; import { capture_store_binding } from './store.js'; import { legacy_mode_flag } from '../../flags/index.js'; @@ -93,7 +100,7 @@ export function rest_props(props, exclude, name) { /** * The proxy handler for legacy $$restProps and $$props - * @type {ProxyHandler<{ props: Record, exclude: Array, special: Record unknown>, version: Source }>}} + * @type {ProxyHandler<{ props: Record, exclude: Array, special: Record unknown>, version: Source, parent_effect: Effect }>}} */ const legacy_rest_props_handler = { get(target, key) { @@ -103,17 +110,25 @@ const legacy_rest_props_handler = { }, set(target, key, value) { if (!(key in target.special)) { - // Handle props that can temporarily get out of sync with the parent - /** @type {Record unknown>} */ - target.special[key] = prop( - { - get [key]() { - return target.props[key]; - } - }, - /** @type {string} */ (key), - PROPS_IS_UPDATED - ); + var previous_effect = active_effect; + + try { + set_active_effect(target.parent_effect); + + // Handle props that can temporarily get out of sync with the parent + /** @type {Record unknown>} */ + target.special[key] = prop( + { + get [key]() { + return target.props[key]; + } + }, + /** @type {string} */ (key), + PROPS_IS_UPDATED + ); + } finally { + set_active_effect(previous_effect); + } } target.special[key](value); @@ -152,7 +167,19 @@ const legacy_rest_props_handler = { * @returns {Record} */ export function legacy_rest_props(props, exclude) { - return new Proxy({ props, exclude, special: {}, version: source(0) }, legacy_rest_props_handler); + return new Proxy( + { + props, + exclude, + special: {}, + version: source(0), + // TODO this is only necessary because we need to track component + // destruction inside `prop`, because of `bind:this`, but it + // seems likely that we can simplify `bind:this` instead + parent_effect: /** @type {Effect} */ (active_effect) + }, + legacy_rest_props_handler + ); } /** @@ -241,14 +268,6 @@ export function spread_props(...props) { return new Proxy({ props }, spread_props_handler); } -/** - * @param {Derived} current_value - * @returns {boolean} - */ -function has_destroyed_component_ctx(current_value) { - return current_value.ctx?.d ?? false; -} - /** * This function is responsible for synchronizing a possibly bound prop with the inner component state. * It is used whenever the compiler sees that the component writes to the prop, or when it has a default prop_value. @@ -260,89 +279,92 @@ function has_destroyed_component_ctx(current_value) { * @returns {(() => V | ((arg: V) => V) | ((arg: V, mutation: boolean) => V))} */ export function prop(props, key, flags, fallback) { - var immutable = (flags & PROPS_IS_IMMUTABLE) !== 0; var runes = !legacy_mode_flag || (flags & PROPS_IS_RUNES) !== 0; var bindable = (flags & PROPS_IS_BINDABLE) !== 0; var lazy = (flags & PROPS_IS_LAZY_INITIAL) !== 0; - var is_store_sub = false; - var prop_value; - - if (bindable) { - [prop_value, is_store_sub] = capture_store_binding(() => /** @type {V} */ (props[key])); - } else { - prop_value = /** @type {V} */ (props[key]); - } - - // Can be the case when someone does `mount(Component, props)` with `let props = $state({...})` - // or `createClassComponent(Component, props)` - var is_entry_props = STATE_SYMBOL in props || LEGACY_PROPS in props; - - var setter = - (bindable && - (get_descriptor(props, key)?.set ?? - (is_entry_props && key in props && ((v) => (props[key] = v))))) || - undefined; var fallback_value = /** @type {V} */ (fallback); var fallback_dirty = true; - var fallback_used = false; var get_fallback = () => { - fallback_used = true; if (fallback_dirty) { fallback_dirty = false; - if (lazy) { - fallback_value = untrack(/** @type {() => V} */ (fallback)); - } else { - fallback_value = /** @type {V} */ (fallback); - } + + fallback_value = lazy + ? untrack(/** @type {() => V} */ (fallback)) + : /** @type {V} */ (fallback); } return fallback_value; }; - if (prop_value === undefined && fallback !== undefined) { - if (setter && runes) { - e.props_invalid_value(key); - } + /** @type {((v: V) => void) | undefined} */ + var setter; - prop_value = get_fallback(); - if (setter) setter(prop_value); + if (bindable) { + // Can be the case when someone does `mount(Component, props)` with `let props = $state({...})` + // or `createClassComponent(Component, props)` + var is_entry_props = STATE_SYMBOL in props || LEGACY_PROPS in props; + + setter = + get_descriptor(props, key)?.set ?? + (is_entry_props && key in props ? (v) => (props[key] = v) : undefined); + } + + var initial_value; + var is_store_sub = false; + + if (bindable) { + [initial_value, is_store_sub] = capture_store_binding(() => /** @type {V} */ (props[key])); + } else { + initial_value = /** @type {V} */ (props[key]); + } + + if (initial_value === undefined && fallback !== undefined) { + initial_value = get_fallback(); + + if (setter) { + if (runes) e.props_invalid_value(key); + setter(initial_value); + } } /** @type {() => V} */ var getter; + if (runes) { getter = () => { var value = /** @type {V} */ (props[key]); if (value === undefined) return get_fallback(); fallback_dirty = true; - fallback_used = false; return value; }; } else { - // Svelte 4 did not trigger updates when a primitive value was updated to the same value. - // Replicate that behavior through using a derived - var derived_getter = (immutable ? derived : derived_safe_equal)( - () => /** @type {V} */ (props[key]) - ); - derived_getter.f |= LEGACY_DERIVED_PROP; getter = () => { - var value = get(derived_getter); - if (value !== undefined) fallback_value = /** @type {V} */ (undefined); + var value = /** @type {V} */ (props[key]); + + if (value !== undefined) { + // in legacy mode, we don't revert to the fallback value + // if the prop goes from defined to undefined. The easiest + // way to model this is to make the fallback undefined + // as soon as the prop has a value + fallback_value = /** @type {V} */ (undefined); + } + return value === undefined ? fallback_value : value; }; } - // easy mode — prop is never written to - if ((flags & PROPS_IS_UPDATED) === 0 && runes) { + // prop is never written to — we only need a getter + if (runes && (flags & PROPS_IS_UPDATED) === 0) { return getter; } - // intermediate mode — prop is written to, but the parent component had - // `bind:foo` which means we can just call `$$props.foo = value` directly + // prop is written to, but the parent component had `bind:foo` which + // means we can just call `$$props.foo = value` directly if (setter) { var legacy_parent = props.$$legacy; + return function (/** @type {any} */ value, /** @type {boolean} */ mutation) { if (arguments.length > 0) { // We don't want to notify if the value was mutated and the parent is in runes mode. @@ -352,82 +374,53 @@ export function prop(props, key, flags, fallback) { if (!runes || !mutation || legacy_parent || is_store_sub) { /** @type {Function} */ (setter)(mutation ? getter() : value); } + return value; - } else { - return getter(); } + + return getter(); }; } - // hard mode. this is where it gets ugly — the value in the child should - // synchronize with the parent, but it should also be possible to temporarily - // set the value to something else locally. - var from_child = false; - var was_from_child = false; - - // The derived returns the current value. The underlying mutable - // source is written to from various places to persist this value. - var inner_current_value = mutable_source(prop_value); - var current_value = derived(() => { - var parent_value = getter(); - var child_value = get(inner_current_value); - - if (from_child) { - from_child = false; - was_from_child = true; - return child_value; - } + // Either prop is written to, but there's no binding, which means we + // create a derived that we can write to locally. + // Or we are in legacy mode where we always create a derived to replicate that + // Svelte 4 did not trigger updates when a primitive value was updated to the same value. + var overridden = false; - was_from_child = false; - return (inner_current_value.v = parent_value); + var d = ((flags & PROPS_IS_IMMUTABLE) !== 0 ? derived : derived_safe_equal)(() => { + overridden = false; + return getter(); }); - // Ensure we eagerly capture the initial value if it's bindable - if (bindable) { - get(current_value); - } + // Capture the initial value if it's bindable + if (bindable) get(d); - if (!immutable) current_value.equals = safe_equals; + var parent_effect = /** @type {Effect} */ (active_effect); return function (/** @type {any} */ value, /** @type {boolean} */ mutation) { - // legacy nonsense — need to ensure the source is invalidated when necessary - // also needed for when handling inspect logic so we can inspect the correct source signal - if (captured_signals !== null) { - // set this so that we don't reset to the parent value if `d` - // is invalidated because of `invalidate_inner_signals` (rather - // than because the parent or child value changed) - from_child = was_from_child; - // invoke getters so that signals are picked up by `invalidate_inner_signals` - getter(); - get(inner_current_value); - } - if (arguments.length > 0) { - const new_value = mutation ? get(current_value) : runes && bindable ? proxy(value) : value; - - if (!current_value.equals(new_value)) { - from_child = true; - set(inner_current_value, new_value); - // To ensure the fallback value is consistent when used with proxies, we - // update the local fallback_value, but only if the fallback is actively used - if (fallback_used && fallback_value !== undefined) { - fallback_value = new_value; - } + const new_value = mutation ? get(d) : runes && bindable ? proxy(value) : value; - if (has_destroyed_component_ctx(current_value)) { - return value; - } + set(d, new_value); + overridden = true; - untrack(() => get(current_value)); // force a synchronisation immediately + if (fallback_value !== undefined) { + fallback_value = new_value; } return value; } - if (has_destroyed_component_ctx(current_value)) { - return current_value.v; + // special case — avoid recalculating the derived if we're in a + // teardown function and the prop was overridden locally, or the + // component was already destroyed (this latter part is necessary + // because `bind:this` can read props after the component has + // been destroyed. TODO simplify `bind:this` + if ((is_destroying_effect && overridden) || (parent_effect.f & DESTROYED) !== 0) { + return d.v; } - return get(current_value); + return get(d); }; } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 56f4138252..9b534d2d71 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -5,14 +5,13 @@ import { active_effect, untracked_writes, get, - schedule_effect, set_untracked_writes, set_signal_status, untrack, increment_write_version, update_effect, - reaction_sources, - check_dirtiness, + current_sources, + is_dirty, untracking, is_destroying_effect, push_reaction_value @@ -27,12 +26,14 @@ import { UNOWNED, MAYBE_DIRTY, BLOCK_EFFECT, - ROOT_EFFECT + ROOT_EFFECT, + ASYNC } from '#client/constants'; import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { get_stack, tag_proxy } from '../dev/tracing.js'; import { component_context, is_runes } from '../context.js'; +import { Batch, schedule_effect } from './batch.js'; import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; @@ -48,6 +49,12 @@ export function set_inspect_effects(v) { inspect_effects = v; } +let inspect_effects_deferred = false; + +export function set_inspect_effects_deferred() { + inspect_effects_deferred = true; +} + /** * @template V * @param {V} v @@ -135,10 +142,12 @@ export function mutate(source, value) { export function set(source, value, should_proxy = false) { if ( active_reaction !== null && - !untracking && + // since we are untracking the function inside `$inspect.with` we need to add this check + // to ensure we error if state is set inside an inspect effect + (!untracking || (active_reaction.f & INSPECT_EFFECT) !== 0) && is_runes() && - (active_reaction.f & (DERIVED | BLOCK_EFFECT)) !== 0 && - !reaction_sources?.includes(source) + (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | INSPECT_EFFECT)) !== 0 && + !current_sources?.includes(source) ) { e.state_unsafe_mutation(); } @@ -170,8 +179,25 @@ export function internal_set(source, value) { source.v = value; - if (DEV && tracing_mode_flag) { - source.updated = get_stack('UpdatedAt'); + const batch = Batch.ensure(); + batch.capture(source, old_value); + + if (DEV) { + if (tracing_mode_flag || active_effect !== null) { + const error = get_stack('UpdatedAt'); + + if (error !== null) { + source.updated ??= new Map(); + let entry = source.updated.get(error.stack); + + if (!entry) { + entry = { error, count: 0 }; + source.updated.set(error.stack, entry); + } + + entry.count++; + } + } if (active_effect !== null) { source.set_during_effect = true; @@ -207,25 +233,32 @@ export function internal_set(source, value) { } } - if (DEV && inspect_effects.size > 0) { - const inspects = Array.from(inspect_effects); + if (DEV && inspect_effects.size > 0 && !inspect_effects_deferred) { + flush_inspect_effects(); + } + } - for (const effect of inspects) { - // Mark clean inspect-effects as maybe dirty and then check their dirtiness - // instead of just updating the effects - this way we avoid overfiring. - if ((effect.f & CLEAN) !== 0) { - set_signal_status(effect, MAYBE_DIRTY); - } - if (check_dirtiness(effect)) { - update_effect(effect); - } - } + return value; +} + +export function flush_inspect_effects() { + inspect_effects_deferred = false; + + const inspects = Array.from(inspect_effects); + + for (const effect of inspects) { + // Mark clean inspect-effects as maybe dirty and then check their dirtiness + // instead of just updating the effects - this way we avoid overfiring. + if ((effect.f & CLEAN) !== 0) { + set_signal_status(effect, MAYBE_DIRTY); + } - inspect_effects.clear(); + if (is_dirty(effect)) { + update_effect(effect); } } - return value; + inspect_effects.clear(); } /** @@ -257,6 +290,14 @@ export function update_pre(source, d = 1) { return set(source, d === 1 ? ++value : --value); } +/** + * Silently (without using `get`) increment a source + * @param {Source} source + */ +export function increment(source) { + set(source, source.v + 1); +} + /** * @param {Value} signal * @param {number} status should be DIRTY or MAYBE_DIRTY diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 756bb98f09..72187e84a7 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -1,4 +1,11 @@ -import type { ComponentContext, Dom, Equals, TemplateNode, TransitionManager } from '#client'; +import type { + ComponentContext, + DevStackEntry, + Equals, + TemplateNode, + TransitionManager +} from '#client'; +import type { Boundary } from '../dom/blocks/boundary'; export interface Signal { /** Flags bitmask */ @@ -22,8 +29,8 @@ export interface Value extends Signal { label?: string; /** An error with a stack trace showing when the source was created */ created?: Error | null; - /** An error with a stack trace showing when the source was last updated */ - updated?: Error | null; + /** An map of errors with stack traces showing when the source was updated, keyed by the stack trace */ + updated?: Map | null; /** * Whether or not the source was set while running an effect — if so, we need to * increment the write version so that it shows up as dirty when the effect re-runs @@ -40,6 +47,8 @@ export interface Reaction extends Signal { fn: null | Function; /** Signals that this signal reads from */ deps: null | Value[]; + /** An AbortController that aborts when the signal is destroyed */ + ac: null | AbortController; } export interface Derived extends Value, Reaction { @@ -76,8 +85,12 @@ export interface Effect extends Reaction { last: null | Effect; /** Parent effect */ parent: Effect | null; + /** The boundary this effect belongs to */ + b: Boundary | null; /** Dev only */ component_function?: any; + /** Dev only. Only set for certain block effects. Contains a reference to the stack that represents the render tree */ + dev_stack?: DevStackEntry | null; } export type Source = Value; diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 3256fe8274..ff6844453d 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -30,6 +30,7 @@ import * as w from './warnings.js'; import * as e from './errors.js'; import { assign_nodes } from './dom/template.js'; import { is_passive_event } from '../../utils.js'; +import { COMMENT_NODE } from './constants.js'; /** * This is normally true — block effects should run their intro transitions — @@ -107,7 +108,7 @@ export function hydrate(component, options) { var anchor = /** @type {TemplateNode} */ (get_first_child(target)); while ( anchor && - (anchor.nodeType !== 8 || /** @type {Comment} */ (anchor).data !== HYDRATION_START) + (anchor.nodeType !== COMMENT_NODE || /** @type {Comment} */ (anchor).data !== HYDRATION_START) ) { anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor)); } @@ -124,7 +125,7 @@ export function hydrate(component, options) { if ( hydrate_node === null || - hydrate_node.nodeType !== 8 || + hydrate_node.nodeType !== COMMENT_NODE || /** @type {Comment} */ (hydrate_node).data !== HYDRATION_END ) { w.hydration_mismatch(); diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 9544060959..306b9b9dd9 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -1,52 +1,57 @@ /** @import { Derived, Effect, Reaction, Signal, Source, Value } from '#client' */ import { DEV } from 'esm-env'; -import { define_property, get_descriptors, get_prototype_of, index_of } from '../shared/utils.js'; +import { get_descriptors, get_prototype_of, index_of } from '../shared/utils.js'; import { destroy_block_effect_children, destroy_effect_children, - execute_effect_teardown, - unlink_effect + execute_effect_teardown } from './reactivity/effects.js'; import { - EFFECT, DIRTY, MAYBE_DIRTY, CLEAN, DERIVED, UNOWNED, DESTROYED, - INERT, BRANCH_EFFECT, STATE_SYMBOL, BLOCK_EFFECT, ROOT_EFFECT, - LEGACY_DERIVED_PROP, DISCONNECTED, - EFFECT_IS_UPDATING + REACTION_IS_UPDATING, + STALE_REACTION, + ERROR_VALUE } from './constants.js'; -import { flush_tasks } from './dom/task.js'; import { internal_set, old_values } from './reactivity/sources.js'; -import { destroy_derived_effects, update_derived } from './reactivity/deriveds.js'; -import * as e from './errors.js'; - -import { tracing_mode_flag } from '../flags/index.js'; +import { + destroy_derived_effects, + execute_derived, + current_async_effect, + recent_async_deriveds, + update_derived +} from './reactivity/deriveds.js'; +import { async_mode_flag, tracing_mode_flag } from '../flags/index.js'; import { tracing_expressions, get_stack } from './dev/tracing.js'; import { component_context, dev_current_component_function, + dev_stack, is_runes, set_component_context, - set_dev_current_component_function + set_dev_current_component_function, + set_dev_stack } from './context.js'; -import { handle_error, invoke_error_boundary } from './error-handling.js'; -import { snapshot } from '../shared/clone.js'; - -let is_flushing = false; +import * as w from './warnings.js'; +import { Batch, batch_deriveds, flushSync, schedule_effect } from './reactivity/batch.js'; +import { handle_error } from './error-handling.js'; +import { UNINITIALIZED } from '../../constants.js'; -/** @type {Effect | null} */ -let last_scheduled_effect = null; +export let is_updating_effect = false; -let is_updating_effect = false; +/** @param {boolean} value */ +export function set_is_updating_effect(value) { + is_updating_effect = value; +} export let is_destroying_effect = false; @@ -55,15 +60,6 @@ export function set_is_destroying_effect(value) { is_destroying_effect = value; } -// Handle effect queues - -/** @type {Effect[]} */ -let queued_root_effects = []; - -/** @type {Effect[]} Stack of effects, dev only */ -let dev_effect_stack = []; -// Handle signal reactivity tree dependencies and reactions - /** @type {null | Reaction} */ export let active_reaction = null; @@ -84,18 +80,18 @@ export function set_active_effect(effect) { /** * When sources are created within a reaction, reading and writing - * them should not cause a re-run + * them within that reaction should not cause a re-run * @type {null | Source[]} */ -export let reaction_sources = null; +export let current_sources = null; /** @param {Value} value */ export function push_reaction_value(value) { - if (active_reaction !== null && active_reaction.f & EFFECT_IS_UPDATING) { - if (reaction_sources === null) { - reaction_sources = [value]; + if (active_reaction !== null && (!async_mode_flag || (active_reaction.f & DERIVED) !== 0)) { + if (current_sources === null) { + current_sources = [value]; } else { - reaction_sources.push(value); + current_sources.push(value); } } } @@ -126,11 +122,18 @@ export function set_untracked_writes(value) { * @type {number} Used by sources and deriveds for handling updates. * Version starts from 1 so that unowned deriveds differentiate between a created effect and a run one for tracing **/ -let write_version = 1; +export let write_version = 1; /** @type {number} Used to version each read of a source of derived to avoid duplicating depedencies inside a reaction */ let read_version = 0; +export let update_version = read_version; + +/** @param {number} value */ +export function set_update_version(value) { + update_version = value; +} + // If we are working with a get() chain that has no active container, // to prevent memory leaks, we skip adding the reaction. export let skip_reaction = false; @@ -153,7 +156,7 @@ export function increment_write_version() { * @param {Reaction} reaction * @returns {boolean} */ -export function check_dirtiness(reaction) { +export function is_dirty(reaction) { var flags = reaction.f; if ((flags & DIRTY) !== 0) { @@ -172,8 +175,12 @@ export function check_dirtiness(reaction) { var length = dependencies.length; // If we are working with a disconnected or an unowned signal that is now connected (due to an active effect) - // then we need to re-connect the reaction to the dependency - if (is_disconnected || is_unowned_connected) { + // then we need to re-connect the reaction to the dependency, unless the effect has already been destroyed + // (which can happen if the derived is read by an async derived) + if ( + (is_disconnected || is_unowned_connected) && + (active_effect === null || (active_effect.f & DESTROYED) === 0) + ) { var derived = /** @type {Derived} */ (reaction); var parent = derived.parent; @@ -202,7 +209,7 @@ export function check_dirtiness(reaction) { for (i = 0; i < length; i++) { dependency = dependencies[i]; - if (check_dirtiness(/** @type {Derived} */ (dependency))) { + if (is_dirty(/** @type {Derived} */ (dependency))) { update_derived(/** @type {Derived} */ (dependency)); } @@ -231,11 +238,13 @@ function schedule_possible_effect_self_invalidation(signal, effect, root = true) var reactions = signal.reactions; if (reactions === null) return; + if (!async_mode_flag && current_sources?.includes(signal)) { + return; + } + for (var i = 0; i < reactions.length; i++) { var reaction = reactions[i]; - if (reaction_sources?.includes(signal)) continue; - if ((reaction.f & DERIVED) !== 0) { schedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false); } else if (effect === reaction) { @@ -256,9 +265,10 @@ export function update_reaction(reaction) { var previous_untracked_writes = untracked_writes; var previous_reaction = active_reaction; var previous_skip_reaction = skip_reaction; - var previous_reaction_sources = reaction_sources; + var previous_sources = current_sources; var previous_component_context = component_context; var previous_untracking = untracking; + var previous_update_version = update_version; var flags = reaction.f; @@ -269,14 +279,18 @@ export function update_reaction(reaction) { (flags & UNOWNED) !== 0 && (untracking || !is_updating_effect || active_reaction === null); active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null; - reaction_sources = null; + current_sources = null; set_component_context(reaction.ctx); untracking = false; - read_version++; + update_version = ++read_version; - reaction.f |= EFFECT_IS_UPDATING; + if (reaction.ac !== null) { + reaction.ac.abort(STALE_REACTION); + reaction.ac = null; + } try { + reaction.f |= REACTION_IS_UPDATING; var result = /** @type {Function} */ (0, reaction.fn)(); var deps = reaction.deps; @@ -294,7 +308,12 @@ export function update_reaction(reaction) { reaction.deps = deps = new_deps; } - if (!skip_reaction) { + if ( + !skip_reaction || + // Deriveds that already have reactions can cleanup, so we still add them as reactions + ((flags & DERIVED) !== 0 && + /** @type {import('#client').Derived} */ (reaction).reactions !== null) + ) { for (i = skipped_deps; i < deps.length; i++) { (deps[i].reactions ??= []).push(reaction); } @@ -338,20 +357,24 @@ export function update_reaction(reaction) { } } + if ((reaction.f & ERROR_VALUE) !== 0) { + reaction.f ^= ERROR_VALUE; + } + return result; } catch (error) { - handle_error(error); + return handle_error(error); } finally { + reaction.f ^= REACTION_IS_UPDATING; new_deps = previous_deps; skipped_deps = previous_skipped_deps; untracked_writes = previous_untracked_writes; active_reaction = previous_reaction; skip_reaction = previous_skip_reaction; - reaction_sources = previous_reaction_sources; + current_sources = previous_sources; set_component_context(previous_component_context); untracking = previous_untracking; - - reaction.f ^= EFFECT_IS_UPDATING; + update_version = previous_update_version; } } @@ -376,6 +399,7 @@ function remove_reaction(signal, dependency) { } } } + // If the derived has no reactions, then we can disconnect it from the graph, // allowing it to either reconnect in the future, or be GC'd by the VM. if ( @@ -434,6 +458,9 @@ export function update_effect(effect) { if (DEV) { var previous_component_fn = dev_current_component_function; set_dev_current_component_function(effect.component_function); + var previous_stack = /** @type {any} */ (dev_stack); + // only block effects have a dev stack, keep the current one otherwise + set_dev_stack(effect.dev_stack ?? dev_stack); } try { @@ -458,255 +485,41 @@ export function update_effect(effect) { } } } - - if (DEV) { - dev_effect_stack.push(effect); - } } finally { is_updating_effect = was_updating_effect; active_effect = previous_effect; if (DEV) { set_dev_current_component_function(previous_component_fn); - } - } -} - -function log_effect_stack() { - // eslint-disable-next-line no-console - console.error( - 'Last ten effects were: ', - dev_effect_stack.slice(-10).map((d) => d.fn) - ); - dev_effect_stack = []; -} - -function infinite_loop_guard() { - try { - e.effect_update_depth_exceeded(); - } catch (error) { - if (DEV) { - // stack is garbage, ignore. Instead add a console.error message. - define_property(error, 'stack', { - value: '' - }); - } - // Try and handle the error so it can be caught at a boundary, that's - // if there's an effect available from when it was last scheduled - if (last_scheduled_effect !== null) { - if (DEV) { - try { - invoke_error_boundary(error, last_scheduled_effect); - } catch (e) { - // Only log the effect stack if the error is re-thrown - log_effect_stack(); - throw e; - } - } else { - invoke_error_boundary(error, last_scheduled_effect); - } - } else { - if (DEV) { - log_effect_stack(); - } - throw error; - } - } -} - -function flush_queued_root_effects() { - var was_updating_effect = is_updating_effect; - - try { - var flush_count = 0; - is_updating_effect = true; - - while (queued_root_effects.length > 0) { - if (flush_count++ > 1000) { - infinite_loop_guard(); - } - - var root_effects = queued_root_effects; - var length = root_effects.length; - - queued_root_effects = []; - - for (var i = 0; i < length; i++) { - var collected_effects = process_effects(root_effects[i]); - flush_queued_effects(collected_effects); - } - old_values.clear(); - } - } finally { - is_flushing = false; - is_updating_effect = was_updating_effect; - - last_scheduled_effect = null; - if (DEV) { - dev_effect_stack = []; - } - } -} - -/** - * @param {Array} effects - * @returns {void} - */ -function flush_queued_effects(effects) { - var length = effects.length; - if (length === 0) return; - - for (var i = 0; i < length; i++) { - var effect = effects[i]; - - if ((effect.f & (DESTROYED | INERT)) === 0) { - if (check_dirtiness(effect)) { - update_effect(effect); - - // Effects with no dependencies or teardown do not get added to the effect tree. - // Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we - // don't know if we need to keep them until they are executed. Doing the check - // here (rather than in `update_effect`) allows us to skip the work for - // immediate effects. - if (effect.deps === null && effect.first === null && effect.nodes_start === null) { - if (effect.teardown === null) { - // remove this effect from the graph - unlink_effect(effect); - } else { - // keep the effect in the graph, but free up some memory - effect.fn = null; - } - } - } + set_dev_stack(previous_stack); } } } /** - * @param {Effect} signal - * @returns {void} - */ -export function schedule_effect(signal) { - if (!is_flushing) { - is_flushing = true; - queueMicrotask(flush_queued_root_effects); - } - - var effect = (last_scheduled_effect = signal); - - while (effect.parent !== null) { - effect = effect.parent; - var flags = effect.f; - - if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) { - if ((flags & CLEAN) === 0) return; - effect.f ^= CLEAN; - } - } - - queued_root_effects.push(effect); -} - -/** - * - * This function both runs render effects and collects user effects in topological order - * from the starting effect passed in. Effects will be collected when they match the filtered - * bitwise flag passed in only. The collected effects array will be populated with all the user - * effects to be flushed. - * - * @param {Effect} root - * @returns {Effect[]} - */ -function process_effects(root) { - /** @type {Effect[]} */ - var effects = []; - - /** @type {Effect | null} */ - var effect = root; - - while (effect !== null) { - var flags = effect.f; - var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; - var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; - - if (!is_skippable_branch && (flags & INERT) === 0) { - if ((flags & EFFECT) !== 0) { - effects.push(effect); - } else if (is_branch) { - effect.f ^= CLEAN; - } else { - if (check_dirtiness(effect)) { - update_effect(effect); - } - } - - /** @type {Effect | null} */ - var child = effect.first; - - if (child !== null) { - effect = child; - continue; - } - } - - var parent = effect.parent; - effect = effect.next; - - while (effect === null && parent !== null) { - effect = parent.next; - parent = parent.parent; - } - } - - return effects; -} - -/** - * Synchronously flush any pending updates. - * Returns void if no callback is provided, otherwise returns the result of calling the callback. - * @template [T=void] - * @param {(() => T) | undefined} [fn] - * @returns {T} + * Returns a promise that resolves once any pending state changes have been applied. + * @returns {Promise} */ -export function flushSync(fn) { - var result; - - if (fn) { - is_flushing = true; - flush_queued_root_effects(); - - is_flushing = true; - result = fn(); +export async function tick() { + if (async_mode_flag) { + return new Promise((f) => requestAnimationFrame(() => f())); } - while (true) { - flush_tasks(); - - if (queued_root_effects.length === 0) { - // this would be reset in `flush_queued_root_effects` but since we are early returning here, - // we need to reset it here as well in case the first time there's 0 queued root effects - is_flushing = false; - last_scheduled_effect = null; - if (DEV) { - dev_effect_stack = []; - } - return /** @type {T} */ (result); - } + await Promise.resolve(); - is_flushing = true; - flush_queued_root_effects(); - } + // By calling flushSync we guarantee that any pending state changes are applied after one tick. + // TODO look into whether we can make flushing subsequent updates synchronously in the future. + flushSync(); } /** - * Returns a promise that resolves once any pending state changes have been applied. + * Returns a promise that resolves once any state changes, and asynchronous work resulting from them, + * have resolved and the DOM has been updated * @returns {Promise} + * @since 5.36 */ -export async function tick() { - await Promise.resolve(); - // By calling flushSync we guarantee that any pending state changes are applied after one tick. - // TODO look into whether we can make flushing subsequent updates synchronously in the future. - flushSync(); +export function settled() { + return Batch.ensure().settled(); } /** @@ -724,22 +537,44 @@ export function get(signal) { // Register the dependency on the current reaction signal. if (active_reaction !== null && !untracking) { - if (!reaction_sources?.includes(signal)) { + // if we're in a derived that is being read inside an _async_ derived, + // it's possible that the effect was already destroyed. In this case, + // we don't add the dependency, because that would create a memory leak + var destroyed = active_effect !== null && (active_effect.f & DESTROYED) !== 0; + + if (!destroyed && !current_sources?.includes(signal)) { var deps = active_reaction.deps; - if (signal.rv < read_version) { - signal.rv = read_version; - // If the signal is accessing the same dependencies in the same - // order as it did last time, increment `skipped_deps` - // rather than updating `new_deps`, which creates GC cost - if (new_deps === null && deps !== null && deps[skipped_deps] === signal) { - skipped_deps++; - } else if (new_deps === null) { - new_deps = [signal]; - } else if (!skip_reaction || !new_deps.includes(signal)) { - // Normally we can push duplicated dependencies to `new_deps`, but if we're inside - // an unowned derived because skip_reaction is true, then we need to ensure that - // we don't have duplicates - new_deps.push(signal); + + if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) { + // we're in the effect init/update cycle + if (signal.rv < read_version) { + signal.rv = read_version; + + // If the signal is accessing the same dependencies in the same + // order as it did last time, increment `skipped_deps` + // rather than updating `new_deps`, which creates GC cost + if (new_deps === null && deps !== null && deps[skipped_deps] === signal) { + skipped_deps++; + } else if (new_deps === null) { + new_deps = [signal]; + } else if (!skip_reaction || !new_deps.includes(signal)) { + // Normally we can push duplicated dependencies to `new_deps`, but if we're inside + // an unowned derived because skip_reaction is true, then we need to ensure that + // we don't have duplicates + new_deps.push(signal); + } + } + } else { + // we're adding a dependency outside the init/update cycle + // (i.e. after an `await`) + (active_reaction.deps ??= []).push(signal); + + var reactions = signal.reactions; + + if (reactions === null) { + signal.reactions = [active_reaction]; + } else if (!reactions.includes(active_reaction)) { + reactions.push(active_reaction); } } } @@ -759,54 +594,111 @@ export function get(signal) { } } - if (is_derived) { - derived = /** @type {Derived} */ (signal); + if (DEV) { + if (current_async_effect) { + var tracking = (current_async_effect.f & REACTION_IS_UPDATING) !== 0; + var was_read = current_async_effect.deps?.includes(signal); - if (check_dirtiness(derived)) { - update_derived(derived); + if (!tracking && !untracking && !was_read) { + w.await_reactivity_loss(/** @type {string} */ (signal.label)); + + var trace = get_stack('TracedAt'); + // eslint-disable-next-line no-console + if (trace) console.warn(trace); + } } - } - if ( - DEV && - tracing_mode_flag && - !untracking && - tracing_expressions !== null && - active_reaction !== null && - tracing_expressions.reaction === active_reaction - ) { - // Used when mapping state between special blocks like `each` - if (signal.trace) { - signal.trace(); - } else { - var trace = get_stack('TracedAt'); + recent_async_deriveds.delete(signal); - if (trace) { - var entry = tracing_expressions.entries.get(signal); + if ( + tracing_mode_flag && + !untracking && + tracing_expressions !== null && + active_reaction !== null && + tracing_expressions.reaction === active_reaction + ) { + // Used when mapping state between special blocks like `each` + if (signal.trace) { + signal.trace(); + } else { + trace = get_stack('TracedAt'); - if (entry === undefined) { - entry = { traces: [] }; - tracing_expressions.entries.set(signal, entry); - } + if (trace) { + var entry = tracing_expressions.entries.get(signal); - var last = entry.traces[entry.traces.length - 1]; + if (entry === undefined) { + entry = { traces: [] }; + tracing_expressions.entries.set(signal, entry); + } - // traces can be duplicated, e.g. by `snapshot` invoking both - // both `getOwnPropertyDescriptor` and `get` traps at once - if (trace.stack !== last?.stack) { - entry.traces.push(trace); + var last = entry.traces[entry.traces.length - 1]; + + // traces can be duplicated, e.g. by `snapshot` invoking both + // both `getOwnPropertyDescriptor` and `get` traps at once + if (trace.stack !== last?.stack) { + entry.traces.push(trace); + } } } } } - if (is_destroying_effect && old_values.has(signal)) { - return old_values.get(signal); + if (is_destroying_effect) { + if (old_values.has(signal)) { + return old_values.get(signal); + } + + if (is_derived) { + derived = /** @type {Derived} */ (signal); + + var value = derived.v; + + // if the derived is dirty, or depends on the values that just changed, re-execute + if ((derived.f & CLEAN) !== 0 || depends_on_old_values(derived)) { + value = execute_derived(derived); + } + + old_values.set(derived, value); + + return value; + } + } else if (is_derived) { + derived = /** @type {Derived} */ (signal); + + if (batch_deriveds?.has(derived)) { + return batch_deriveds.get(derived); + } + + if (is_dirty(derived)) { + update_derived(derived); + } + } + + if ((signal.f & ERROR_VALUE) !== 0) { + throw signal.v; } return signal.v; } +/** @param {Derived} derived */ +function depends_on_old_values(derived) { + if (derived.v === UNINITIALIZED) return true; // we don't know, so assume the worst + if (derived.deps === null) return false; + + for (const dep of derived.deps) { + if (old_values.has(dep)) { + return true; + } + + if ((dep.f & DERIVED) !== 0 && depends_on_old_values(/** @type {Derived} */ (dep))) { + return true; + } + } + + return false; +} + /** * Like `get`, but checks for `undefined`. Used for `var` declarations because they can be accessed before being declared * @template V @@ -852,17 +744,7 @@ export function invalidate_inner_signals(fn) { var captured = capture_signals(() => untrack(fn)); for (var signal of captured) { - // Go one level up because derived signals created as part of props in legacy mode - if ((signal.f & LEGACY_DERIVED_PROP) !== 0) { - for (const dep of /** @type {Derived} */ (signal).deps || []) { - if ((dep.f & DERIVED) === 0) { - // Use internal_set instead of set here and below to avoid mutation validation - internal_set(dep, dep.v); - } - } - } else { - internal_set(signal, signal.v); - } + internal_set(signal, signal.v); } } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 9703c2aac1..d24218c4d3 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -14,16 +14,8 @@ export type ComponentContext = { p: null | ComponentContext; /** context */ c: null | Map; - /** destroyed */ - d: boolean; /** deferred effects */ - e: null | Array<{ - fn: () => void | (() => void); - effect: null | Effect; - reaction: null | Reaction; - }>; - /** mounted */ - m: boolean; + e: null | Array<() => void | (() => void)>; /** * props — needed for legacy mode lifecycle functions, and for `createEventDispatcher` * @deprecated remove in 6.0 @@ -51,9 +43,7 @@ export type ComponentContext = { m: Array<() => any>; }; /** `$:` statements */ - r1: any[]; - /** This tracks whether `$:` statements have run in the current cycle, to ensure they only run once */ - r2: Source; + $: any[]; }; /** * dev mode only: the component function @@ -187,4 +177,13 @@ export type SourceLocation = | [line: number, column: number] | [line: number, column: number, SourceLocation[]]; +export interface DevStackEntry { + file: string; + type: 'component' | 'if' | 'each' | 'await' | 'key' | 'render'; + line: number; + column: number; + parent: DevStackEntry | null; + componentTag?: string; +} + export * from './reactivity/types'; diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index e6325417a7..dfa2a3752e 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -18,6 +18,31 @@ export function assignment_value_stale(property, location) { } } +/** + * Detected reactivity loss when reading `%name%`. This happens when state is read in an async function after an earlier `await` + * @param {string} name + */ +export function await_reactivity_loss(name) { + if (DEV) { + console.warn(`%c[svelte] await_reactivity_loss\n%cDetected reactivity loss when reading \`${name}\`. This happens when state is read in an async function after an earlier \`await\`\nhttps://svelte.dev/e/await_reactivity_loss`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/await_reactivity_loss`); + } +} + +/** + * An async derived, `%name%` (%location%) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app + * @param {string} name + * @param {string} location + */ +export function await_waterfall(name, location) { + if (DEV) { + console.warn(`%c[svelte] await_waterfall\n%cAn async derived, \`${name}\` (${location}) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app\nhttps://svelte.dev/e/await_waterfall`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/await_waterfall`); + } +} + /** * `%binding%` (%location%) is binding to a non-reactive property * @param {string} binding @@ -25,7 +50,13 @@ export function assignment_value_stale(property, location) { */ export function binding_property_non_reactive(binding, location) { if (DEV) { - console.warn(`%c[svelte] binding_property_non_reactive\n%c${location ? `\`${binding}\` (${location}) is binding to a non-reactive property` : `\`${binding}\` is binding to a non-reactive property`}\nhttps://svelte.dev/e/binding_property_non_reactive`, bold, normal); + console.warn( + `%c[svelte] binding_property_non_reactive\n%c${location + ? `\`${binding}\` (${location}) is binding to a non-reactive property` + : `\`${binding}\` is binding to a non-reactive property`}\nhttps://svelte.dev/e/binding_property_non_reactive`, + bold, + normal + ); } else { console.warn(`https://svelte.dev/e/binding_property_non_reactive`); } @@ -76,7 +107,13 @@ export function hydration_attribute_changed(attribute, html, value) { */ export function hydration_html_changed(location) { if (DEV) { - console.warn(`%c[svelte] hydration_html_changed\n%c${location ? `The value of an \`{@html ...}\` block ${location} changed between server and client renders. The client value will be ignored in favour of the server value` : 'The value of an `{@html ...}` block changed between server and client renders. The client value will be ignored in favour of the server value'}\nhttps://svelte.dev/e/hydration_html_changed`, bold, normal); + console.warn( + `%c[svelte] hydration_html_changed\n%c${location + ? `The value of an \`{@html ...}\` block ${location} changed between server and client renders. The client value will be ignored in favour of the server value` + : 'The value of an `{@html ...}` block changed between server and client renders. The client value will be ignored in favour of the server value'}\nhttps://svelte.dev/e/hydration_html_changed`, + bold, + normal + ); } else { console.warn(`https://svelte.dev/e/hydration_html_changed`); } @@ -88,7 +125,13 @@ export function hydration_html_changed(location) { */ export function hydration_mismatch(location) { if (DEV) { - console.warn(`%c[svelte] hydration_mismatch\n%c${location ? `Hydration failed because the initial UI does not match what was rendered on the server. The error occurred near ${location}` : 'Hydration failed because the initial UI does not match what was rendered on the server'}\nhttps://svelte.dev/e/hydration_mismatch`, bold, normal); + console.warn( + `%c[svelte] hydration_mismatch\n%c${location + ? `Hydration failed because the initial UI does not match what was rendered on the server. The error occurred near ${location}` + : 'Hydration failed because the initial UI does not match what was rendered on the server'}\nhttps://svelte.dev/e/hydration_mismatch`, + bold, + normal + ); } else { console.warn(`https://svelte.dev/e/hydration_mismatch`); } diff --git a/packages/svelte/src/internal/flags/async.js b/packages/svelte/src/internal/flags/async.js new file mode 100644 index 0000000000..ca4ff9286a --- /dev/null +++ b/packages/svelte/src/internal/flags/async.js @@ -0,0 +1,3 @@ +import { enable_async_mode_flag } from './index.js'; + +enable_async_mode_flag(); diff --git a/packages/svelte/src/internal/flags/index.js b/packages/svelte/src/internal/flags/index.js index 017840f2d9..ce7bba604b 100644 --- a/packages/svelte/src/internal/flags/index.js +++ b/packages/svelte/src/internal/flags/index.js @@ -1,6 +1,16 @@ +export let async_mode_flag = false; export let legacy_mode_flag = false; export let tracing_mode_flag = false; +export function enable_async_mode_flag() { + async_mode_flag = true; +} + +/** ONLY USE THIS DURING TESTING */ +export function disable_async_mode_flag() { + async_mode_flag = false; +} + export function enable_legacy_mode_flag() { legacy_mode_flag = true; } diff --git a/packages/svelte/src/internal/server/abort-signal.js b/packages/svelte/src/internal/server/abort-signal.js new file mode 100644 index 0000000000..a769a46e3d --- /dev/null +++ b/packages/svelte/src/internal/server/abort-signal.js @@ -0,0 +1,13 @@ +import { STALE_REACTION } from '#client/constants'; + +/** @type {AbortController | null} */ +let controller = null; + +export function abort() { + controller?.abort(STALE_REACTION); + controller = null; +} + +export function getAbortSignal() { + return (controller ??= new AbortController()).signal; +} diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 4e547f48cb..bae93beb53 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -1,7 +1,7 @@ /** @import { Component } from '#server' */ import { DEV } from 'esm-env'; import { on_destroy } from './index.js'; -import * as e from '../shared/errors.js'; +import * as e from './errors.js'; /** @type {Component | null} */ export var current_component = null; diff --git a/packages/svelte/src/internal/server/dev.js b/packages/svelte/src/internal/server/dev.js index efc761d7c5..3c320f9698 100644 --- a/packages/svelte/src/internal/server/dev.js +++ b/packages/svelte/src/internal/server/dev.js @@ -5,7 +5,7 @@ import { is_tag_valid_with_parent } from '../../html-tree-validation.js'; import { current_component } from './context.js'; -import { invalid_snippet_arguments } from '../shared/errors.js'; +import * as e from './errors.js'; import { HeadPayload, Payload } from './payload.js'; /** @@ -102,6 +102,6 @@ export function validate_snippet_args(payload) { // for some reason typescript consider the type of payload as never after the first instanceof !(payload instanceof Payload || /** @type {any} */ (payload) instanceof HeadPayload) ) { - invalid_snippet_arguments(); + e.invalid_snippet_arguments(); } } diff --git a/packages/svelte/src/internal/server/errors.js b/packages/svelte/src/internal/server/errors.js index 38c545c84e..458937218f 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -1,5 +1,7 @@ /* This file is generated by scripts/process-messages/index.js. Do not edit! */ +export * from '../shared/errors.js'; + /** * `%name%(...)` is not available on the server * @param {string} name @@ -9,5 +11,6 @@ export function lifecycle_function_unavailable(name) { const error = new Error(`lifecycle_function_unavailable\n\`${name}(...)\` is not available on the server\nhttps://svelte.dev/e/lifecycle_function_unavailable`); error.name = 'Svelte error'; + throw error; } \ No newline at end of file diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 2ca85fff44..9942882d26 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -18,6 +18,7 @@ import { validate_store } from '../shared/validate.js'; import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js'; import { reset_elements } from './dev.js'; import { Payload } from './payload.js'; +import { abort } from './abort-signal.js'; // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 // https://infra.spec.whatwg.org/#noncharacter @@ -66,50 +67,54 @@ export let on_destroy = []; * @returns {RenderOutput} */ export function render(component, options = {}) { - const payload = new Payload(options.idPrefix ? options.idPrefix + '-' : ''); + try { + const payload = new Payload(options.idPrefix ? options.idPrefix + '-' : ''); - const prev_on_destroy = on_destroy; - on_destroy = []; - payload.out += BLOCK_OPEN; + const prev_on_destroy = on_destroy; + on_destroy = []; + payload.out += BLOCK_OPEN; - let reset_reset_element; + let reset_reset_element; - if (DEV) { - // prevent parent/child element state being corrupted by a bad render - reset_reset_element = reset_elements(); - } + if (DEV) { + // prevent parent/child element state being corrupted by a bad render + reset_reset_element = reset_elements(); + } - if (options.context) { - push(); - /** @type {Component} */ (current_component).c = options.context; - } + if (options.context) { + push(); + /** @type {Component} */ (current_component).c = options.context; + } - // @ts-expect-error - component(payload, options.props ?? {}, {}, {}); + // @ts-expect-error + component(payload, options.props ?? {}, {}, {}); - if (options.context) { - pop(); - } + if (options.context) { + pop(); + } - if (reset_reset_element) { - reset_reset_element(); - } + if (reset_reset_element) { + reset_reset_element(); + } - payload.out += BLOCK_CLOSE; - for (const cleanup of on_destroy) cleanup(); - on_destroy = prev_on_destroy; + payload.out += BLOCK_CLOSE; + for (const cleanup of on_destroy) cleanup(); + on_destroy = prev_on_destroy; - let head = payload.head.out + payload.head.title; + let head = payload.head.out + payload.head.title; - for (const { hash, code } of payload.css) { - head += ``; - } + for (const { hash, code } of payload.css) { + head += ``; + } - return { - head, - html: payload.out, - body: payload.out - }; + return { + head, + html: payload.out, + body: payload.out + }; + } finally { + abort(); + } } /** @@ -515,6 +520,8 @@ export { export { escape_html as escape }; +export { await_outside_boundary } from '../shared/errors.js'; + /** * @template T * @param {()=>T} fn diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index b8606fbf6f..66685cb00b 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -2,6 +2,22 @@ import { DEV } from 'esm-env'; +/** + * Cannot await outside a `` with a `pending` snippet + * @returns {never} + */ +export function await_outside_boundary() { + if (DEV) { + const error = new Error(`await_outside_boundary\nCannot await outside a \`\` with a \`pending\` snippet\nhttps://svelte.dev/e/await_outside_boundary`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/await_outside_boundary`); + } +} + /** * Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead * @returns {never} @@ -11,6 +27,7 @@ export function invalid_default_snippet() { const error = new Error(`invalid_default_snippet\nCannot use \`{@render children(...)}\` if the parent component uses \`let:\` directives. Consider using a named snippet instead\nhttps://svelte.dev/e/invalid_default_snippet`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/invalid_default_snippet`); @@ -26,6 +43,7 @@ export function invalid_snippet_arguments() { const error = new Error(`invalid_snippet_arguments\nA snippet function was passed invalid arguments. Snippets should only be instantiated via \`{@render ...}\`\nhttps://svelte.dev/e/invalid_snippet_arguments`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/invalid_snippet_arguments`); @@ -42,6 +60,7 @@ export function lifecycle_outside_component(name) { const error = new Error(`lifecycle_outside_component\n\`${name}(...)\` can only be used during component initialisation\nhttps://svelte.dev/e/lifecycle_outside_component`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/lifecycle_outside_component`); @@ -57,6 +76,7 @@ export function snippet_without_render_tag() { const error = new Error(`snippet_without_render_tag\nAttempted to render a snippet without a \`{@render}\` block. This would cause the snippet code to be stringified instead of its content being rendered to the DOM. To fix this, change \`{snippet}\` to \`{@render snippet()}\`.\nhttps://svelte.dev/e/snippet_without_render_tag`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/snippet_without_render_tag`); @@ -73,6 +93,7 @@ export function store_invalid_shape(name) { const error = new Error(`store_invalid_shape\n\`${name}\` is not a store with a \`subscribe\` method\nhttps://svelte.dev/e/store_invalid_shape`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/store_invalid_shape`); @@ -88,6 +109,7 @@ export function svelte_element_invalid_this_value() { const error = new Error(`svelte_element_invalid_this_value\nThe \`this\` prop on \`\` must be a string, if defined\nhttps://svelte.dev/e/svelte_element_invalid_this_value`); error.name = 'Svelte error'; + throw error; } else { throw new Error(`https://svelte.dev/e/svelte_element_invalid_this_value`); diff --git a/packages/svelte/src/internal/shared/validate.js b/packages/svelte/src/internal/shared/validate.js index bbb237594b..8f3e2807e7 100644 --- a/packages/svelte/src/internal/shared/validate.js +++ b/packages/svelte/src/internal/shared/validate.js @@ -1,5 +1,3 @@ -/** @import { TemplateNode } from '#client' */ -/** @import { Getters } from '#shared' */ import { is_void } from '../../utils.js'; import * as w from './warnings.js'; import * as e from './errors.js'; diff --git a/packages/svelte/src/internal/shared/warnings.js b/packages/svelte/src/internal/shared/warnings.js index 281be08382..0acca44184 100644 --- a/packages/svelte/src/internal/shared/warnings.js +++ b/packages/svelte/src/internal/shared/warnings.js @@ -25,11 +25,15 @@ export function dynamic_void_element_content(tag) { */ export function state_snapshot_uncloneable(properties) { if (DEV) { - console.warn(`%c[svelte] state_snapshot_uncloneable\n%c${properties - ? `The following properties cannot be cloned with \`$state.snapshot\` — the return value contains the originals: + console.warn( + `%c[svelte] state_snapshot_uncloneable\n%c${properties + ? `The following properties cannot be cloned with \`$state.snapshot\` — the return value contains the originals: ${properties}` - : 'Value cannot be cloned with `$state.snapshot` — the original value was returned'}\nhttps://svelte.dev/e/state_snapshot_uncloneable`, bold, normal); + : 'Value cannot be cloned with `$state.snapshot` — the original value was returned'}\nhttps://svelte.dev/e/state_snapshot_uncloneable`, + bold, + normal + ); } else { console.warn(`https://svelte.dev/e/state_snapshot_uncloneable`); } diff --git a/packages/svelte/src/legacy/legacy-client.js b/packages/svelte/src/legacy/legacy-client.js index 45c478ecab..d4a053d1aa 100644 --- a/packages/svelte/src/legacy/legacy-client.js +++ b/packages/svelte/src/legacy/legacy-client.js @@ -3,13 +3,15 @@ import { DIRTY, LEGACY_PROPS, MAYBE_DIRTY } from '../internal/client/constants.j import { user_pre_effect } from '../internal/client/reactivity/effects.js'; import { mutable_source, set } from '../internal/client/reactivity/sources.js'; import { hydrate, mount, unmount } from '../internal/client/render.js'; -import { active_effect, flushSync, get, set_signal_status } from '../internal/client/runtime.js'; -import { lifecycle_outside_component } from '../internal/shared/errors.js'; +import { active_effect, get, set_signal_status } from '../internal/client/runtime.js'; +import { flushSync } from '../internal/client/reactivity/batch.js'; import { define_property, is_array } from '../internal/shared/utils.js'; +import * as e from '../internal/client/errors.js'; import * as w from '../internal/client/warnings.js'; import { DEV } from 'esm-env'; import { FILENAME } from '../constants.js'; import { component_context, dev_current_component_function } from '../internal/client/context.js'; +import { async_mode_flag } from '../internal/flags/index.js'; /** * Takes the same options as a Svelte 4 component and the component function and returns a Svelte 4 compatible component. @@ -119,8 +121,9 @@ class Svelte4Component { recover: options.recover }); - // We don't flushSync for custom element wrappers or if the user doesn't want it - if (!options?.props?.$$host || options.sync === false) { + // We don't flushSync for custom element wrappers or if the user doesn't want it, + // or if we're in async mode since `flushSync()` will fail + if (!async_mode_flag && (!options?.props?.$$host || options.sync === false)) { flushSync(); } @@ -245,7 +248,7 @@ export function handlers(...handlers) { export function createBubbler() { const active_component_context = component_context; if (active_component_context === null) { - lifecycle_outside_component('createBubbler'); + e.lifecycle_outside_component('createBubbler'); } return (/**@type {string}*/ type) => (/**@type {Event}*/ event) => { diff --git a/packages/svelte/src/motion/spring.js b/packages/svelte/src/motion/spring.js index 0f3bc6fb9f..44be1a501b 100644 --- a/packages/svelte/src/motion/spring.js +++ b/packages/svelte/src/motion/spring.js @@ -5,7 +5,7 @@ import { writable } from '../store/shared/index.js'; import { loop } from '../internal/client/loop.js'; import { raf } from '../internal/client/timing.js'; import { is_date } from './utils.js'; -import { set, source } from '../internal/client/reactivity/sources.js'; +import { set, state } from '../internal/client/reactivity/sources.js'; import { render_effect } from '../internal/client/reactivity/effects.js'; import { tag } from '../internal/client/dev/tracing.js'; import { get } from '../internal/client/runtime.js'; @@ -170,9 +170,9 @@ export function spring(value, opts = {}) { * @since 5.8.0 */ export class Spring { - #stiffness = source(0.15); - #damping = source(0.8); - #precision = source(0.01); + #stiffness = state(0.15); + #damping = state(0.8); + #precision = state(0.01); #current; #target; @@ -194,8 +194,8 @@ export class Spring { * @param {SpringOpts} [options] */ constructor(value, options = {}) { - this.#current = DEV ? tag(source(value), 'Spring.current') : source(value); - this.#target = DEV ? tag(source(value), 'Spring.target') : source(value); + this.#current = DEV ? tag(state(value), 'Spring.current') : state(value); + this.#target = DEV ? tag(state(value), 'Spring.target') : state(value); if (typeof options.stiffness === 'number') this.#stiffness.v = clamp(options.stiffness, 0, 1); if (typeof options.damping === 'number') this.#damping.v = clamp(options.damping, 0, 1); diff --git a/packages/svelte/src/motion/tweened.js b/packages/svelte/src/motion/tweened.js index 09bd06c325..437c22ec3b 100644 --- a/packages/svelte/src/motion/tweened.js +++ b/packages/svelte/src/motion/tweened.js @@ -6,7 +6,7 @@ import { raf } from '../internal/client/timing.js'; import { loop } from '../internal/client/loop.js'; import { linear } from '../easing/index.js'; import { is_date } from './utils.js'; -import { set, source } from '../internal/client/reactivity/sources.js'; +import { set, state } from '../internal/client/reactivity/sources.js'; import { tag } from '../internal/client/dev/tracing.js'; import { get, render_effect } from 'svelte/internal/client'; import { DEV } from 'esm-env'; @@ -191,8 +191,8 @@ export class Tween { * @param {TweenedOptions} options */ constructor(value, options = {}) { - this.#current = source(value); - this.#target = source(value); + this.#current = state(value); + this.#target = state(value); this.#defaults = options; if (DEV) { diff --git a/packages/svelte/src/reactivity/create-subscriber.js b/packages/svelte/src/reactivity/create-subscriber.js index 491ffb45cb..4dcac4e6f6 100644 --- a/packages/svelte/src/reactivity/create-subscriber.js +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -1,15 +1,18 @@ import { get, tick, untrack } from '../internal/client/runtime.js'; import { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js'; -import { source } from '../internal/client/reactivity/sources.js'; +import { source, increment } from '../internal/client/reactivity/sources.js'; import { tag } from '../internal/client/dev/tracing.js'; -import { increment } from './utils.js'; import { DEV } from 'esm-env'; +import { queue_micro_task } from '../internal/client/dom/task.js'; /** - * Returns a `subscribe` function that, if called in an effect (including expressions in the template), - * calls its `start` callback with an `update` function. Whenever `update` is called, the effect re-runs. + * Returns a `subscribe` function that integrates external event-based systems with Svelte's reactivity. + * It's particularly useful for integrating with web APIs like `MediaQuery`, `IntersectionObserver`, or `WebSocket`. * - * If `start` returns a function, it will be called when the effect is destroyed. + * If `subscribe` is called inside an effect (including indirectly, for example inside a getter), + * the `start` callback will be called with an `update` function. Whenever `update` is called, the effect re-runs. + * + * If `start` returns a cleanup function, it will be called when the effect is destroyed. * * If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects * are active, and the returned teardown function will only be called when all effects are destroyed. @@ -37,6 +40,7 @@ import { DEV } from 'esm-env'; * } * * get current() { + * // This makes the getter reactive, if read in an effect * this.#subscribe(); * * // Return the current state of the query, whether or not we're in an effect @@ -69,8 +73,8 @@ export function createSubscriber(start) { subscribers += 1; return () => { - tick().then(() => { - // Only count down after timeout, else we would reach 0 before our own render effect reruns, + queue_micro_task(() => { + // Only count down after a microtask, else we would reach 0 before our own render effect reruns, // but reach 1 again when the tick callback of the prior teardown runs. That would mean we // re-subcribe unnecessarily and create a memory leak because the old subscription is never cleaned up. subscribers -= 1; diff --git a/packages/svelte/src/reactivity/date.js b/packages/svelte/src/reactivity/date.js index 4176f0ceec..8e2b5d41ab 100644 --- a/packages/svelte/src/reactivity/date.js +++ b/packages/svelte/src/reactivity/date.js @@ -1,6 +1,6 @@ /** @import { Source } from '#client' */ import { derived } from '../internal/client/index.js'; -import { source, set } from '../internal/client/reactivity/sources.js'; +import { set, state } from '../internal/client/reactivity/sources.js'; import { tag } from '../internal/client/dev/tracing.js'; import { active_reaction, get, set_active_reaction } from '../internal/client/runtime.js'; import { DEV } from 'esm-env'; @@ -40,7 +40,7 @@ var inited = false; * ``` */ export class SvelteDate extends Date { - #time = source(super.getTime()); + #time = state(super.getTime()); /** @type {Map>} */ #deriveds = new Map(); diff --git a/packages/svelte/src/reactivity/map.js b/packages/svelte/src/reactivity/map.js index eed163dbf2..014b5e7c7c 100644 --- a/packages/svelte/src/reactivity/map.js +++ b/packages/svelte/src/reactivity/map.js @@ -1,9 +1,8 @@ /** @import { Source } from '#client' */ import { DEV } from 'esm-env'; -import { set, source } from '../internal/client/reactivity/sources.js'; +import { set, source, state, increment } from '../internal/client/reactivity/sources.js'; import { label, tag } from '../internal/client/dev/tracing.js'; -import { get } from '../internal/client/runtime.js'; -import { increment } from './utils.js'; +import { get, update_version } from '../internal/client/runtime.js'; /** * A reactive version of the built-in [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) object. @@ -54,8 +53,9 @@ import { increment } from './utils.js'; export class SvelteMap extends Map { /** @type {Map>} */ #sources = new Map(); - #version = source(0); - #size = source(0); + #version = state(0); + #size = state(0); + #update_version = update_version || -1; /** * @param {Iterable | null | undefined} [value] @@ -79,6 +79,19 @@ export class SvelteMap extends Map { } } + /** + * If the source is being created inside the same reaction as the SvelteMap instance, + * we use `state` so that it will not be a dependency of the reaction. Otherwise we + * use `source` so it will be. + * + * @template T + * @param {T} value + * @returns {Source} + */ + #source(value) { + return update_version === this.#update_version ? state(value) : source(value); + } + /** @param {K} key */ has(key) { var sources = this.#sources; @@ -87,7 +100,7 @@ export class SvelteMap extends Map { if (s === undefined) { var ret = super.get(key); if (ret !== undefined) { - s = source(0); + s = this.#source(0); if (DEV) { tag(s, `SvelteMap get(${label(key)})`); @@ -123,7 +136,7 @@ export class SvelteMap extends Map { if (s === undefined) { var ret = super.get(key); if (ret !== undefined) { - s = source(0); + s = this.#source(0); if (DEV) { tag(s, `SvelteMap get(${label(key)})`); @@ -154,7 +167,7 @@ export class SvelteMap extends Map { var version = this.#version; if (s === undefined) { - s = source(0); + s = this.#source(0); if (DEV) { tag(s, `SvelteMap get(${label(key)})`); @@ -219,8 +232,7 @@ export class SvelteMap extends Map { if (this.#size.v !== sources.size) { for (var key of super.keys()) { if (!sources.has(key)) { - var s = source(0); - + var s = this.#source(0); if (DEV) { tag(s, `SvelteMap get(${label(key)})`); } diff --git a/packages/svelte/src/reactivity/set.js b/packages/svelte/src/reactivity/set.js index fd22014cb3..d7c2deeaae 100644 --- a/packages/svelte/src/reactivity/set.js +++ b/packages/svelte/src/reactivity/set.js @@ -1,9 +1,8 @@ /** @import { Source } from '#client' */ import { DEV } from 'esm-env'; -import { source, set } from '../internal/client/reactivity/sources.js'; +import { source, set, state, increment } from '../internal/client/reactivity/sources.js'; import { label, tag } from '../internal/client/dev/tracing.js'; -import { get } from '../internal/client/runtime.js'; -import { increment } from './utils.js'; +import { get, update_version } from '../internal/client/runtime.js'; var read_methods = ['forEach', 'isDisjointFrom', 'isSubsetOf', 'isSupersetOf']; var set_like_methods = ['difference', 'intersection', 'symmetricDifference', 'union']; @@ -48,8 +47,9 @@ var inited = false; export class SvelteSet extends Set { /** @type {Map>} */ #sources = new Map(); - #version = source(0); - #size = source(0); + #version = state(0); + #size = state(0); + #update_version = update_version || -1; /** * @param {Iterable | null | undefined} [value] @@ -75,6 +75,19 @@ export class SvelteSet extends Set { if (!inited) this.#init(); } + /** + * If the source is being created inside the same reaction as the SvelteSet instance, + * we use `state` so that it will not be a dependency of the reaction. Otherwise we + * use `source` so it will be. + * + * @template T + * @param {T} value + * @returns {Source} + */ + #source(value) { + return update_version === this.#update_version ? state(value) : source(value); + } + // We init as part of the first instance so that we can treeshake this class #init() { inited = true; @@ -116,7 +129,7 @@ export class SvelteSet extends Set { return false; } - s = source(true); + s = this.#source(true); if (DEV) { tag(s, `SvelteSet has(${label(value)})`); diff --git a/packages/svelte/src/reactivity/url-search-params.js b/packages/svelte/src/reactivity/url-search-params.js index c77ff9c822..2381e11875 100644 --- a/packages/svelte/src/reactivity/url-search-params.js +++ b/packages/svelte/src/reactivity/url-search-params.js @@ -1,9 +1,8 @@ import { DEV } from 'esm-env'; -import { source } from '../internal/client/reactivity/sources.js'; +import { state, increment } from '../internal/client/reactivity/sources.js'; import { tag } from '../internal/client/dev/tracing.js'; import { get } from '../internal/client/runtime.js'; import { get_current_url } from './url.js'; -import { increment } from './utils.js'; export const REPLACE = Symbol(); @@ -34,7 +33,7 @@ export const REPLACE = Symbol(); * ``` */ export class SvelteURLSearchParams extends URLSearchParams { - #version = DEV ? tag(source(0), 'SvelteURLSearchParams version') : source(0); + #version = DEV ? tag(state(0), 'SvelteURLSearchParams version') : state(0); #url = get_current_url(); #updating = false; diff --git a/packages/svelte/src/reactivity/url.js b/packages/svelte/src/reactivity/url.js index 56732a0402..dfede23f6e 100644 --- a/packages/svelte/src/reactivity/url.js +++ b/packages/svelte/src/reactivity/url.js @@ -1,5 +1,5 @@ import { DEV } from 'esm-env'; -import { source, set } from '../internal/client/reactivity/sources.js'; +import { set, state } from '../internal/client/reactivity/sources.js'; import { tag } from '../internal/client/dev/tracing.js'; import { get } from '../internal/client/runtime.js'; import { REPLACE, SvelteURLSearchParams } from './url-search-params.js'; @@ -40,14 +40,14 @@ export function get_current_url() { * ``` */ export class SvelteURL extends URL { - #protocol = source(super.protocol); - #username = source(super.username); - #password = source(super.password); - #hostname = source(super.hostname); - #port = source(super.port); - #pathname = source(super.pathname); - #hash = source(super.hash); - #search = source(super.search); + #protocol = state(super.protocol); + #username = state(super.username); + #password = state(super.password); + #hostname = state(super.hostname); + #port = state(super.port); + #pathname = state(super.pathname); + #hash = state(super.hash); + #search = state(super.search); #searchParams; /** diff --git a/packages/svelte/src/reactivity/utils.js b/packages/svelte/src/reactivity/utils.js deleted file mode 100644 index cd55e0e0ba..0000000000 --- a/packages/svelte/src/reactivity/utils.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @import { Source } from '#client' */ -import { set } from '../internal/client/reactivity/sources.js'; - -/** @param {Source} source */ -export function increment(source) { - set(source, source.v + 1); -} diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index 921eaec57c..cd79cfc274 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -428,7 +428,7 @@ export function is_mathml(name) { return MATHML_ELEMENTS.includes(name); } -export const STATE_CREATION_RUNES = /** @type {const} */ ([ +const STATE_CREATION_RUNES = /** @type {const} */ ([ '$state', '$state.raw', '$derived', @@ -445,6 +445,7 @@ const RUNES = /** @type {const} */ ([ '$effect.pre', '$effect.tracking', '$effect.root', + '$effect.pending', '$inspect', '$inspect().with', '$inspect.trace', diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 01888eaa78..4d6811c409 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.34.3'; +export const VERSION = '5.36.5'; export const PUBLIC_VERSION = '5'; diff --git a/packages/svelte/tests/compiler-errors/test.ts b/packages/svelte/tests/compiler-errors/test.ts index 5e57a3a032..13b9280dde 100644 --- a/packages/svelte/tests/compiler-errors/test.ts +++ b/packages/svelte/tests/compiler-errors/test.ts @@ -1,5 +1,5 @@ import * as fs from 'node:fs'; -import { assert, expect } from 'vitest'; +import { assert, expect, it } from 'vitest'; import { compile, compileModule, type CompileError } from 'svelte/compiler'; import { suite, type BaseTest } from '../suite'; import { read_file } from '../helpers.js'; @@ -78,3 +78,15 @@ const { test, run } = suite((config, cwd) => { export { test }; await run(__dirname); + +it('resets the compiler state including filename', () => { + // start with something that succeeds + compile('
hello
', { filename: 'foo.svelte' }); + // then try something that fails in the parsing stage + try { + compile('

hello

invalid

', { filename: 'bar.svelte' }); + expect.fail('Expected an error'); + } catch (e: any) { + expect(e.toString()).toContain('bar.svelte'); + } +}); diff --git a/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/expected.css b/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/expected.css new file mode 100644 index 0000000000..4b5e4bfd09 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/expected.css @@ -0,0 +1,2 @@ + span[class].svelte-xyz { color: green } + div[style].svelte-xyz { color: green } \ No newline at end of file diff --git a/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/input.svelte b/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/input.svelte new file mode 100644 index 0000000000..2f9ab202ca --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/input.svelte @@ -0,0 +1,7 @@ + +
+ + diff --git a/packages/svelte/tests/css/samples/class-directive/_config.js b/packages/svelte/tests/css/samples/class-directive/_config.js new file mode 100644 index 0000000000..abeb8b6329 --- /dev/null +++ b/packages/svelte/tests/css/samples/class-directive/_config.js @@ -0,0 +1,20 @@ +import { test } from '../../test'; + +export default test({ + warnings: [ + { + code: 'css_unused_selector', + message: 'Unused CSS selector ".forth"\nhttps://svelte.dev/e/css_unused_selector', + start: { + line: 8, + column: 2, + character: 190 + }, + end: { + line: 8, + column: 8, + character: 196 + } + } + ] +}); diff --git a/packages/svelte/tests/css/samples/class-directive/expected.css b/packages/svelte/tests/css/samples/class-directive/expected.css new file mode 100644 index 0000000000..b3a74baee5 --- /dev/null +++ b/packages/svelte/tests/css/samples/class-directive/expected.css @@ -0,0 +1,5 @@ + + .zero.first.svelte-xyz { color: green } + .second.svelte-xyz { color: green } + .third.svelte-xyz { color: green } + /* (unused) .forth { color: red }*/ diff --git a/packages/svelte/tests/css/samples/class-directive/input.svelte b/packages/svelte/tests/css/samples/class-directive/input.svelte new file mode 100644 index 0000000000..70075f89d4 --- /dev/null +++ b/packages/svelte/tests/css/samples/class-directive/input.svelte @@ -0,0 +1,9 @@ +
+
+ + \ No newline at end of file diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index 2d825dbb7c..410838829e 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -86,7 +86,8 @@ export async function compile_directory( const compiled = compileModule(text, { filename: opts.filename, generate: opts.generate, - dev: opts.dev + dev: opts.dev, + experimental: opts.experimental }); write(out, compiled.js.code.replace(`v${VERSION}`, 'VERSION')); } else { @@ -193,6 +194,8 @@ if (typeof window !== 'undefined') { export const fragments = /** @type {'html' | 'tree'} */ (process.env.FRAGMENTS) ?? 'html'; +export const async_mode = process.env.SVELTE_NO_ASYNC !== 'true'; + /** * @param {any[]} logs */ diff --git a/packages/svelte/tests/html_equal.js b/packages/svelte/tests/html_equal.js index 6948d8db32..b637e4d538 100644 --- a/packages/svelte/tests/html_equal.js +++ b/packages/svelte/tests/html_equal.js @@ -1,3 +1,4 @@ +import { COMMENT_NODE, ELEMENT_NODE, TEXT_NODE } from '#client/constants'; import { assert } from 'vitest'; /** @@ -35,7 +36,7 @@ function clean_children(node, opts) { }); for (let child of [...node.childNodes]) { - if (child.nodeType === 3) { + if (child.nodeType === TEXT_NODE) { let text = /** @type {Text} */ (child); if ( @@ -49,7 +50,7 @@ function clean_children(node, opts) { text.data = text.data.replace(/[^\S]+/g, ' '); - if (previous && previous.nodeType === 3) { + if (previous && previous.nodeType === TEXT_NODE) { const prev = /** @type {Text} */ (previous); prev.data += text.data; @@ -62,22 +63,22 @@ function clean_children(node, opts) { } } - if (child.nodeType === 8 && !opts.preserveComments) { + if (child.nodeType === COMMENT_NODE && !opts.preserveComments) { // comment child.remove(); continue; } // add newlines for better readability and potentially recurse into children - if (child.nodeType === 1 || child.nodeType === 8) { - if (previous?.nodeType === 3) { + if (child.nodeType === ELEMENT_NODE || child.nodeType === COMMENT_NODE) { + if (previous?.nodeType === TEXT_NODE) { const prev = /** @type {Text} */ (previous); prev.data = prev.data.replace(/^[^\S]+$/, '\n'); - } else if (previous?.nodeType === 1 || previous?.nodeType === 8) { + } else if (previous?.nodeType === ELEMENT_NODE || previous?.nodeType === COMMENT_NODE) { node.insertBefore(document.createTextNode('\n'), child); } - if (child.nodeType === 1) { + if (child.nodeType === ELEMENT_NODE) { has_element_children = true; clean_children(/** @type {Element} */ (child), opts); } @@ -87,12 +88,12 @@ function clean_children(node, opts) { } // collapse whitespace - if (node.firstChild && node.firstChild.nodeType === 3) { + if (node.firstChild && node.firstChild.nodeType === TEXT_NODE) { const text = /** @type {Text} */ (node.firstChild); text.data = text.data.trimStart(); } - if (node.lastChild && node.lastChild.nodeType === 3) { + if (node.lastChild && node.lastChild.nodeType === TEXT_NODE) { const text = /** @type {Text} */ (node.lastChild); text.data = text.data.trimEnd(); } diff --git a/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_config.js b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_config.js new file mode 100644 index 0000000000..bc74f23aac --- /dev/null +++ b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_config.js @@ -0,0 +1,11 @@ +import { test } from '../../test'; + +export default test({ + server_props: { + browser: false + }, + + props: { + browser: true + } +}); diff --git a/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_expected.html b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_expected.html new file mode 100644 index 0000000000..cc789c8f51 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_expected.html @@ -0,0 +1 @@ +
diff --git a/packages/svelte/tests/hydration/samples/removes-undefined-attributes/main.svelte b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/main.svelte new file mode 100644 index 0000000000..1a587eeeeb --- /dev/null +++ b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/main.svelte @@ -0,0 +1,9 @@ + + +
diff --git a/packages/svelte/tests/parser-modern/test.ts b/packages/svelte/tests/parser-modern/test.ts index b47d4a4879..279ba7bc08 100644 --- a/packages/svelte/tests/parser-modern/test.ts +++ b/packages/svelte/tests/parser-modern/test.ts @@ -21,6 +21,8 @@ const { test, run } = suite(async (config, cwd) => { ) ); + delete actual.comments; + // run `UPDATE_SNAPSHOTS=true pnpm test parser` to update parser tests if (process.env.UPDATE_SNAPSHOTS) { fs.writeFileSync(`${cwd}/output.json`, JSON.stringify(actual, null, '\t')); diff --git a/packages/svelte/tests/runtime-browser/assert.js b/packages/svelte/tests/runtime-browser/assert.js index fb460c722a..48bde01410 100644 --- a/packages/svelte/tests/runtime-browser/assert.js +++ b/packages/svelte/tests/runtime-browser/assert.js @@ -1,5 +1,8 @@ /** @import { assert } from 'vitest' */ /** @import { CompileOptions, Warning } from '#compiler' */ + +import { ELEMENT_NODE } from '#client/constants'; + /** * @param {any} a * @param {any} b @@ -102,7 +105,7 @@ function normalize_children(node) { } for (let child of [...node.childNodes]) { - if (child.nodeType === 1 /* Element */) { + if (child.nodeType === ELEMENT_NODE) { normalize_children(child); } } diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/_config.js b/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/_config.js new file mode 100644 index 0000000000..15adef2c9b --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const button = target.querySelector('button'); + + assert.htmlEqual(target.innerHTML, `
[0,0,0,0,0,0,0,0,0]`); + flushSync(() => button?.click()); + assert.htmlEqual(target.innerHTML, `
[0,0,0,0,0,0,0,0,0]`); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/main.svelte b/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/main.svelte new file mode 100644 index 0000000000..67190669ed --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/main.svelte @@ -0,0 +1,45 @@ + + +{#if a = 0}{/if} + +{#each [b = 0] as x}{x,''}{/each} + +{#key c = 0}{/key} + +{#await d = 0}{/await} + +{#snippet snip()}{/snippet} + +{@render (e = 0, snip)()} + +{@html f = 0, ''} + +
+ +{#key 1} + {@const x = (h = 0)} + {x, ''} +{/key} + +{#if 1} + {@const x = (i = 0)} + {x, ''} +{/if} + + +[{a},{b},{c},{d},{e},{f},{g},{h},{i}] + + diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/_config.js b/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/_config.js new file mode 100644 index 0000000000..523dcd625d --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const button = target.querySelector('button'); + + assert.htmlEqual(target.innerHTML, `
12 - 12`); + flushSync(() => button?.click()); + assert.htmlEqual(target.innerHTML, `
13 - 12`); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/main.svelte b/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/main.svelte new file mode 100644 index 0000000000..37838f091f --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/main.svelte @@ -0,0 +1,36 @@ + + +{#if fn(false)}{:else if fn(true)}{/if} + +{#each fn([]) as x}{x, ''}{/each} + +{#key fn(1)}{/key} + +{#await fn(Promise.resolve())}{/await} + +{#snippet snip()}{/snippet} + +{@render fn(snip)()} + +{@html fn('')} + +
{})}>
+ +{#key 1} + {@const x = fn('')} + {x} +{/key} + + +{count1} - {count2} + + diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/_config.js b/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/_config.js new file mode 100644 index 0000000000..0e1a5a8150 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const button = target.querySelector('button'); + + assert.htmlEqual(target.innerHTML, `
10 - 10`); + flushSync(() => button?.click()); + assert.htmlEqual(target.innerHTML, `
11 - 10`); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/main.svelte b/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/main.svelte new file mode 100644 index 0000000000..4041be4f6f --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/main.svelte @@ -0,0 +1,46 @@ + + +{#if obj.false}{:else if obj.true}{/if} + +{#each obj.array as x}{x, ''}{/each} + +{#key obj.string}{/key} + +{#await obj.promise}{/await} + +{#snippet snip()}{/snippet} + +{@render obj.snippet()} + +{@html obj.string} + +
+ +{#key 1} + {@const x = obj.string} + {x} +{/key} + + +{count1} - {count2} + + diff --git a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/Item.svelte b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/Item.svelte index b2e6cd046c..4127e857d5 100644 --- a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/Item.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/Item.svelte @@ -5,7 +5,7 @@ export let index; export let n; - function logRender () { + function logRender (n) { order.push(`${index}: render ${n}`); return index; } @@ -24,5 +24,5 @@
  • - {logRender()} + {logRender(n)}
  • diff --git a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/main.svelte b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/main.svelte index b05b1476fd..51dee3bc0c 100644 --- a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/main.svelte @@ -5,7 +5,7 @@ export let n = 0; - function logRender () { + function logRender (n) { order.push(`parent: render ${n}`); return 'parent'; } @@ -23,7 +23,7 @@ }) -{logRender()} +{logRender(n)}
    `; diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js index 762a23754c..218951b836 100644 --- a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js @@ -14,6 +14,7 @@ export default function Function_prop_no_getter($$anchor) { onmousedown: () => $.set(count, $.get(count) + 1), onmouseup, onmouseenter: () => $.set(count, plusOne($.get(count)), true), + children: ($$anchor, $$slotProps) => { $.next(); @@ -22,6 +23,7 @@ export default function Function_prop_no_getter($$anchor) { $.template_effect(() => $.set_text(text, `clicks: ${$.get(count) ?? ''}`)); $.append($$anchor, text); }, + $$slots: { default: true } }); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/server/index.svelte.js index 88f6f55ee7..7d37abd97b 100644 --- a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/server/index.svelte.js @@ -13,9 +13,11 @@ export default function Function_prop_no_getter($$payload) { onmousedown: () => count += 1, onmouseup, onmouseenter: () => count = plusOne(count), + children: ($$payload) => { $$payload.out += `clicks: ${$.escape(count)}`; }, + $$slots: { default: true } }); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js index 792d5421e1..d4034dc55d 100644 --- a/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/functional-templating/_expected/client/index.svelte.js @@ -6,6 +6,7 @@ var root = $.from_tree( [ ['h1', null, 'hello'], ' ', + [ 'div', { class: 'potato' }, diff --git a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client/index.svelte.js index ebbe191dcb..0eab38919c 100644 --- a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client/index.svelte.js @@ -3,6 +3,4 @@ import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; import { random } from './module.svelte'; -export default function Imports_in_modules($$anchor) { - -} \ No newline at end of file +export default function Imports_in_modules($$anchor) {} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/server/index.svelte.js index 4cd6bc59d7..2ed863d68f 100644 --- a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/server/index.svelte.js @@ -1,6 +1,4 @@ import * as $ from 'svelte/internal/server'; import { random } from './module.svelte'; -export default function Imports_in_modules($$payload) { - -} \ No newline at end of file +export default function Imports_in_modules($$payload) {} \ No newline at end of file diff --git a/packages/svelte/tests/store/test.ts b/packages/svelte/tests/store/test.ts index 77cecca7e5..ecb22c1be6 100644 --- a/packages/svelte/tests/store/test.ts +++ b/packages/svelte/tests/store/test.ts @@ -11,6 +11,7 @@ import { } from 'svelte/store'; import { source, set } from '../../src/internal/client/reactivity/sources'; import * as $ from '../../src/internal/client/runtime'; +import { flushSync } from '../../src/internal/client/reactivity/batch'; import { effect_root, render_effect } from 'svelte/internal/client'; describe('writable', () => { @@ -602,7 +603,7 @@ describe('toStore', () => { assert.deepEqual(log, [0]); set(count, 1); - $.flushSync(); + flushSync(); assert.deepEqual(log, [0, 1]); unsubscribe(); @@ -625,7 +626,7 @@ describe('toStore', () => { assert.deepEqual(log, [0]); set(count, 1); - $.flushSync(); + flushSync(); assert.deepEqual(log, [0, 1]); store.set(2); @@ -654,11 +655,11 @@ describe('fromStore', () => { assert.deepEqual(log, [0]); store.set(1); - $.flushSync(); + flushSync(); assert.deepEqual(log, [0, 1]); count.current = 2; - $.flushSync(); + flushSync(); assert.deepEqual(log, [0, 1, 2]); assert.equal(get(store), 2); diff --git a/packages/svelte/tests/suite.ts b/packages/svelte/tests/suite.ts index 0ae06e727f..bbd252b8e1 100644 --- a/packages/svelte/tests/suite.ts +++ b/packages/svelte/tests/suite.ts @@ -20,6 +20,9 @@ const filter = process.env.FILTER ) : /./; +// this defaults to 10, which is too low for some of our tests +Error.stackTraceLimit = 100; + export function suite(fn: (config: Test, test_dir: string) => void) { return { test: (config: Test) => config, @@ -35,7 +38,7 @@ export function suite(fn: (config: Test, test_dir: string export function suite_with_variants( variants: Variants[], - should_skip_variant: (variant: Variants, config: Test) => boolean | 'no-test', + should_skip_variant: (variant: Variants, config: Test, test_name: string) => boolean | 'no-test', common_setup: (config: Test, test_dir: string) => Promise | Common, fn: (config: Test, test_dir: string, variant: Variants, common: Common) => void ) { @@ -46,11 +49,11 @@ export function suite_with_variants + diff --git a/packages/svelte/tests/validator/samples/a11y-no-autofocus/input.svelte b/packages/svelte/tests/validator/samples/a11y-no-autofocus/input.svelte index 769dbe8c5b..7b1dccd1e8 100644 --- a/packages/svelte/tests/validator/samples/a11y-no-autofocus/input.svelte +++ b/packages/svelte/tests/validator/samples/a11y-no-autofocus/input.svelte @@ -1 +1,6 @@ -
    \ No newline at end of file +
    + + + + + diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 1a83e0d0f1..a8b769d6d4 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -348,6 +348,30 @@ declare module 'svelte' { */ props: Props; }); + /** + * Returns an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when the current [derived](https://svelte.dev/docs/svelte/$derived) or [effect](https://svelte.dev/docs/svelte/$effect) re-runs or is destroyed. + * + * Must be called while a derived or effect is running. + * + * ```svelte + * + * ``` + */ + export function getAbortSignal(): AbortSignal; /** * `onMount`, like [`$effect`](https://svelte.dev/docs/svelte/$effect), schedules a function to run as soon as the component has been mounted to the DOM. * Unlike `$effect`, the provided function only runs once. @@ -410,6 +434,11 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; + /** + * Synchronously flush any pending updates. + * Returns void if no callback is provided, otherwise returns the result of calling the callback. + * */ + export function flushSync(fn?: (() => T) | undefined): T; /** * Create a snippet programmatically * */ @@ -419,29 +448,6 @@ declare module 'svelte' { }): Snippet; /** Anything except a function */ type NotFunction = T extends Function ? never : T; - /** - * Synchronously flush any pending updates. - * Returns void if no callback is provided, otherwise returns the result of calling the callback. - * */ - export function flushSync(fn?: (() => T) | undefined): T; - /** - * Returns a promise that resolves once any pending state changes have been applied. - * */ - export function tick(): Promise; - /** - * When used inside a [`$derived`](https://svelte.dev/docs/svelte/$derived) or [`$effect`](https://svelte.dev/docs/svelte/$effect), - * any state read inside `fn` will not be treated as a dependency. - * - * ```ts - * $effect(() => { - * // this will run when `data` changes, but not when `time` changes - * save(data, { - * timestamp: untrack(() => time) - * }); - * }); - * ``` - * */ - export function untrack(fn: () => T): T; /** * Retrieves the context that belongs to the closest parent component with the specified `key`. * Must be called during component initialisation. @@ -515,6 +521,30 @@ declare module 'svelte' { export function unmount(component: Record, options?: { outro?: boolean; } | undefined): Promise; + /** + * Returns a promise that resolves once any pending state changes have been applied. + * */ + export function tick(): Promise; + /** + * Returns a promise that resolves once any state changes, and asynchronous work resulting from them, + * have resolved and the DOM has been updated + * @since 5.36 + */ + export function settled(): Promise; + /** + * When used inside a [`$derived`](https://svelte.dev/docs/svelte/$derived) or [`$effect`](https://svelte.dev/docs/svelte/$effect), + * any state read inside `fn` will not be treated as a dependency. + * + * ```ts + * $effect(() => { + * // this will run when `data` changes, but not when `time` changes + * save(data, { + * timestamp: untrack(() => time) + * }); + * }); + * ``` + * */ + export function untrack(fn: () => T): T; type Getters = { [K in keyof T]: () => T[K]; }; @@ -1087,6 +1117,17 @@ declare module 'svelte/compiler' { * Use this to filter out warnings. Return `true` to keep the warning, `false` to discard it. */ warningFilter?: (warning: Warning) => boolean; + /** + * Experimental options + * @since 5.36 + */ + experimental?: { + /** + * Allow `await` keyword in deriveds, template expressions, and the top level of components + * @since 5.36 + */ + async?: boolean; + }; } /** * - `html` — the default, for e.g. `
    ` or `` @@ -1120,6 +1161,8 @@ declare module 'svelte/compiler' { instance: Script | null; /** The parsed ` + + + + + {#snippet pending()}{/snippet} + diff --git a/playgrounds/sandbox/index.html b/playgrounds/sandbox/index.html index 845538abf0..639409b877 100644 --- a/playgrounds/sandbox/index.html +++ b/playgrounds/sandbox/index.html @@ -12,7 +12,13 @@