From c5c4bea9dae1234e038400f539ae5fee7e28b056 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 12:10:34 +0100 Subject: [PATCH 01/23] feat: provide `MediaQuery` / `prefersReducedMotion` closes #5346 --- packages/svelte/src/motion/index.js | 8 +++ .../svelte/src/reactivity/index-client.js | 1 + .../svelte/src/reactivity/index-server.js | 4 ++ packages/svelte/src/reactivity/media-query.js | 49 +++++++++++++++++++ packages/svelte/types/index.d.ts | 14 ++++++ 5 files changed, 76 insertions(+) create mode 100644 packages/svelte/src/reactivity/media-query.js diff --git a/packages/svelte/src/motion/index.js b/packages/svelte/src/motion/index.js index 10f52502d372..295e32eb0d65 100644 --- a/packages/svelte/src/motion/index.js +++ b/packages/svelte/src/motion/index.js @@ -1,2 +1,10 @@ +import { MediaQuery } from 'svelte/reactivity'; + export * from './spring.js'; export * from './tweened.js'; + +/** + * A media query that matches if the user has requested reduced motion. + * @type {MediaQuery} + */ +export const prefersReducedMotion = new MediaQuery('(prefers-reduced-motion: reduce)'); diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 2757688a5958..d11d5195e741 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -3,3 +3,4 @@ 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'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 6240469ec36f..71202f41bc12 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -3,3 +3,7 @@ export const SvelteSet = globalThis.Set; export const SvelteMap = globalThis.Map; export const SvelteURL = globalThis.URL; export const SvelteURLSearchParams = globalThis.URLSearchParams; + +export class MediaQuery { + matches = false; +} diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js new file mode 100644 index 000000000000..acb965d182da --- /dev/null +++ b/packages/svelte/src/reactivity/media-query.js @@ -0,0 +1,49 @@ +import { get, tick } from '../internal/client/runtime.js'; +import { set, source } from '../internal/client/reactivity/sources.js'; +import { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js'; + +/** + * Creates a media query and provides a `matches` property that reflects its current state. + */ +export class MediaQuery { + #matches = source(false); + #subscribers = 0; + #query; + /** @type {any} */ + #listener; + + get matches() { + if (effect_tracking()) { + render_effect(() => { + if (this.#subscribers === 0) { + this.#listener = () => set(this.#matches, this.#query.matches); + this.#query.addEventListener('change', this.#listener); + } + + this.#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. + this.#subscribers -= 1; + + if (this.#subscribers === 0) { + this.#query.removeEventListener('change', this.#listener); + } + }); + }; + }); + } + + return get(this.#matches); + } + + /** @param {string} query */ + constructor(query) { + this.#query = window.matchMedia(query); + console.log('MediaQuery.constructor', query, this.#query); + this.#matches.v = this.#query.matches; + } +} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 01119e748572..fe432fc3edf2 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1630,6 +1630,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; @@ -1676,6 +1677,10 @@ declare module 'svelte/motion' { easing?: (t: number) => number; interpolate?: (a: T, b: T) => (t: number) => T; } + /** + * A media query that matches if the user has requested reduced motion. + * */ + 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. * @@ -1720,6 +1725,15 @@ declare module 'svelte/reactivity' { [REPLACE](params: URLSearchParams): void; #private; } + /** + * Creates a media query and provides a `matches` property that reflects its current state. + */ + export class MediaQuery { + + constructor(query: string); + get matches(): boolean; + #private; + } export {}; } From 62d370a753cbc889eb8ec701f013c24113a996fc Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 17:03:20 +0100 Subject: [PATCH 02/23] matches -> current, server fallback --- .../svelte/src/reactivity/index-server.js | 9 +++++++- packages/svelte/src/reactivity/media-query.js | 21 +++++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 71202f41bc12..653a7bae4a82 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -5,5 +5,12 @@ export const SvelteURL = globalThis.URL; export const SvelteURLSearchParams = globalThis.URLSearchParams; export class MediaQuery { - matches = false; + current; + /** + * @param {string} query + * @param {boolean} [matches] + */ + constructor(query, matches = false) { + this.current = matches; + } } diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index acb965d182da..ecb44cd50ed6 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -3,20 +3,22 @@ import { set, source } from '../internal/client/reactivity/sources.js'; import { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js'; /** - * Creates a media query and provides a `matches` property that reflects its current state. + * Creates a media query and provides a `current` property that reflects whether or not it matches. */ export class MediaQuery { - #matches = source(false); + #version = source(0); #subscribers = 0; #query; /** @type {any} */ #listener; - get matches() { + get current() { if (effect_tracking()) { + get(this.#version); + render_effect(() => { if (this.#subscribers === 0) { - this.#listener = () => set(this.#matches, this.#query.matches); + this.#listener = () => set(this.#version, this.#version.v + 1); this.#query.addEventListener('change', this.#listener); } @@ -37,13 +39,14 @@ export class MediaQuery { }); } - return get(this.#matches); + return this.#query.matches; } - /** @param {string} query */ - constructor(query) { + /** + * @param {string} query A media query string (don't forget the braces) + * @param {boolean} [matches] Fallback value for the server + */ + constructor(query, matches) { this.#query = window.matchMedia(query); - console.log('MediaQuery.constructor', query, this.#query); - this.#matches.v = this.#query.matches; } } From 9da3a10adb30942b86e278b565bb84de0aa37e40 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 17:44:43 +0100 Subject: [PATCH 03/23] createStartStopNotifier --- .../svelte/src/reactivity/index-server.js | 7 +++ packages/svelte/src/reactivity/media-query.js | 37 ++++---------- .../src/reactivity/start-stop-notifier.js | 40 ++++++++++++++++ packages/svelte/src/store/index-client.js | 48 ++++++------------- packages/svelte/types/index.d.ts | 11 +++-- 5 files changed, 79 insertions(+), 64 deletions(-) create mode 100644 packages/svelte/src/reactivity/start-stop-notifier.js diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 653a7bae4a82..436f2aab54c6 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -14,3 +14,10 @@ export class MediaQuery { this.current = matches; } } + +/** + * @param {any} _ + */ +export function createStartStopNotifier(_) { + return () => {}; +} diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index ecb44cd50ed6..333628b659b9 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -1,42 +1,20 @@ -import { get, tick } from '../internal/client/runtime.js'; +import { get } from '../internal/client/runtime.js'; import { set, source } from '../internal/client/reactivity/sources.js'; -import { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js'; +import { effect_tracking } from '../internal/client/reactivity/effects.js'; +import { createStartStopNotifier } from './start-stop-notifier.js'; /** * Creates a media query and provides a `current` property that reflects whether or not it matches. */ export class MediaQuery { #version = source(0); - #subscribers = 0; #query; - /** @type {any} */ - #listener; + #notify; get current() { if (effect_tracking()) { get(this.#version); - - render_effect(() => { - if (this.#subscribers === 0) { - this.#listener = () => set(this.#version, this.#version.v + 1); - this.#query.addEventListener('change', this.#listener); - } - - this.#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. - this.#subscribers -= 1; - - if (this.#subscribers === 0) { - this.#query.removeEventListener('change', this.#listener); - } - }); - }; - }); + this.#notify(); } return this.#query.matches; @@ -48,5 +26,10 @@ export class MediaQuery { */ constructor(query, matches) { this.#query = window.matchMedia(query); + this.#notify = createStartStopNotifier(() => { + const listener = () => set(this.#version, this.#version.v + 1); + this.#query.addEventListener('change', listener); + return () => this.#query.removeEventListener('change', listener); + }); } } diff --git a/packages/svelte/src/reactivity/start-stop-notifier.js b/packages/svelte/src/reactivity/start-stop-notifier.js new file mode 100644 index 000000000000..8c561b6f8868 --- /dev/null +++ b/packages/svelte/src/reactivity/start-stop-notifier.js @@ -0,0 +1,40 @@ +import { tick, untrack } from '../internal/client/runtime.js'; +import { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js'; + +/** + * Returns a function that, when invoked in a reactive context, calls the `start` function once, + * and calls the `stop` function returned from `start` when all reactive contexts it's called in + * are destroyed. This is useful for creating a notifier that starts and stops when the + * "subscriber" count goes from 0 to 1 and back to 0. + * @param {() => () => void} start + */ +export function createStartStopNotifier(start) { + let subscribers = 0; + /** @type {() => void} */ + let stop; + + return () => { + if (effect_tracking()) { + render_effect(() => { + if (subscribers === 0) { + stop = untrack(start); + } + + 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(); + } + }); + }; + }); + } + }; +} diff --git a/packages/svelte/src/store/index-client.js b/packages/svelte/src/store/index-client.js index f2f1dfc4eba1..ec3f2c43efde 100644 --- a/packages/svelte/src/store/index-client.js +++ b/packages/svelte/src/store/index-client.js @@ -1,14 +1,14 @@ /** @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 { get as get_source } from '../internal/client/runtime.js'; import { increment } from '../reactivity/utils.js'; import { get, writable } from './shared/index.js'; +import { createStartStopNotifier } from '../reactivity/start-stop-notifier.js'; export { derived, get, readable, readonly, writable } from './shared/index.js'; @@ -110,42 +110,24 @@ 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 notify = createStartStopNotifier(() => { + let ran = false; + + const unsubscribe = store.subscribe((v) => { + value = v; + if (ran) increment(version); + }); + + ran = true; + + return unsubscribe; + }); function current() { if (effect_tracking()) { get_source(version); - - 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(); - } - }); - }; - }); - + notify(); return value; } diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index fe432fc3edf2..5c64465a3216 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1726,12 +1726,15 @@ declare module 'svelte/reactivity' { #private; } /** - * Creates a media query and provides a `matches` property that reflects its current state. + * Creates a media query and provides a `current` property that reflects whether or not it matches. */ export class MediaQuery { - - constructor(query: string); - get matches(): boolean; + /** + * @param query A media query string (don't forget the braces) + * @param matches Fallback value for the server + */ + constructor(query: string, matches?: boolean | undefined); + get current(): boolean; #private; } From 7c3fb02e96544a32bec617bf37cbe3d33d521e51 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 17:44:52 +0100 Subject: [PATCH 04/23] test polyfill --- packages/svelte/tests/runtime-legacy/shared.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index b14c0bdf4bd3..0fac5927b23e 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -478,3 +478,13 @@ export function ok(value: any): asserts value { throw new Error(`Expected truthy value, got ${value}`); } } + +// @ts-expect-error JS DOM doesn't support it +Window.prototype.matchMedia = (media) => { + return { + matches: false, + media, + addEventListener: () => {}, + removeEventListener: () => {} + }; +}; From defa6465ad26d9a83b0abbf6ff4f3e4c0dad8b03 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 17:55:15 +0100 Subject: [PATCH 05/23] more tests fixes --- packages/svelte/tests/helpers.js | 10 ++++++++++ packages/svelte/tests/motion/test.ts | 2 ++ packages/svelte/tests/runtime-legacy/shared.ts | 10 ---------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index 002ebf2e38ff..0d4a220f78a9 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -172,3 +172,13 @@ export function write(file, contents) { fs.writeFileSync(file, contents); } + +// @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/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index 0fac5927b23e..b14c0bdf4bd3 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -478,13 +478,3 @@ export function ok(value: any): asserts value { throw new Error(`Expected truthy value, got ${value}`); } } - -// @ts-expect-error JS DOM doesn't support it -Window.prototype.matchMedia = (media) => { - return { - matches: false, - media, - addEventListener: () => {}, - removeEventListener: () => {} - }; -}; From 905556b5179ac74335f5ca9e536622468dbc5985 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 19:57:23 +0100 Subject: [PATCH 06/23] feedback --- packages/svelte/src/reactivity/media-query.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index 333628b659b9..0b414e706911 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -1,15 +1,19 @@ import { get } from '../internal/client/runtime.js'; -import { set, source } from '../internal/client/reactivity/sources.js'; +import { source } from '../internal/client/reactivity/sources.js'; import { effect_tracking } from '../internal/client/reactivity/effects.js'; import { createStartStopNotifier } from './start-stop-notifier.js'; +import { on } from '../events/index.js'; +import { increment } from './utils.js'; /** * Creates a media query and provides a `current` property that reflects whether or not it matches. */ export class MediaQuery { - #version = source(0); #query; - #notify; + #version = source(0); + #notify = createStartStopNotifier(() => { + return on(this.#query, 'change', () => increment(this.#version)); + }); get current() { if (effect_tracking()) { @@ -21,15 +25,11 @@ export class MediaQuery { } /** - * @param {string} query A media query string (don't forget the braces) + * @param {string} query A media query string * @param {boolean} [matches] Fallback value for the server */ constructor(query, matches) { - this.#query = window.matchMedia(query); - this.#notify = createStartStopNotifier(() => { - const listener = () => set(this.#version, this.#version.v + 1); - this.#query.addEventListener('change', listener); - return () => this.#query.removeEventListener('change', listener); - }); + // For convenience (and because people likely forget them) we add the parentheses; double parantheses are not a problem + this.#query = window.matchMedia(`(${query})`); } } From 6e2bc3e12cad3bf46743f536d822df7e80757ad2 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 19:58:33 +0100 Subject: [PATCH 07/23] rename --- .../{start-stop-notifier.js => create-subscriber.js} | 2 +- packages/svelte/src/reactivity/media-query.js | 4 ++-- packages/svelte/src/store/index-client.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename packages/svelte/src/reactivity/{start-stop-notifier.js => create-subscriber.js} (96%) diff --git a/packages/svelte/src/reactivity/start-stop-notifier.js b/packages/svelte/src/reactivity/create-subscriber.js similarity index 96% rename from packages/svelte/src/reactivity/start-stop-notifier.js rename to packages/svelte/src/reactivity/create-subscriber.js index 8c561b6f8868..d64a32aa4345 100644 --- a/packages/svelte/src/reactivity/start-stop-notifier.js +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -8,7 +8,7 @@ import { effect_tracking, render_effect } from '../internal/client/reactivity/ef * "subscriber" count goes from 0 to 1 and back to 0. * @param {() => () => void} start */ -export function createStartStopNotifier(start) { +export function createSubscriber(start) { let subscribers = 0; /** @type {() => void} */ let stop; diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index 0b414e706911..b93eddf4e320 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -1,7 +1,7 @@ import { get } from '../internal/client/runtime.js'; import { source } from '../internal/client/reactivity/sources.js'; import { effect_tracking } from '../internal/client/reactivity/effects.js'; -import { createStartStopNotifier } from './start-stop-notifier.js'; +import { createSubscriber } from './create-subscriber.js'; import { on } from '../events/index.js'; import { increment } from './utils.js'; @@ -11,7 +11,7 @@ import { increment } from './utils.js'; export class MediaQuery { #query; #version = source(0); - #notify = createStartStopNotifier(() => { + #notify = createSubscriber(() => { return on(this.#query, 'change', () => increment(this.#version)); }); diff --git a/packages/svelte/src/store/index-client.js b/packages/svelte/src/store/index-client.js index ec3f2c43efde..1140f4b18c5c 100644 --- a/packages/svelte/src/store/index-client.js +++ b/packages/svelte/src/store/index-client.js @@ -8,7 +8,7 @@ import { source } from '../internal/client/reactivity/sources.js'; import { get as get_source } from '../internal/client/runtime.js'; import { increment } from '../reactivity/utils.js'; import { get, writable } from './shared/index.js'; -import { createStartStopNotifier } from '../reactivity/start-stop-notifier.js'; +import { createSubscriber } from '../reactivity/create-subscriber.js'; export { derived, get, readable, readonly, writable } from './shared/index.js'; @@ -111,7 +111,7 @@ export function fromStore(store) { let value = /** @type {V} */ (undefined); let version = source(0); - const notify = createStartStopNotifier(() => { + const notify = createSubscriber(() => { let ran = false; const unsubscribe = store.subscribe((v) => { From 80887196f8df6057a10fa6434a4772ef9cba9ccd Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 20:00:52 +0100 Subject: [PATCH 08/23] tweak, types --- packages/svelte/src/reactivity/create-subscriber.js | 7 ++++--- packages/svelte/src/reactivity/index-client.js | 1 + packages/svelte/src/reactivity/index-server.js | 2 +- packages/svelte/types/index.d.ts | 9 ++++++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/svelte/src/reactivity/create-subscriber.js b/packages/svelte/src/reactivity/create-subscriber.js index d64a32aa4345..5911b4936c68 100644 --- a/packages/svelte/src/reactivity/create-subscriber.js +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -6,11 +6,11 @@ import { effect_tracking, render_effect } from '../internal/client/reactivity/ef * and calls the `stop` function returned from `start` when all reactive contexts it's called in * are destroyed. This is useful for creating a notifier that starts and stops when the * "subscriber" count goes from 0 to 1 and back to 0. - * @param {() => () => void} start + * @param {() => (() => void) | void} start */ export function createSubscriber(start) { let subscribers = 0; - /** @type {() => void} */ + /** @type {(() => void) | void} */ let stop; return () => { @@ -30,7 +30,8 @@ export function createSubscriber(start) { subscribers -= 1; if (subscribers === 0) { - stop(); + stop?.(); + stop = undefined; } }); }; diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index d11d5195e741..3eb9b95333ab 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -4,3 +4,4 @@ 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 436f2aab54c6..6a6c9dcf1360 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -18,6 +18,6 @@ export class MediaQuery { /** * @param {any} _ */ -export function createStartStopNotifier(_) { +export function createSubscriber(_) { return () => {}; } diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 5c64465a3216..2e9d3d188bda 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1730,13 +1730,20 @@ declare module 'svelte/reactivity' { */ export class MediaQuery { /** - * @param query A media query string (don't forget the braces) + * @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 function that, when invoked in a reactive context, calls the `start` function once, + * and calls the `stop` function returned from `start` when all reactive contexts it's called in + * are destroyed. This is useful for creating a notifier that starts and stops when the + * "subscriber" count goes from 0 to 1 and back to 0. + * */ + export function createSubscriber(start: () => (() => void) | void): () => void; export {}; } From c996cc4a89ace88a1fd4c331dfbd3e15e4f8a6be Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 20:29:58 +0100 Subject: [PATCH 09/23] hnnnggh --- packages/svelte/tests/helpers.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index 0d4a220f78a9..45b90240c99a 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -173,12 +173,15 @@ export function write(file, contents) { fs.writeFileSync(file, contents); } -// @ts-expect-error JS DOM doesn't support it -Window.prototype.matchMedia = (media) => { - return { - matches: false, - media, - addEventListener: () => {}, - removeEventListener: () => {} +// 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: () => {} + }; }; -}; +} From 42cd56302f9f7a88cd4fe100265f85ba73a8cd19 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 20:45:32 +0100 Subject: [PATCH 10/23] mark as pure --- packages/svelte/src/motion/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/motion/index.js b/packages/svelte/src/motion/index.js index 295e32eb0d65..c5ff5b02f22b 100644 --- a/packages/svelte/src/motion/index.js +++ b/packages/svelte/src/motion/index.js @@ -7,4 +7,6 @@ export * from './tweened.js'; * A media query that matches if the user has requested reduced motion. * @type {MediaQuery} */ -export const prefersReducedMotion = new MediaQuery('(prefers-reduced-motion: reduce)'); +export const prefersReducedMotion = /*@__PURE__*/ new MediaQuery( + '(prefers-reduced-motion: reduce)' +); From 075b6dbba832d764882d2b44b465bdaa92b0eaad Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 25 Nov 2024 21:03:09 +0100 Subject: [PATCH 11/23] fix type check --- packages/svelte/tsconfig.json | 1 + 1 file changed, 1 insertion(+) 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"], From 1a6b8f37d3efe85337f1599443f9617cb8dcc166 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 26 Nov 2024 15:27:46 -0500 Subject: [PATCH 12/23] notify -> subscribe --- packages/svelte/src/reactivity/media-query.js | 4 ++-- packages/svelte/src/store/index-client.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index b93eddf4e320..6a5e3da478a8 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -11,14 +11,14 @@ import { increment } from './utils.js'; export class MediaQuery { #query; #version = source(0); - #notify = createSubscriber(() => { + #subscribe = createSubscriber(() => { return on(this.#query, 'change', () => increment(this.#version)); }); get current() { if (effect_tracking()) { get(this.#version); - this.#notify(); + this.#subscribe(); } return this.#query.matches; diff --git a/packages/svelte/src/store/index-client.js b/packages/svelte/src/store/index-client.js index 1140f4b18c5c..28be7982911b 100644 --- a/packages/svelte/src/store/index-client.js +++ b/packages/svelte/src/store/index-client.js @@ -111,7 +111,7 @@ export function fromStore(store) { let value = /** @type {V} */ (undefined); let version = source(0); - const notify = createSubscriber(() => { + const subscribe = createSubscriber(() => { let ran = false; const unsubscribe = store.subscribe((v) => { @@ -127,7 +127,7 @@ export function fromStore(store) { function current() { if (effect_tracking()) { get_source(version); - notify(); + subscribe(); return value; } From d7b331f7070f3a15f10dd2d7e5976a34627f2f6b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 26 Nov 2024 15:30:40 -0500 Subject: [PATCH 13/23] add links to inline docs --- packages/svelte/src/motion/index.js | 2 +- packages/svelte/types/index.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/motion/index.js b/packages/svelte/src/motion/index.js index c5ff5b02f22b..3db1a55baa45 100644 --- a/packages/svelte/src/motion/index.js +++ b/packages/svelte/src/motion/index.js @@ -4,7 +4,7 @@ export * from './spring.js'; export * from './tweened.js'; /** - * A media query that matches if the user has requested reduced motion. + * 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). * @type {MediaQuery} */ export const prefersReducedMotion = /*@__PURE__*/ new MediaQuery( diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 2e9d3d188bda..07280899fc85 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1678,7 +1678,7 @@ declare module 'svelte/motion' { interpolate?: (a: T, b: T) => (t: number) => T; } /** - * A media query that matches if the user has requested reduced motion. + * 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). * */ export const prefersReducedMotion: MediaQuery; /** From 5afa6f3cc3b0c583530d5757ca235d37e30570e5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 27 Nov 2024 11:11:45 +0100 Subject: [PATCH 14/23] better API, more docs --- .../src/reactivity/create-subscriber.js | 42 +++++++++++++++++-- packages/svelte/src/reactivity/media-query.js | 14 ++----- packages/svelte/src/store/index-client.js | 9 +--- packages/svelte/types/index.d.ts | 33 ++++++++++++++- 4 files changed, 74 insertions(+), 24 deletions(-) diff --git a/packages/svelte/src/reactivity/create-subscriber.js b/packages/svelte/src/reactivity/create-subscriber.js index 5911b4936c68..dd7af37b1594 100644 --- a/packages/svelte/src/reactivity/create-subscriber.js +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -1,23 +1,57 @@ -import { tick, untrack } from '../internal/client/runtime.js'; +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 function that, when invoked in a reactive context, calls the `start` function once, * and calls the `stop` function returned from `start` when all reactive contexts it's called in * are destroyed. This is useful for creating a notifier that starts and stops when the - * "subscriber" count goes from 0 to 1 and back to 0. - * @param {() => (() => void) | void} start + * "subscriber" count goes from 0 to 1 and back to 0. Call the `update` function passed to the + * `start` function to notify subscribers of an update. + * + * Usage example (reimplementing `MediaQuery`): + * + * ```js + * import { createSubscriber, on } from 'svelte/reactivity'; + * + * export class MediaQuery { + * #query; + * #subscribe = createSubscriber((update) => { + * // add an event listener to update all subscribers when the match changes + * return on(this.#query, 'change', update); + * }); + * + * get current() { + * // If the `current` property is accessed in a reactive context, start a new + * // subscription if there isn't one already. The subscription will under the + * // hood ensure that whatever reactive context reads `current` will rerun when + * // the match changes + * this.#subscribe(); + * // Return the current state of the query + * return this.#query.matches; + * } + * + * constructor(query) { + * this.#query = window.matchMedia(`(${query})`); + * } + * } + * ``` + * @param {(update: () => void) => (() => void) | void} start */ 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); + stop = untrack(() => start(() => increment(version))); } subscribers += 1; diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index 6a5e3da478a8..463454bbb2c8 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -1,25 +1,17 @@ -import { get } from '../internal/client/runtime.js'; -import { source } from '../internal/client/reactivity/sources.js'; -import { effect_tracking } from '../internal/client/reactivity/effects.js'; import { createSubscriber } from './create-subscriber.js'; import { on } from '../events/index.js'; -import { increment } from './utils.js'; /** * Creates a media query and provides a `current` property that reflects whether or not it matches. */ export class MediaQuery { #query; - #version = source(0); - #subscribe = createSubscriber(() => { - return on(this.#query, 'change', () => increment(this.#version)); + #subscribe = createSubscriber((update) => { + return on(this.#query, 'change', update); }); get current() { - if (effect_tracking()) { - get(this.#version); - this.#subscribe(); - } + this.#subscribe(); return this.#query.matches; } diff --git a/packages/svelte/src/store/index-client.js b/packages/svelte/src/store/index-client.js index 28be7982911b..ae6806ec763f 100644 --- a/packages/svelte/src/store/index-client.js +++ b/packages/svelte/src/store/index-client.js @@ -4,9 +4,6 @@ import { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js'; -import { source } from '../internal/client/reactivity/sources.js'; -import { get as get_source } 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'; @@ -109,14 +106,13 @@ export function toStore(get, set) { */ export function fromStore(store) { let value = /** @type {V} */ (undefined); - let version = source(0); - const subscribe = createSubscriber(() => { + const subscribe = createSubscriber((update) => { let ran = false; const unsubscribe = store.subscribe((v) => { value = v; - if (ran) increment(version); + if (ran) update(); }); ran = true; @@ -126,7 +122,6 @@ export function fromStore(store) { function current() { if (effect_tracking()) { - get_source(version); subscribe(); return value; } diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 07280899fc85..4cd31359e2ed 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1741,9 +1741,38 @@ declare module 'svelte/reactivity' { * Returns a function that, when invoked in a reactive context, calls the `start` function once, * and calls the `stop` function returned from `start` when all reactive contexts it's called in * are destroyed. This is useful for creating a notifier that starts and stops when the - * "subscriber" count goes from 0 to 1 and back to 0. + * "subscriber" count goes from 0 to 1 and back to 0. Call the `update` function passed to the + * `start` function to notify subscribers of an update. + * + * Usage example (reimplementing `MediaQuery`): + * + * ```js + * import { createSubscriber, on } from 'svelte/reactivity'; + * + * export class MediaQuery { + * #query; + * #subscribe = createSubscriber((update) => { + * // add an event listener to update all subscribers when the match changes + * return on(this.#query, 'change', update); + * }); + * + * get current() { + * // If the `current` property is accessed in a reactive context, start a new + * // subscription if there isn't one already. The subscription will under the + * // hood ensure that whatever reactive context reads `current` will rerun when + * // the match changes + * this.#subscribe(); + * // Return the current state of the query + * return this.#query.matches; + * } + * + * constructor(query) { + * this.#query = window.matchMedia(`(${query})`); + * } + * } + * ``` * */ - export function createSubscriber(start: () => (() => void) | void): () => void; + export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export {}; } From 7900d9b3d4cd41c611323460224d63d0f0cd3275 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 17:24:02 -0500 Subject: [PATCH 15/23] add example to prefersReducedMotion --- packages/svelte/src/motion/index.js | 19 +++++++++++++++++++ packages/svelte/types/index.d.ts | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/svelte/src/motion/index.js b/packages/svelte/src/motion/index.js index 3db1a55baa45..022be7bc1a1b 100644 --- a/packages/svelte/src/motion/index.js +++ b/packages/svelte/src/motion/index.js @@ -5,6 +5,25 @@ 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} */ export const prefersReducedMotion = /*@__PURE__*/ new MediaQuery( diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 66396ec12eda..7f456ffad450 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1686,6 +1686,23 @@ declare module 'svelte/motion' { } /** * 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} + * ``` * */ export const prefersReducedMotion: MediaQuery; /** From 4e59abc444e50f457ca667a4a17f35023b34d045 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 17:30:32 -0500 Subject: [PATCH 16/23] add example for MediaQuery --- packages/svelte/src/reactivity/media-query.js | 12 ++++++++++++ packages/svelte/types/index.d.ts | 18 ++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index 463454bbb2c8..28e940589615 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -3,6 +3,18 @@ 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. + * + * ```svelte + * + * + *

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

