diff --git a/.changeset/popular-worms-repeat.md b/.changeset/popular-worms-repeat.md new file mode 100644 index 000000000000..68d9f9a3e80e --- /dev/null +++ b/.changeset/popular-worms-repeat.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `createSubscriber` function for creating reactive values that depend on subscriptions diff --git a/.changeset/quiet-tables-cheat.md b/.changeset/quiet-tables-cheat.md new file mode 100644 index 000000000000..92e9c266cc90 --- /dev/null +++ b/.changeset/quiet-tables-cheat.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add reactive `MediaQuery` class, and a `prefersReducedMotion` class instance diff --git a/packages/svelte/src/motion/index.js b/packages/svelte/src/motion/index.js index 10f52502d372..f4262a565024 100644 --- a/packages/svelte/src/motion/index.js +++ b/packages/svelte/src/motion/index.js @@ -1,2 +1,32 @@ +import { MediaQuery } from 'svelte/reactivity'; + export * from './spring.js'; export * from './tweened.js'; + +/** + * A [media query](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery) that matches if the user [prefers reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion). + * + * ```svelte + * + * + * + * + * {#if visible} + *
+ * flies in, unless the user prefers reduced motion + *
+ * {/if} + * ``` + * @type {MediaQuery} + * @since 5.7.0 + */ +export const prefersReducedMotion = /*@__PURE__*/ new MediaQuery( + '(prefers-reduced-motion: reduce)' +); diff --git a/packages/svelte/src/reactivity/create-subscriber.js b/packages/svelte/src/reactivity/create-subscriber.js new file mode 100644 index 000000000000..63deca62ea8b --- /dev/null +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -0,0 +1,81 @@ +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 { increment } from './utils.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. + * + * If `start` returns a 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. + * + * It's best understood with an example. Here's an implementation of [`MediaQuery`](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery): + * + * ```js + * import { createSubscriber } from 'svelte/reactivity'; + * import { on } from 'svelte/events'; + * + * export class MediaQuery { + * #query; + * #subscribe; + * + * constructor(query) { + * this.#query = window.matchMedia(`(${query})`); + * + * this.#subscribe = createSubscriber((update) => { + * // when the `change` event occurs, re-run any effects that read `this.current` + * const off = on(this.#query, 'change', update); + * + * // stop listening when all the effects are destroyed + * return () => off(); + * }); + * } + * + * get current() { + * this.#subscribe(); + * + * // Return the current state of the query, whether or not we're in an effect + * return this.#query.matches; + * } + * } + * ``` + * @param {(update: () => void) => (() => void) | void} start + * @since 5.7.0 + */ +export function createSubscriber(start) { + let subscribers = 0; + let version = source(0); + /** @type {(() => void) | void} */ + let stop; + + return () => { + if (effect_tracking()) { + get(version); + + render_effect(() => { + if (subscribers === 0) { + stop = untrack(() => start(() => increment(version))); + } + + subscribers += 1; + + return () => { + tick().then(() => { + // Only count down after timeout, 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; + + if (subscribers === 0) { + stop?.(); + stop = undefined; + } + }); + }; + }); + } + }; +} diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 2757688a5958..3eb9b95333ab 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -3,3 +3,5 @@ export { SvelteSet } from './set.js'; export { SvelteMap } from './map.js'; export { SvelteURL } from './url.js'; export { SvelteURLSearchParams } from './url-search-params.js'; +export { MediaQuery } from './media-query.js'; +export { createSubscriber } from './create-subscriber.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 6240469ec36f..6a6c9dcf1360 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -3,3 +3,21 @@ export const SvelteSet = globalThis.Set; export const SvelteMap = globalThis.Map; export const SvelteURL = globalThis.URL; export const SvelteURLSearchParams = globalThis.URLSearchParams; + +export class MediaQuery { + current; + /** + * @param {string} query + * @param {boolean} [matches] + */ + constructor(query, matches = false) { + this.current = matches; + } +} + +/** + * @param {any} _ + */ +export function createSubscriber(_) { + return () => {}; +} diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js new file mode 100644 index 000000000000..a2be0adc91e2 --- /dev/null +++ b/packages/svelte/src/reactivity/media-query.js @@ -0,0 +1,41 @@ +import { createSubscriber } from './create-subscriber.js'; +import { on } from '../events/index.js'; + +/** + * Creates a media query and provides a `current` property that reflects whether or not it matches. + * + * Use it carefully — during server-side rendering, there is no way to know what the correct value should be, potentially causing content to change upon hydration. + * If you can use the media query in CSS to achieve the same effect, do that. + * + * ```svelte + * + * + *+ * flies in, unless the user prefers reduced motion + *
+ * {/if} + * ``` + * @since 5.7.0 + */ + export const prefersReducedMotion: MediaQuery; /** * The spring function in Svelte creates a store whose value is animated, with a motion that simulates the behavior of a spring. This means when the value changes, instead of transitioning at a steady rate, it "bounces" like a spring would, depending on the physics parameters provided. This adds a level of realism to the transitions and can enhance the user experience. * @@ -1727,6 +1752,74 @@ declare module 'svelte/reactivity' { [REPLACE](params: URLSearchParams): void; #private; } + /** + * Creates a media query and provides a `current` property that reflects whether or not it matches. + * + * Use it carefully — during server-side rendering, there is no way to know what the correct value should be, potentially causing content to change upon hydration. + * If you can use the media query in CSS to achieve the same effect, do that. + * + * ```svelte + * + * + *