From 8caaa375cf6f16e480833f6e270a664e4f5e73ab Mon Sep 17 00:00:00 2001 From: Tan Li Hau Date: Mon, 15 Apr 2024 03:35:21 +0800 Subject: [PATCH] feat: add reactive URL object to svelte/reactivity (#11157) * feat: reactive url * fix * simplify * tidy * simplify, make ReactiveURLSearchParams signature match URLSearchParams * Update .changeset/tidy-chefs-taste.md * fix * fix * regenerate types * improve minifiability --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/tidy-chefs-taste.md | 5 + .../svelte/src/reactivity/index-client.js | 1 + .../svelte/src/reactivity/index-server.js | 2 + packages/svelte/src/reactivity/url.js | 268 ++++++++++++++++++ packages/svelte/src/reactivity/url.test.ts | 101 +++++++ .../samples/reactive-url/_config.js | 46 +++ .../samples/reactive-url/main.svelte | 25 ++ packages/svelte/types/index.d.ts | 12 +- 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-chefs-taste.md create mode 100644 packages/svelte/src/reactivity/url.js create mode 100644 packages/svelte/src/reactivity/url.test.ts create mode 100644 packages/svelte/tests/runtime-runes/samples/reactive-url/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/reactive-url/main.svelte diff --git a/.changeset/tidy-chefs-taste.md b/.changeset/tidy-chefs-taste.md new file mode 100644 index 0000000000..ef87b2fa46 --- /dev/null +++ b/.changeset/tidy-chefs-taste.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: reactive `URL` and `URLSearchParams` classes diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 38c7d91885..9c0b28dd2f 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 1821bac2de..63360a30d4 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 0000000000..3f11a4af37 --- /dev/null +++ b/packages/svelte/src/reactivity/url.js @@ -0,0 +1,268 @@ +import { source, set } from '../internal/client/reactivity/sources.js'; +import { get } from '../internal/client/runtime.js'; + +const REPLACE = Symbol(); + +export class ReactiveURL extends 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); + #hash = source(super.hash); + #searchParams = new ReactiveURLSearchParams(); + + /** + * @param {string | URL} url + * @param {string | URL} [base] + */ + constructor(url, base) { + url = new URL(url, base); + super(url); + this.#searchParams[REPLACE](url.searchParams); + } + + get hash() { + return get(this.#hash); + } + + set hash(value) { + super.hash = value; + set(this.#hash, super.hash); + } + + get host() { + get(this.#hostname); + get(this.#port); + return super.host; + } + + set host(value) { + super.host = value; + set(this.#hostname, super.hostname); + set(this.#port, super.port); + } + + get hostname() { + return get(this.#hostname); + } + + set hostname(value) { + super.hostname = value; + set(this.#hostname, super.hostname); + } + + get href() { + get(this.#protocol); + get(this.#username); + get(this.#password); + get(this.#hostname); + get(this.#port); + get(this.#pathname); + get(this.#hash); + this.#searchParams.toString(); + return super.href; + } + + set href(value) { + super.href = value; + set(this.#protocol, super.protocol); + set(this.#username, super.username); + set(this.#password, super.password); + set(this.#hostname, super.hostname); + set(this.#port, super.port); + set(this.#pathname, super.pathname); + set(this.#hash, super.hash); + this.#searchParams[REPLACE](super.searchParams); + } + + get password() { + return get(this.#password); + } + + set password(value) { + super.password = value; + set(this.#password, super.password); + } + + get pathname() { + return get(this.#pathname); + } + + set pathname(value) { + super.pathname = value; + set(this.#pathname, super.pathname); + } + + get port() { + return get(this.#port); + } + + set port(value) { + super.port = value; + set(this.#port, super.port); + } + + get protocol() { + return get(this.#protocol); + } + + set protocol(value) { + super.protocol = value; + set(this.#protocol, super.protocol); + } + + get search() { + const search = this.#searchParams?.toString(); + return search ? `?${search}` : ''; + } + + set search(value) { + super.search = value; + this.#searchParams[REPLACE](new URLSearchParams(value.replace(/^\?/, ''))); + } + + get username() { + return get(this.#username); + } + + set username(value) { + super.username = value; + set(this.#username, super.username); + } + + get origin() { + get(this.#protocol); + get(this.#hostname); + get(this.#port); + return super.origin; + } + + get searchParams() { + return this.#searchParams; + } + + toString() { + return this.href; + } + + toJSON() { + return this.href; + } +} + +export class ReactiveURLSearchParams extends URLSearchParams { + #version = source(0); + + #increment_version() { + set(this.#version, this.#version.v + 1); + } + + /** + * @param {URLSearchParams} params + */ + [REPLACE](params) { + for (const key of [...super.keys()]) { + super.delete(key); + } + + for (const [key, value] of params) { + super.append(key, value); + } + + this.#increment_version(); + } + + /** + * @param {string} name + * @param {string} value + * @returns {void} + */ + append(name, value) { + this.#increment_version(); + return super.append(name, value); + } + + /** + * @param {string} name + * @param {string=} value + * @returns {void} + */ + delete(name, value) { + this.#increment_version(); + return super.delete(name, value); + } + + /** + * @param {string} name + * @returns {string|null} + */ + get(name) { + get(this.#version); + return super.get(name); + } + + /** + * @param {string} name + * @returns {string[]} + */ + getAll(name) { + get(this.#version); + return super.getAll(name); + } + + /** + * @param {string} name + * @param {string=} value + * @returns {boolean} + */ + has(name, value) { + get(this.#version); + return super.has(name, value); + } + + keys() { + get(this.#version); + return super.keys(); + } + + /** + * @param {string} name + * @param {string} value + * @returns {void} + */ + set(name, value) { + this.#increment_version(); + return super.set(name, value); + } + + sort() { + this.#increment_version(); + return super.sort(); + } + + toString() { + get(this.#version); + return super.toString(); + } + + values() { + get(this.#version); + return super.values(); + } + + entries() { + get(this.#version); + return super.entries(); + } + + [Symbol.iterator]() { + return this.entries(); + } + + get size() { + get(this.#version); + return super.size; + } +} diff --git a/packages/svelte/src/reactivity/url.test.ts b/packages/svelte/src/reactivity/url.test.ts new file mode 100644 index 0000000000..910edba502 --- /dev/null +++ b/packages/svelte/src/reactivity/url.test.ts @@ -0,0 +1,101 @@ +import { render_effect, effect_root } from '../internal/client/reactivity/effects.js'; +import { flushSync } from '../index-client.js'; +import { ReactiveURL, ReactiveURLSearchParams } 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(); +}); + +test('URLSearchParams', () => { + const params = new ReactiveURLSearchParams(); + const log: any = []; + + const cleanup = effect_root(() => { + render_effect(() => { + log.push(params.toString()); + }); + }); + + flushSync(() => { + params.set('a', 'b'); + }); + + flushSync(() => { + params.append('a', 'c'); + }); + + assert.deepEqual(log, ['', 'a=b', 'a=b&a=c']); + + cleanup(); +}); 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 0000000000..88c036fa82 --- /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 0000000000..2116b55a40 --- /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')}
+ + + + + diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 2c5be715b6..0342dc5a24 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2008,8 +2008,18 @@ declare module 'svelte/reactivity' { [Symbol.iterator](): IterableIterator<[K, V]>; #private; } + class ReactiveURL extends URL { + get searchParams(): ReactiveURLSearchParams; + #private; + } + class ReactiveURLSearchParams extends URLSearchParams { + + [REPLACE](params: URLSearchParams): void; + #private; + } + const REPLACE: unique symbol; - export { ReactiveDate as Date, ReactiveSet as Set, ReactiveMap as Map }; + export { ReactiveDate as Date, ReactiveSet as Set, ReactiveMap as Map, ReactiveURL as URL, ReactiveURLSearchParams as URLSearchParams }; } declare module 'svelte/server' {