+ * ``` */ export class MediaQuery { #query; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 7f456ffad450..a98e53eb04c0 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1695,11 +1695,13 @@ declare module 'svelte/motion' { * let visible = $state(false); * * - * + * * * {#if visible} *

- * flies in, unless the user prefers reduced motion + * flies in, unless the user prefers reduced motion *

* {/if} * ``` @@ -1751,6 +1753,18 @@ declare module 'svelte/reactivity' { } /** * 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. + * + * ```svelte + * + * + *

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

+ * ``` */ export class MediaQuery { /** From 0e689c84786bd2ed7bb079ef683f96bd179ff242 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 17:37:40 -0500 Subject: [PATCH 17/23] typo --- packages/svelte/src/reactivity/media-query.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index 28e940589615..a5cda5e0f8a2 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -33,7 +33,7 @@ export class MediaQuery { * @param {boolean} [matches] Fallback value for the server */ constructor(query, matches) { - // For convenience (and because people likely forget them) we add the parentheses; double parantheses are not a problem + // For convenience (and because people likely forget them) we add the parentheses; double parentheses are not a problem this.#query = window.matchMedia(`(${query})`); } } From c3b3e1480762095af85f08114bce8ae27f28a777 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 17:39:35 -0500 Subject: [PATCH 18/23] fix example --- packages/svelte/src/reactivity/create-subscriber.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/reactivity/create-subscriber.js b/packages/svelte/src/reactivity/create-subscriber.js index dd7af37b1594..44552a995542 100644 --- a/packages/svelte/src/reactivity/create-subscriber.js +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -13,7 +13,8 @@ import { increment } from './utils.js'; * Usage example (reimplementing `MediaQuery`): * * ```js - * import { createSubscriber, on } from 'svelte/reactivity'; + * import { createSubscriber } from 'svelte/reactivity'; + * import { on } from 'svelte/events'; * * export class MediaQuery { * #query; From 1f0ad0a7460e6ba24ed684e684150d9e1d3a4968 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 18:32:00 -0500 Subject: [PATCH 19/23] tweak docs --- .../src/reactivity/create-subscriber.js | 42 +++++++++-------- packages/svelte/types/index.d.ts | 45 ++++++++++--------- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/packages/svelte/src/reactivity/create-subscriber.js b/packages/svelte/src/reactivity/create-subscriber.js index 44552a995542..085660f86ee3 100644 --- a/packages/svelte/src/reactivity/create-subscriber.js +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -4,13 +4,15 @@ import { source } from '../internal/client/reactivity/sources.js'; import { increment } from './utils.js'; /** - * Returns a function that, when invoked in a reactive context, calls the `start` function once, - * and calls the `stop` function returned from `start` when all reactive contexts it's called in - * are destroyed. This is useful for creating a notifier that starts and stops when the - * "subscriber" count goes from 0 to 1 and back to 0. Call the `update` function passed to the - * `start` function to notify subscribers of an update. + * 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. * - * Usage example (reimplementing `MediaQuery`): + * 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'; @@ -18,23 +20,25 @@ import { increment } from './utils.js'; * * export class MediaQuery { * #query; - * #subscribe = createSubscriber((update) => { - * // add an event listener to update all subscribers when the match changes - * return on(this.#query, 'change', update); - * }); + * #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() { - * // If the `current` property is accessed in a reactive context, start a new - * // subscription if there isn't one already. The subscription will under the - * // hood ensure that whatever reactive context reads `current` will rerun when - * // the match changes * this.#subscribe(); - * // Return the current state of the query - * return this.#query.matches; - * } * - * constructor(query) { - * this.#query = window.matchMedia(`(${query})`); + * // Return the current state of the query, whether or not we're in an effect + * return this.#query.matches; * } * } * ``` diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index a98e53eb04c0..7e4e58f97f29 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1776,36 +1776,41 @@ declare module 'svelte/reactivity' { #private; } /** - * Returns a function that, when invoked in a reactive context, calls the `start` function once, - * and calls the `stop` function returned from `start` when all reactive contexts it's called in - * are destroyed. This is useful for creating a notifier that starts and stops when the - * "subscriber" count goes from 0 to 1 and back to 0. Call the `update` function passed to the - * `start` function to notify subscribers of an update. + * 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. * - * Usage example (reimplementing `MediaQuery`): + * 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, on } from 'svelte/reactivity'; + * import { createSubscriber } from 'svelte/reactivity'; + * import { on } from 'svelte/events'; * * export class MediaQuery { * #query; - * #subscribe = createSubscriber((update) => { - * // add an event listener to update all subscribers when the match changes - * return on(this.#query, 'change', update); - * }); + * #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() { - * // If the `current` property is accessed in a reactive context, start a new - * // subscription if there isn't one already. The subscription will under the - * // hood ensure that whatever reactive context reads `current` will rerun when - * // the match changes * this.#subscribe(); - * // Return the current state of the query - * return this.#query.matches; - * } * - * constructor(query) { - * this.#query = window.matchMedia(`(${query})`); + * // Return the current state of the query, whether or not we're in an effect + * return this.#query.matches; * } * } * ``` From 60aaaaca24537255d99f5c3c1313599c829dfd36 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 2 Dec 2024 18:33:46 -0500 Subject: [PATCH 20/23] changesets --- .changeset/popular-worms-repeat.md | 5 +++++ .changeset/quiet-tables-cheat.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/popular-worms-repeat.md create mode 100644 .changeset/quiet-tables-cheat.md 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 From 8288220a6a906f416bcbeaa0801abfcd00c23c2a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Dec 2024 10:10:22 -0500 Subject: [PATCH 21/23] note when APIs were added --- packages/svelte/src/motion/index.js | 1 + packages/svelte/src/reactivity/create-subscriber.js | 1 + packages/svelte/src/reactivity/media-query.js | 1 + packages/svelte/types/index.d.ts | 7 +++++-- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/motion/index.js b/packages/svelte/src/motion/index.js index 022be7bc1a1b..f4262a565024 100644 --- a/packages/svelte/src/motion/index.js +++ b/packages/svelte/src/motion/index.js @@ -25,6 +25,7 @@ export * from './tweened.js'; * {/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 index 085660f86ee3..63deca62ea8b 100644 --- a/packages/svelte/src/reactivity/create-subscriber.js +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -43,6 +43,7 @@ import { increment } from './utils.js'; * } * ``` * @param {(update: () => void) => (() => void) | void} start + * @since 5.7.0 */ export function createSubscriber(start) { let subscribers = 0; diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index a5cda5e0f8a2..ce5e6d4e1fb1 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -15,6 +15,7 @@ import { on } from '../events/index.js'; * *

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

* ``` + * @since 5.7.0 */ export class MediaQuery { #query; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 7e4e58f97f29..72454f501663 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1705,7 +1705,8 @@ declare module 'svelte/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. @@ -1765,6 +1766,7 @@ declare module 'svelte/reactivity' { * *

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

* ``` + * @since 5.7.0 */ export class MediaQuery { /** @@ -1814,7 +1816,8 @@ declare module 'svelte/reactivity' { * } * } * ``` - * */ + * @since 5.7.0 + */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export {}; From df97184069584ef21170d50da222a7d442150053 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 5 Dec 2024 10:18:02 -0500 Subject: [PATCH 22/23] add note --- packages/svelte/src/reactivity/media-query.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index ce5e6d4e1fb1..a2be0adc91e2 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -5,6 +5,7 @@ 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 *