Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/svelte/src/motion/index.js
Original file line number Diff line number Diff line change
@@ -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)');
1 change: 1 addition & 0 deletions packages/svelte/src/reactivity/index-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
18 changes: 18 additions & 0 deletions packages/svelte/src/reactivity/index-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 createStartStopNotifier(_) {
return () => {};
}
35 changes: 35 additions & 0 deletions packages/svelte/src/reactivity/media-query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { get } from '../internal/client/runtime.js';
import { set, source } from '../internal/client/reactivity/sources.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);
#query;
#notify;

get current() {
if (effect_tracking()) {
get(this.#version);
this.#notify();
}

return this.#query.matches;
}

/**
* @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);
this.#notify = createStartStopNotifier(() => {
const listener = () => set(this.#version, this.#version.v + 1);
this.#query.addEventListener('change', listener);
return () => this.#query.removeEventListener('change', listener);
});
}
}
40 changes: 40 additions & 0 deletions packages/svelte/src/reactivity/start-stop-notifier.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
};
});
}
};
}
48 changes: 15 additions & 33 deletions packages/svelte/src/store/index-client.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}

Expand Down
10 changes: 10 additions & 0 deletions packages/svelte/tests/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {}
};
};
2 changes: 2 additions & 0 deletions packages/svelte/tests/motion/test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
17 changes: 17 additions & 0 deletions packages/svelte/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,7 @@ declare module 'svelte/legacy' {
}

declare module 'svelte/motion' {
import type { MediaQuery } from 'svelte/reactivity';
export interface Spring<T> extends Readable<T> {
set: (new_value: T, opts?: SpringUpdateOpts) => Promise<void>;
update: (fn: Updater<T>, opts?: SpringUpdateOpts) => Promise<void>;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -1720,6 +1725,18 @@ 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.
*/
export class MediaQuery {
/**
* @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;
}

export {};
}
Expand Down
Loading