diff --git a/.changeset/tidy-chefs-taste.md b/.changeset/tidy-chefs-taste.md new file mode 100644 index 000000000000..fc6e39dbd499 --- /dev/null +++ b/.changeset/tidy-chefs-taste.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: reactive url diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 38c7d918850d..9c0b28dd2f34 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -1,3 +1,4 @@ export { ReactiveDate as Date } from './date.js'; export { ReactiveSet as Set } from './set.js'; export { ReactiveMap as Map } from './map.js'; +export { ReactiveURL as URL, ReactiveURLSearchParams as URLSearchParams } from './url.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 1821bac2de82..63360a30d4f5 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,3 +1,5 @@ export const Date = globalThis.Date; export const Set = globalThis.Set; export const Map = globalThis.Map; +export const URL = globalThis.URL; +export const URLSearchParams = globalThis.URLSearchParams; diff --git a/packages/svelte/src/reactivity/url.js b/packages/svelte/src/reactivity/url.js new file mode 100644 index 000000000000..58218a73d2a8 --- /dev/null +++ b/packages/svelte/src/reactivity/url.js @@ -0,0 +1,287 @@ +import { source, set } from '../internal/client/reactivity/sources.js'; +import { get } from '../internal/client/runtime.js'; +import { map } from './utils.js'; + +const UPDATE = Symbol.for('UPDATE'); + +export class ReactiveURL extends URL { + #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), + search: source(super.search), + hash: source(super.hash) + }; + #searchParams = new ReactiveURLSearchParams(super.searchParams, this.#url.search); + + get hash() { + get(this.#url.hash); + return super.hash; + } + + set hash(value) { + super.hash = value; + set(this.#url.hash, super.hash); + } + + get host() { + get(this.#url.hostname); + get(this.#url.port); + return super.host; + } + + set host(value) { + super.host = value; + set(this.#url.hostname, super.hostname); + set(this.#url.port, super.port); + } + + get hostname() { + get(this.#url.hostname); + return super.hostname; + } + + set hostname(value) { + super.hostname = value; + set(this.#url.hostname, super.hostname); + } + + get href() { + get(this.#url.protocol); + get(this.#url.username); + get(this.#url.password); + get(this.#url.hostname); + get(this.#url.port); + get(this.#url.pathname); + get(this.#url.search); + get(this.#url.hash); + return super.href; + } + + set href(value) { + super.href = value; + set(this.#url.protocol, super.protocol); + set(this.#url.username, super.username); + set(this.#url.password, super.password); + set(this.#url.hostname, super.hostname); + set(this.#url.port, super.port); + set(this.#url.pathname, super.pathname); + set(this.#url.search, super.search); + set(this.#url.hash, super.hash); + this.#searchParams[UPDATE](super.searchParams); + } + + get password() { + get(this.#url.password); + return super.password; + } + + set password(value) { + super.password = value; + set(this.#url.password, super.password); + } + + get pathname() { + get(this.#url.pathname); + return super.pathname; + } + + set pathname(value) { + super.pathname = value; + set(this.#url.pathname, super.pathname); + } + + get port() { + get(this.#url.port); + return super.port; + } + + set port(value) { + super.port = value; + set(this.#url.port, super.port); + } + + get protocol() { + get(this.#url.protocol); + return super.protocol; + } + + set protocol(value) { + super.protocol = value; + set(this.#url.protocol, super.protocol); + } + + get search() { + get(this.#url.search); + return super.search; + } + + set search(value) { + super.search = value; + set(this.#url.search, super.search); + this.#searchParams[UPDATE](super.searchParams); + } + + get username() { + get(this.#url.username); + return super.username; + } + + set username(value) { + super.username = value; + set(this.#url.username, super.username); + } + + get origin() { + get(this.#url.protocol); + get(this.#url.hostname); + get(this.#url.port); + return super.origin; + } + + get searchParams() { + return this.#searchParams; + } + + toString() { + this.href; + return super.toString(); + } + + toJSON() { + this.href; + return super.toJSON(); + } + + /** + * @param {string} input + * @param {string=} base + */ + constructor(input, base) { + super(input, base); + } +} + +export class ReactiveURLSearchParams extends URLSearchParams { + #url_search_params; + #search; + #version = source(0); + + #increment_version() { + set(this.#version, this.#version.v + 1); + } + #update_search() { + set(this.#search, '?' + this.#url_search_params.toString()); + } + + /** + * + * @param {URLSearchParams} value + */ + [UPDATE](value) { + this.#url_search_params = value; + this.#increment_version(); + } + /** + * + * @param {URLSearchParams} url_search_params + * @param {import('../internal/client/reactivity/types.js').Source} search + */ + constructor(url_search_params, search) { + super(); + this.#url_search_params = url_search_params; + this.#search = search; + } + + /** + * + * @param {string} name + * @param {string} value + * @returns {void} + */ + append(name, value) { + this.#increment_version(); + this.#update_search(); + return this.#url_search_params.append(name, value); + } + /** + * + * @param {string} name + * @param {string=} value + * @returns {void} + */ + delete(name, value) { + this.#increment_version(); + this.#update_search(); + return this.#url_search_params.delete(name, value); + } + /** + * + * @param {string} name + * @returns {string|null} + */ + get(name) { + get(this.#version); + return this.#url_search_params.get(name); + } + /** + * + * @param {string} name + * @returns {string[]} + */ + getAll(name) { + get(this.#version); + return this.#url_search_params.getAll(name); + } + /** + * + * @param {string} name + * @param {string=} value + * @returns {boolean} + */ + has(name, value) { + get(this.#version); + return this.#url_search_params.has(name, value); + } + keys() { + get(this.#version); + return this.#url_search_params.keys(); + } + /** + * + * @param {string} name + * @param {string} value + * @returns {void} + */ + set(name, value) { + this.#increment_version(); + this.#update_search(); + return this.#url_search_params.set(name, value); + } + sort() { + this.#increment_version(); + this.#update_search(); + return this.#url_search_params.sort(); + } + toString() { + get(this.#version); + return this.#url_search_params.toString(); + } + values() { + get(this.#version); + return this.#url_search_params.values(); + } + entries() { + get(this.#version); + return this.#url_search_params.entries(); + } + [Symbol.iterator]() { + return this.entries(); + } + get size() { + return this.#url_search_params.size; + } +} diff --git a/packages/svelte/src/reactivity/url.test.ts b/packages/svelte/src/reactivity/url.test.ts new file mode 100644 index 000000000000..53f7796bc2ee --- /dev/null +++ b/packages/svelte/src/reactivity/url.test.ts @@ -0,0 +1,78 @@ +import { render_effect, effect_root } from '../internal/client/reactivity/effects.js'; +import { flushSync } from '../index-client.js'; +import { ReactiveURL } from './url.js'; +import { assert, test } from 'vitest'; + +test('url.hash', () => { + const url = new ReactiveURL('http://google.com'); + const log: any = []; + + const cleanup = effect_root(() => { + render_effect(() => { + log.push(url.hash); + }); + }); + + flushSync(() => { + url.hash = 'abc'; + }); + + flushSync(() => { + url.href = 'http://google.com/a/b/c#def'; + }); + + flushSync(() => { + // does not affect hash + url.pathname = 'e/f'; + }); + + assert.deepEqual(log, ['', '#abc', '#def']); + + cleanup(); +}); + +test('url.searchParams', () => { + const url = new ReactiveURL('https://svelte.dev?foo=bar&t=123'); + const log: any = []; + + const cleanup = effect_root(() => { + render_effect(() => { + log.push('search: ' + url.search); + }); + render_effect(() => { + log.push('foo: ' + url.searchParams.get('foo')); + }); + render_effect(() => { + log.push('q: ' + url.searchParams.has('q')); + }); + }); + + flushSync(() => { + url.search = '?q=kit&foo=baz'; + }); + + flushSync(() => { + url.searchParams.append('foo', 'qux'); + }); + + flushSync(() => { + url.searchParams.delete('foo'); + }); + + assert.deepEqual(log, [ + 'search: ?foo=bar&t=123', + 'foo: bar', + 'q: false', + 'search: ?q=kit&foo=baz', + 'foo: baz', + 'q: true', + 'search: ?q=kit&foo=baz&foo=qux', + 'foo: baz', + 'q: true', + 'search: ?q=kit', + 'foo: null', + 'q: true' + ]); + + cleanup(); +}); diff --git a/packages/svelte/tests/runtime-runes/samples/reactive-date/main.svelte b/packages/svelte/tests/runtime-runes/samples/reactive-date/main.svelte index 7c117bb19efb..750774641812 100644 --- a/packages/svelte/tests/runtime-runes/samples/reactive-date/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/reactive-date/main.svelte @@ -1,5 +1,5 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/reactive-url/_config.js b/packages/svelte/tests/runtime-runes/samples/reactive-url/_config.js new file mode 100644 index 000000000000..88c036fa823a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/reactive-url/_config.js @@ -0,0 +1,46 @@ +import { flushSync } from '../../../../src/index-client'; +import { test } from '../../test'; + +export default test({ + html: `
href: https://svelte.dev/repl/hello-world?version=5.0
host: svelte.dev
pathname: /repl/hello-world
search: ?version=5.0
version: 5.0
t:
`, + + test({ assert, target }) { + const [btn, btn2, btn3, btn4] = target.querySelectorAll('button'); + + flushSync(() => { + btn?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
href: https://kit.svelte.dev/repl/hello-world?version=5.0
host: kit.svelte.dev
pathname: /repl/hello-world
search: ?version=5.0
version: 5.0
t:
` + ); + + flushSync(() => { + btn2?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
href: https://kit.svelte.dev/docs/introduction?version=5.0
host: kit.svelte.dev
pathname: /docs/introduction
search: ?version=5.0
version: 5.0
t:
` + ); + + flushSync(() => { + btn3?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
href: https://kit.svelte.dev/docs/introduction?t=123
host: kit.svelte.dev
pathname: /docs/introduction
search: ?t=123
version:
t: 123
` + ); + + flushSync(() => { + btn4?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
href: https://google.com/search?version=3
host: google.com
pathname: /search
search: ?version=3
version: 3
t:
` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/reactive-url/main.svelte b/packages/svelte/tests/runtime-runes/samples/reactive-url/main.svelte new file mode 100644 index 000000000000..2116b55a405e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/reactive-url/main.svelte @@ -0,0 +1,25 @@ + + +
href: {url.href}
+
host: {url.host}
+
pathname: {url.pathname}
+
search: {url.search}
+
version: {url.searchParams.get('version')}
+
t: {url.searchParams.get('t')}
+ + + + +