fix: improved fine-grainability of ReactiveDate (#12110)

* fix: improve ReactiveDate class

* improved test

* added changeset

* improved performance by using less signals

* using the same signal for getX and getUTCX

* fixed types

* combined setX with setUTCX tests together

* simplified fine-grainability by using deriveds

* reused the calculated new_time

* removed logic that reduced number of signals, cuz its use-cases are rare

* inference is fine

* tidy up

* tweaks

* more efficient iteration

* tweak

* more descriptive changeset

* this makes all tests but one pass

* fix

* adjust test to be robust across timezones

* tweak more tests

* Update .changeset/sleepy-dogs-sit.md

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
Co-authored-by: Rich Harris <hello@rich-harris.dev>
pull/12283/head
FoHoOV 6 months ago committed by GitHub
parent 730e179772
commit e5d70c3b97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: lazily create a derived for each read method on `SvelteDate.prototype`

@ -1138,7 +1138,7 @@ export function deep_read(value, visited = new Set()) {
!visited.has(value) !visited.has(value)
) { ) {
visited.add(value); visited.add(value);
// When working with a possible ReactiveDate, this // When working with a possible SvelteDate, this
// will ensure we capture changes to it. // will ensure we capture changes to it.
if (value instanceof Date) { if (value instanceof Date) {
value.getTime(); value.getTime();

@ -1,102 +1,62 @@
/** @import { Source } from '#client' */
import { derived } from '../internal/client/index.js';
import { source, set } from '../internal/client/reactivity/sources.js'; import { source, set } from '../internal/client/reactivity/sources.js';
import { get } from '../internal/client/runtime.js'; import { get } from '../internal/client/runtime.js';
/** @type {Array<keyof Date>} */
const read = [
'getDate',
'getDay',
'getFullYear',
'getHours',
'getMilliseconds',
'getMinutes',
'getMonth',
'getSeconds',
'getTime',
'getTimezoneOffset',
'getUTCDate',
'getUTCDay',
'getUTCFullYear',
'getUTCHours',
'getUTCMilliseconds',
'getUTCMinutes',
'getUTCMonth',
'getUTCSeconds',
// @ts-expect-error this is deprecated
'getYear',
'toDateString',
'toISOString',
'toJSON',
'toLocaleDateString',
'toLocaleString',
'toLocaleTimeString',
'toString',
'toTimeString',
'toUTCString'
];
/** @type {Array<keyof Date>} */
const write = [
'setDate',
'setFullYear',
'setHours',
'setMilliseconds',
'setMinutes',
'setMonth',
'setSeconds',
'setTime',
'setUTCDate',
'setUTCFullYear',
'setUTCHours',
'setUTCMilliseconds',
'setUTCMinutes',
'setUTCMonth',
'setUTCSeconds',
// @ts-expect-error this is deprecated
'setYear'
];
var inited = false; var inited = false;
export class SvelteDate extends Date { export class SvelteDate extends Date {
#raw_time = source(super.getTime()); #time = source(super.getTime());
/** @type {Map<keyof Date, Source<unknown>>} */
#deriveds = new Map();
/** @param {any[]} params */
constructor(...params) {
// @ts-ignore
super(...params);
if (!inited) this.#init();
}
// We init as part of the first instance so that we can treeshake this class
#init() { #init() {
if (!inited) {
inited = true; inited = true;
const proto = SvelteDate.prototype;
const date_proto = Date.prototype;
for (const method of read) { var proto = SvelteDate.prototype;
var date_proto = Date.prototype;
var methods = /** @type {Array<keyof Date & string>} */ (
Object.getOwnPropertyNames(date_proto)
);
for (const method of methods) {
if (method.startsWith('get') || method.startsWith('to')) {
// @ts-ignore // @ts-ignore
proto[method] = function (...args) { proto[method] = function (...args) {
get(this.#raw_time); var d = this.#deriveds.get(method);
if (d === undefined) {
d = derived(() => {
get(this.#time);
// @ts-ignore // @ts-ignore
return date_proto[method].apply(this, args); return date_proto[method].apply(this, args);
});
this.#deriveds.set(method, d);
}
return get(d);
}; };
} }
for (const method of write) { if (method.startsWith('set')) {
// @ts-ignore // @ts-ignore
proto[method] = function (...args) { proto[method] = function (...args) {
// @ts-ignore // @ts-ignore
const v = date_proto[method].apply(this, args); var result = date_proto[method].apply(this, args);
const time = date_proto.getTime.call(this); set(this.#time, date_proto.getTime.call(this));
if (time !== this.#raw_time.v) { return result;
set(this.#raw_time, time);
}
return v;
}; };
} }
} }
} }
/**
* @param {any[]} values
*/
constructor(...values) {
// @ts-ignore
super(...values);
this.#init();
}
} }

@ -0,0 +1,560 @@
import { render_effect, effect_root } from '../internal/client/reactivity/effects.js';
import { flushSync } from '../index-client.js';
import { SvelteDate } from './date.js';
import { assert, test } from 'vitest';
const initial_date = new Date(2023, 0, 2, 0, 0, 0, 0);
const a = new Date(2024, 1, 3, 1, 1, 1, 1);
const b = new Date(2025, 2, 4, 2, 2, 2, 2);
const c = new Date(2026, 3, 5, 3, 3, 3, 3);
test('date.setDate and date.setUTCDate', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getDate());
});
render_effect(() => {
log.push(date.getUTCDate());
});
});
flushSync(() => {
date.setDate(a.getDate());
});
flushSync(() => {
date.setDate(date.getDate() + 1);
});
flushSync(() => {
date.setDate(date.getDate()); // no change expected
});
flushSync(() => {
date.setUTCDate(date.getUTCDate() + 1);
});
assert.deepEqual(log, [
initial_date.getDate(),
initial_date.getUTCDate(),
a.getDate(),
a.getUTCDate(),
a.getDate() + 1,
a.getUTCDate() + 1,
a.getDate() + 2,
a.getUTCDate() + 2
]);
cleanup();
});
test('date.setFullYear and date.setUTCFullYear', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getFullYear());
});
render_effect(() => {
log.push(date.getUTCFullYear());
});
});
flushSync(() => {
date.setFullYear(a.getFullYear());
});
flushSync(() => {
date.setFullYear(b.getFullYear());
});
flushSync(() => {
date.setFullYear(b.getFullYear()); // no change expected
});
flushSync(() => {
date.setUTCFullYear(c.getUTCFullYear());
});
assert.deepEqual(log, [
initial_date.getFullYear(),
initial_date.getUTCFullYear(),
a.getFullYear(),
a.getUTCFullYear(),
b.getFullYear(),
b.getUTCFullYear(),
c.getFullYear(),
c.getUTCFullYear()
]);
cleanup();
});
test('date.setHours and date.setUTCHours', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getHours() % 24);
});
render_effect(() => {
log.push(date.getUTCHours() % 24);
});
});
flushSync(() => {
date.setHours(a.getHours());
});
flushSync(() => {
date.setHours(date.getHours() + 1);
});
flushSync(() => {
date.setHours(date.getHours()); // no change expected
});
flushSync(() => {
date.setUTCHours(date.getUTCHours() + 1);
});
assert.deepEqual(log, [
initial_date.getHours(),
initial_date.getUTCHours(),
a.getHours() % 24,
a.getUTCHours() % 24,
(a.getHours() + 1) % 24,
(a.getUTCHours() + 1) % 24,
(a.getHours() + 2) % 24,
(a.getUTCHours() + 2) % 24
]);
cleanup();
});
test('date.setMilliseconds and date.setUTCMilliseconds', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getMilliseconds());
});
render_effect(() => {
log.push(date.getUTCMilliseconds());
});
});
flushSync(() => {
date.setMilliseconds(a.getMilliseconds());
});
flushSync(() => {
date.setMilliseconds(b.getMilliseconds());
});
flushSync(() => {
date.setMilliseconds(b.getMilliseconds()); // no change expected
});
flushSync(() => {
date.setUTCMilliseconds(c.getUTCMilliseconds());
});
assert.deepEqual(log, [
initial_date.getMilliseconds(),
initial_date.getUTCMilliseconds(),
a.getMilliseconds(),
a.getUTCMilliseconds(),
b.getMilliseconds(),
b.getUTCMilliseconds(),
c.getMilliseconds(),
c.getUTCMilliseconds()
]);
cleanup();
});
test('date.setMinutes and date.setUTCMinutes', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getMinutes());
});
render_effect(() => {
log.push(date.getUTCMinutes());
});
});
flushSync(() => {
date.setMinutes(a.getMinutes());
});
flushSync(() => {
date.setMinutes(b.getMinutes());
});
flushSync(() => {
date.setMinutes(b.getMinutes()); // no change expected
});
flushSync(() => {
date.setUTCMinutes(c.getUTCMinutes());
});
assert.deepEqual(log, [
initial_date.getMinutes(),
initial_date.getUTCMinutes(),
a.getMinutes(),
a.getUTCMinutes(),
b.getMinutes(),
b.getUTCMinutes(),
c.getMinutes(),
c.getUTCMinutes()
]);
cleanup();
});
test('date.setMonth and date.setUTCMonth', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getMonth());
});
render_effect(() => {
log.push(date.getUTCMonth());
});
});
flushSync(() => {
date.setMonth(a.getMonth());
});
flushSync(() => {
date.setMonth(b.getMonth());
});
flushSync(() => {
date.setMonth(b.getMonth()); // no change expected
});
flushSync(() => {
date.setUTCMonth(c.getUTCMonth());
});
assert.deepEqual(log, [
initial_date.getMonth(),
initial_date.getUTCMonth(),
a.getMonth(),
a.getUTCMonth(),
b.getMonth(),
b.getUTCMonth(),
c.getMonth(),
c.getUTCMonth()
]);
cleanup();
});
test('date.setSeconds and date.setUTCSeconds', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getSeconds());
});
render_effect(() => {
log.push(date.getUTCSeconds());
});
});
flushSync(() => {
date.setSeconds(a.getSeconds());
});
flushSync(() => {
date.setSeconds(b.getSeconds());
});
flushSync(() => {
date.setSeconds(b.getSeconds()); // no change expected
});
flushSync(() => {
date.setUTCSeconds(c.getUTCSeconds());
});
assert.deepEqual(log, [
initial_date.getSeconds(),
initial_date.getUTCSeconds(),
a.getSeconds(),
a.getUTCSeconds(),
b.getSeconds(),
b.getUTCSeconds(),
c.getSeconds(),
c.getUTCSeconds()
]);
cleanup();
});
test('date.setTime', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getTime());
});
});
flushSync(() => {
date.setTime(a.getTime());
});
flushSync(() => {
date.setTime(b.getTime());
});
flushSync(() => {
// nothing should happen here
date.setTime(b.getTime());
});
assert.deepEqual(log, [initial_date.getTime(), a.getTime(), b.getTime()]);
cleanup();
});
test('date.setYear', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
// @ts-expect-error
if (!date.setYear) {
return;
}
const cleanup = effect_root(() => {
render_effect(() => {
// @ts-expect-error
log.push(date.getYear());
});
});
flushSync(() => {
// @ts-expect-error
date.setYear(22);
});
flushSync(() => {
// @ts-expect-error
date.setYear(23);
});
flushSync(() => {
// nothing should happen here
// @ts-expect-error
date.setYear(23);
});
// @ts-expect-error
assert.deepEqual(log, [initial_date.getYear(), 22, 23]);
cleanup();
});
test('date.setSeconds - edge cases', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getSeconds());
});
render_effect(() => {
log.push(date.getMinutes());
});
});
flushSync(() => {
date.setSeconds(60);
});
flushSync(() => {
date.setSeconds(61);
});
assert.deepEqual(log, [
initial_date.getSeconds(),
initial_date.getMinutes(),
initial_date.getMinutes() + 1,
initial_date.getSeconds() + 1,
initial_date.getMinutes() + 2
]);
cleanup();
});
test('Date propagated changes', () => {
const date = new SvelteDate(initial_date);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(date.getSeconds());
});
render_effect(() => {
log.push(date.getMonth());
});
render_effect(() => {
log.push(date.getFullYear());
});
});
flushSync(() => {
date.setMonth(13);
});
assert.deepEqual(log, [
initial_date.getSeconds(),
initial_date.getMonth(),
initial_date.getFullYear(),
1,
2024
]);
cleanup();
});
test('Date fine grained tests', () => {
const date = new SvelteDate(initial_date);
let changes: Record<string, boolean> = {
getFullYear: true,
getUTCFullYear: true,
getMonth: true,
getUTCMonth: true,
getDate: true,
getUTCDate: true,
getDay: true,
getUTCDay: true,
getHours: true,
getUTCHours: true,
getMinutes: true,
getUTCMinutes: true,
getSeconds: true,
getUTCSeconds: true,
getMilliseconds: true,
getUTCMilliseconds: true,
getTime: true,
toISOString: true,
toJSON: true,
toUTCString: true,
toString: true,
toLocaleString: true
};
let test_description: string = '';
const expect_all_changes_to_be_false = () => {
for (const key of Object.keys(changes) as Array<keyof typeof Date>) {
assert.equal(changes[key], false, `${test_description}: effect for ${key} was not fired`);
}
};
const cleanup = effect_root(() => {
for (const key of Object.keys(changes)) {
render_effect(() => {
// @ts-ignore
date[key]();
assert.equal(changes[key], true, `${test_description}: for ${key}`);
changes[key] = false;
});
}
});
flushSync(() => {
expect_all_changes_to_be_false();
changes = {
...changes,
getFullYear: true,
getUTCFullYear: true,
getMonth: true,
getUTCMonth: true,
getDay: true,
getUTCDay: true,
getTime: true,
toISOString: true,
toJSON: true,
toUTCString: true,
toString: true,
toLocaleString: true
};
test_description = 'changing setFullYear that will cause month/day change as well';
date.setFullYear(initial_date.getFullYear() + 1, initial_date.getMonth() + 1);
});
flushSync(() => {
expect_all_changes_to_be_false();
changes = {
...changes,
getDate: true,
getUTCDate: true,
getDay: true,
getUTCDay: true,
getHours: true,
getUTCHours: true,
getMinutes: true,
getUTCMinutes: true,
getSeconds: true,
getUTCSeconds: true,
getMilliseconds: true,
getUTCMilliseconds: true,
getTime: true,
toISOString: true,
toJSON: true,
toUTCString: true,
toString: true,
toLocaleString: true
};
test_description = 'changing seconds that will change day/hour/minutes/seconds/milliseconds';
date.setSeconds(61 * 60 * 25 + 1, 10);
});
flushSync(() => {
expect_all_changes_to_be_false();
changes = {
...changes,
getMonth: true,
getUTCMonth: true,
getDay: true,
getUTCDay: true,
getMilliseconds: true,
getUTCMilliseconds: true,
getTime: true,
toISOString: true,
toJSON: true,
toUTCString: true,
toString: true,
toLocaleString: true
};
test_description = 'changing month';
date.setMonth(date.getMonth() + 1);
});
cleanup();
});
test('Date.instanceOf', () => {
assert.equal(new SvelteDate() instanceof Date, true);
});

@ -2136,7 +2136,7 @@ declare module 'svelte/reactivity' {
function URLSearchParams_1(): void; function URLSearchParams_1(): void;
export class SvelteDate extends Date { export class SvelteDate extends Date {
constructor(...values: any[]); constructor(...params: any[]);
#private; #private;
} }
export class SvelteSet<T> extends Set<T> { export class SvelteSet<T> extends Set<T> {

Loading…
Cancel
Save