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 + * + * + *

{large.current ? 'large screen' : 'small screen'}

+ * ``` + * @since 5.7.0 + */ +export class MediaQuery { + #query; + #subscribe = createSubscriber((update) => { + return on(this.#query, 'change', update); + }); + + get current() { + this.#subscribe(); + + return this.#query.matches; + } + + /** + * @param {string} query A media query string + * @param {boolean} [matches] Fallback value for the server + */ + constructor(query, matches) { + // For convenience (and because people likely forget them) we add the parentheses; double parentheses are not a problem + this.#query = window.matchMedia(`(${query})`); + } +} diff --git a/packages/svelte/src/store/index-client.js b/packages/svelte/src/store/index-client.js index f2f1dfc4eba1..ae6806ec763f 100644 --- a/packages/svelte/src/store/index-client.js +++ b/packages/svelte/src/store/index-client.js @@ -1,14 +1,11 @@ /** @import { Readable, Writable } from './public.js' */ -import { noop } from '../internal/shared/utils.js'; import { effect_root, effect_tracking, render_effect } from '../internal/client/reactivity/effects.js'; -import { source } from '../internal/client/reactivity/sources.js'; -import { get as get_source, tick } from '../internal/client/runtime.js'; -import { increment } from '../reactivity/utils.js'; import { get, writable } from './shared/index.js'; +import { createSubscriber } from '../reactivity/create-subscriber.js'; export { derived, get, readable, readonly, writable } from './shared/index.js'; @@ -109,43 +106,23 @@ export function toStore(get, set) { */ export function fromStore(store) { let value = /** @type {V} */ (undefined); - let version = source(0); - let subscribers = 0; - let unsubscribe = noop; + const subscribe = createSubscriber((update) => { + let ran = false; - function current() { - if (effect_tracking()) { - get_source(version); + const unsubscribe = store.subscribe((v) => { + value = v; + if (ran) update(); + }); - render_effect(() => { - if (subscribers === 0) { - let ran = false; - - unsubscribe = store.subscribe((v) => { - value = v; - if (ran) increment(version); - }); - - ran = true; - } - - 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) { - unsubscribe(); - } - }); - }; - }); + ran = true; + + return unsubscribe; + }); + function current() { + if (effect_tracking()) { + subscribe(); return value; } diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index 002ebf2e38ff..45b90240c99a 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -172,3 +172,16 @@ export function write(file, contents) { fs.writeFileSync(file, contents); } + +// Guard because not all test contexts load this with JSDOM +if (typeof window !== 'undefined') { + // @ts-expect-error JS DOM doesn't support it + Window.prototype.matchMedia = (media) => { + return { + matches: false, + media, + addEventListener: () => {}, + removeEventListener: () => {} + }; + }; +} diff --git a/packages/svelte/tests/motion/test.ts b/packages/svelte/tests/motion/test.ts index 05971b5cab65..b6554e5e56ed 100644 --- a/packages/svelte/tests/motion/test.ts +++ b/packages/svelte/tests/motion/test.ts @@ -1,3 +1,5 @@ +// @vitest-environment jsdom +import '../helpers.js'; // for the matchMedia polyfill import { describe, it, assert } from 'vitest'; import { get } from 'svelte/store'; import { spring, tweened } from 'svelte/motion'; diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json index 017b92629866..380307901edd 100644 --- a/packages/svelte/tsconfig.json +++ b/packages/svelte/tsconfig.json @@ -25,6 +25,7 @@ "svelte/motion": ["./src/motion/public.d.ts"], "svelte/server": ["./src/server/index.d.ts"], "svelte/store": ["./src/store/public.d.ts"], + "svelte/reactivity": ["./src/reactivity/index-client.js"], "#compiler": ["./src/compiler/types/index.d.ts"], "#client": ["./src/internal/client/types.d.ts"], "#server": ["./src/internal/server/types.d.ts"], diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 5a5cc86ae6c3..46a2137ae6e4 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1637,6 +1637,7 @@ declare module 'svelte/legacy' { } declare module 'svelte/motion' { + import type { MediaQuery } from 'svelte/reactivity'; export interface Spring extends Readable { set: (new_value: T, opts?: SpringUpdateOpts) => Promise; update: (fn: Updater, opts?: SpringUpdateOpts) => Promise; @@ -1683,6 +1684,30 @@ declare module 'svelte/motion' { easing?: (t: number) => number; interpolate?: (a: T, b: T) => (t: number) => T; } + /** + * 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} + * ``` + * @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 + * + * + *

{large.current ? 'large screen' : 'small screen'}

+ * ``` + * @since 5.7.0 + */ + export class MediaQuery { + /** + * @param query A media query string + * @param matches Fallback value for the server + */ + constructor(query: string, matches?: boolean | undefined); + get current(): boolean; + #private; + } + /** + * 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; + * } + * } + * ``` + * @since 5.7.0 + */ + export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export {}; }