feat: omit unnecessary setters from bindings (#13269)

* remove unnecessary closure

* lint

* reuse logic

* feat: omit unnecessary setters in bindings

* changeset

---------

Co-authored-by: adiguba <frederic.martini@gmail.com>
pull/13275/head
Rich Harris 4 days ago committed by GitHub
parent c1d8eb375d
commit 836bc605f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: unwrap function expressions where possible, and optimise bindings

@ -36,13 +36,20 @@ export function BindDirective(node, context) {
);
}
const getter = b.thunk(/** @type {Expression} */ (context.visit(expression)));
const setter = b.arrow(
[b.id('$$value')],
/** @type {Expression} */ (context.visit(b.assignment('=', expression, b.id('$$value'))))
const get = b.thunk(/** @type {Expression} */ (context.visit(expression)));
/** @type {Expression | undefined} */
let set = b.unthunk(
b.arrow(
[b.id('$$value')],
/** @type {Expression} */ (context.visit(b.assignment('=', expression, b.id('$$value'))))
)
);
if (get === set) {
set = undefined;
}
/** @type {CallExpression} */
let call;
@ -52,15 +59,15 @@ export function BindDirective(node, context) {
b.literal(node.name),
b.literal(property.event),
context.state.node,
setter,
property.bidirectional && getter
set ?? get,
property.bidirectional && get
);
} else {
// special cases
switch (node.name) {
// window
case 'online':
call = b.call(`$.bind_online`, setter);
call = b.call(`$.bind_online`, set ?? get);
break;
case 'scrollX':
@ -68,8 +75,8 @@ export function BindDirective(node, context) {
call = b.call(
'$.bind_window_scroll',
b.literal(node.name === 'scrollX' ? 'x' : 'y'),
getter,
setter
get,
set
);
break;
@ -77,47 +84,47 @@ export function BindDirective(node, context) {
case 'innerHeight':
case 'outerWidth':
case 'outerHeight':
call = b.call('$.bind_window_size', b.literal(node.name), setter);
call = b.call('$.bind_window_size', b.literal(node.name), set ?? get);
break;
// document
case 'activeElement':
call = b.call('$.bind_active_element', setter);
call = b.call('$.bind_active_element', set ?? get);
break;
// media
case 'muted':
call = b.call(`$.bind_muted`, context.state.node, getter, setter);
call = b.call(`$.bind_muted`, context.state.node, get, set);
break;
case 'paused':
call = b.call(`$.bind_paused`, context.state.node, getter, setter);
call = b.call(`$.bind_paused`, context.state.node, get, set);
break;
case 'volume':
call = b.call(`$.bind_volume`, context.state.node, getter, setter);
call = b.call(`$.bind_volume`, context.state.node, get, set);
break;
case 'playbackRate':
call = b.call(`$.bind_playback_rate`, context.state.node, getter, setter);
call = b.call(`$.bind_playback_rate`, context.state.node, get, set);
break;
case 'currentTime':
call = b.call(`$.bind_current_time`, context.state.node, getter, setter);
call = b.call(`$.bind_current_time`, context.state.node, get, set);
break;
case 'buffered':
call = b.call(`$.bind_buffered`, context.state.node, setter);
call = b.call(`$.bind_buffered`, context.state.node, set ?? get);
break;
case 'played':
call = b.call(`$.bind_played`, context.state.node, setter);
call = b.call(`$.bind_played`, context.state.node, set ?? get);
break;
case 'seekable':
call = b.call(`$.bind_seekable`, context.state.node, setter);
call = b.call(`$.bind_seekable`, context.state.node, set ?? get);
break;
case 'seeking':
call = b.call(`$.bind_seeking`, context.state.node, setter);
call = b.call(`$.bind_seeking`, context.state.node, set ?? get);
break;
case 'ended':
call = b.call(`$.bind_ended`, context.state.node, setter);
call = b.call(`$.bind_ended`, context.state.node, set ?? get);
break;
case 'readyState':
call = b.call(`$.bind_ready_state`, context.state.node, setter);
call = b.call(`$.bind_ready_state`, context.state.node, set ?? get);
break;
// dimensions
@ -125,28 +132,33 @@ export function BindDirective(node, context) {
case 'contentBoxSize':
case 'borderBoxSize':
case 'devicePixelContentBoxSize':
call = b.call('$.bind_resize_observer', context.state.node, b.literal(node.name), setter);
call = b.call(
'$.bind_resize_observer',
context.state.node,
b.literal(node.name),
set ?? get
);
break;
case 'clientWidth':
case 'clientHeight':
case 'offsetWidth':
case 'offsetHeight':
call = b.call('$.bind_element_size', context.state.node, b.literal(node.name), setter);
call = b.call('$.bind_element_size', context.state.node, b.literal(node.name), set ?? get);
break;
// various
case 'value': {
if (parent?.type === 'RegularElement' && parent.name === 'select') {
call = b.call(`$.bind_select_value`, context.state.node, getter, setter);
call = b.call(`$.bind_select_value`, context.state.node, get, set);
} else {
call = b.call(`$.bind_value`, context.state.node, getter, setter);
call = b.call(`$.bind_value`, context.state.node, get, set);
}
break;
}
case 'files':
call = b.call(`$.bind_files`, context.state.node, getter, setter);
call = b.call(`$.bind_files`, context.state.node, get, set);
break;
case 'this':
@ -160,18 +172,18 @@ export function BindDirective(node, context) {
'$.bind_content_editable',
b.literal(node.name),
context.state.node,
getter,
setter
get,
set
);
break;
// checkbox/radio
case 'checked':
call = b.call(`$.bind_checked`, context.state.node, getter, setter);
call = b.call(`$.bind_checked`, context.state.node, get, set);
break;
case 'focused':
call = b.call(`$.bind_focused`, context.state.node, setter);
call = b.call(`$.bind_focused`, context.state.node, set ?? get);
break;
case 'group': {
@ -184,7 +196,7 @@ export function BindDirective(node, context) {
// We need to additionally invoke the value attribute signal to register it as a dependency,
// so that when the value is updated, the group binding is updated
let group_getter = getter;
let group_getter = get;
if (parent?.type === 'RegularElement') {
const value = /** @type {any[]} */ (
@ -215,7 +227,7 @@ export function BindDirective(node, context) {
b.array(indexes),
context.state.node,
group_getter,
setter
set ?? get
);
break;
}

@ -419,19 +419,31 @@ export function template(elements, expressions) {
* @returns {ESTree.Expression}
*/
export function thunk(expression, async = false) {
const fn = arrow([], expression);
if (async) fn.async = true;
return unthunk(fn);
}
/**
* Replace "(arg) => func(arg)" to "func"
* @param {ESTree.Expression} expression
* @returns {ESTree.Expression}
*/
export function unthunk(expression) {
if (
expression.type === 'CallExpression' &&
expression.callee.type !== 'Super' &&
expression.callee.type !== 'MemberExpression' &&
expression.callee.type !== 'CallExpression' &&
expression.arguments.length === 0
expression.type === 'ArrowFunctionExpression' &&
expression.async === false &&
expression.body.type === 'CallExpression' &&
expression.body.callee.type === 'Identifier' &&
expression.params.length === expression.body.arguments.length &&
expression.params.every((param, index) => {
const arg = /** @type {ESTree.SimpleCallExpression} */ (expression.body).arguments[index];
return param.type === 'Identifier' && arg.type === 'Identifier' && param.name === arg.name;
})
) {
return expression.callee;
return expression.body.callee;
}
const fn = arrow([], expression);
if (async) fn.async = true;
return fn;
return expression;
}
/**

@ -8,18 +8,18 @@ import { hydrating } from '../../hydration.js';
/**
* @param {HTMLInputElement} input
* @param {() => unknown} get_value
* @param {(value: unknown) => void} update
* @param {() => unknown} get
* @param {(value: unknown) => void} set
* @returns {void}
*/
export function bind_value(input, get_value, update) {
export function bind_value(input, get, set = get) {
listen_to_event_and_reset_event(input, 'input', () => {
if (DEV && input.type === 'checkbox') {
// TODO should this happen in prod too?
e.bind_invalid_checkbox_value();
}
update(is_numberlike_input(input) ? to_number(input.value) : input.value);
set(is_numberlike_input(input) ? to_number(input.value) : input.value);
});
render_effect(() => {
@ -28,12 +28,12 @@ export function bind_value(input, get_value, update) {
e.bind_invalid_checkbox_value();
}
var value = get_value();
var value = get();
// If we are hydrating and the value has since changed, then use the update value
// from the input instead.
if (hydrating && input.defaultValue !== input.value) {
update(input.value);
set(input.value);
return;
}
@ -60,11 +60,11 @@ const pending = new Set();
* @param {HTMLInputElement[]} inputs
* @param {null | [number]} group_index
* @param {HTMLInputElement} input
* @param {() => unknown} get_value
* @param {(value: unknown) => void} update
* @param {() => unknown} get
* @param {(value: unknown) => void} set
* @returns {void}
*/
export function bind_group(inputs, group_index, input, get_value, update) {
export function bind_group(inputs, group_index, input, get, set = get) {
var is_checkbox = input.getAttribute('type') === 'checkbox';
var binding_group = inputs;
@ -91,14 +91,14 @@ export function bind_group(inputs, group_index, input, get_value, update) {
value = get_binding_group_value(binding_group, value, input.checked);
}
update(value);
set(value);
},
// TODO better default value handling
() => update(is_checkbox ? [] : null)
() => set(is_checkbox ? [] : null)
);
render_effect(() => {
var value = get_value();
var value = get();
// If we are hydrating and the value has since changed, then use the update value
// from the input instead.
@ -147,29 +147,29 @@ export function bind_group(inputs, group_index, input, get_value, update) {
value = hydration_input?.__value;
}
update(value);
set(value);
}
});
}
/**
* @param {HTMLInputElement} input
* @param {() => unknown} get_value
* @param {(value: unknown) => void} update
* @param {() => unknown} get
* @param {(value: unknown) => void} set
* @returns {void}
*/
export function bind_checked(input, get_value, update) {
export function bind_checked(input, get, set = get) {
listen_to_event_and_reset_event(input, 'change', () => {
var value = input.checked;
update(value);
set(value);
});
if (get_value() == undefined) {
update(false);
if (get() == undefined) {
set(false);
}
render_effect(() => {
var value = get_value();
var value = get();
input.checked = Boolean(value);
});
}
@ -215,15 +215,15 @@ function to_number(value) {
/**
* @param {HTMLInputElement} input
* @param {() => FileList | null} get_value
* @param {(value: FileList | null) => void} update
* @param {() => FileList | null} get
* @param {(value: FileList | null) => void} set
*/
export function bind_files(input, get_value, update) {
export function bind_files(input, get, set = get) {
listen_to_event_and_reset_event(input, 'change', () => {
update(input.files);
set(input.files);
});
render_effect(() => {
input.files = get_value();
input.files = get();
});
}

@ -15,11 +15,11 @@ function time_ranges_to_array(ranges) {
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {() => number | undefined} get_value
* @param {(value: number) => void} update
* @param {() => number | undefined} get
* @param {(value: number) => void} set
* @returns {void}
*/
export function bind_current_time(media, get_value, update) {
export function bind_current_time(media, get, set = get) {
/** @type {number} */
var raf_id;
/** @type {number} */
@ -37,7 +37,7 @@ export function bind_current_time(media, get_value, update) {
var next_value = media.currentTime;
if (value !== next_value) {
update((value = next_value));
set((value = next_value));
}
};
@ -45,7 +45,7 @@ export function bind_current_time(media, get_value, update) {
media.addEventListener('timeupdate', callback);
render_effect(() => {
var next_value = Number(get_value());
var next_value = Number(get());
if (value !== next_value && !isNaN(/** @type {any} */ (next_value))) {
media.currentTime = value = next_value;
@ -57,66 +57,66 @@ export function bind_current_time(media, get_value, update) {
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {(array: Array<{ start: number; end: number }>) => void} update
* @param {(array: Array<{ start: number; end: number }>) => void} set
*/
export function bind_buffered(media, update) {
listen(media, ['loadedmetadata', 'progress'], () => update(time_ranges_to_array(media.buffered)));
export function bind_buffered(media, set) {
listen(media, ['loadedmetadata', 'progress'], () => set(time_ranges_to_array(media.buffered)));
}
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {(array: Array<{ start: number; end: number }>) => void} update
* @param {(array: Array<{ start: number; end: number }>) => void} set
*/
export function bind_seekable(media, update) {
listen(media, ['loadedmetadata'], () => update(time_ranges_to_array(media.seekable)));
export function bind_seekable(media, set) {
listen(media, ['loadedmetadata'], () => set(time_ranges_to_array(media.seekable)));
}
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {(array: Array<{ start: number; end: number }>) => void} update
* @param {(array: Array<{ start: number; end: number }>) => void} set
*/
export function bind_played(media, update) {
listen(media, ['timeupdate'], () => update(time_ranges_to_array(media.played)));
export function bind_played(media, set) {
listen(media, ['timeupdate'], () => set(time_ranges_to_array(media.played)));
}
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {(seeking: boolean) => void} update
* @param {(seeking: boolean) => void} set
*/
export function bind_seeking(media, update) {
listen(media, ['seeking', 'seeked'], () => update(media.seeking));
export function bind_seeking(media, set) {
listen(media, ['seeking', 'seeked'], () => set(media.seeking));
}
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {(seeking: boolean) => void} update
* @param {(seeking: boolean) => void} set
*/
export function bind_ended(media, update) {
listen(media, ['timeupdate', 'ended'], () => update(media.ended));
export function bind_ended(media, set) {
listen(media, ['timeupdate', 'ended'], () => set(media.ended));
}
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {(ready_state: number) => void} update
* @param {(ready_state: number) => void} set
*/
export function bind_ready_state(media, update) {
export function bind_ready_state(media, set) {
listen(
media,
['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'playing', 'waiting', 'emptied'],
() => update(media.readyState)
() => set(media.readyState)
);
}
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {() => number | undefined} get_value
* @param {(playback_rate: number) => void} update
* @param {() => number | undefined} get
* @param {(playback_rate: number) => void} set
*/
export function bind_playback_rate(media, get_value, update) {
export function bind_playback_rate(media, get, set = get) {
// Needs to happen after element is inserted into the dom (which is guaranteed by using effect),
// else playback will be set back to 1 by the browser
effect(() => {
var value = Number(get_value());
var value = Number(get());
if (value !== media.playbackRate && !isNaN(value)) {
media.playbackRate = value;
@ -127,24 +127,24 @@ export function bind_playback_rate(media, get_value, update) {
// else playback will be set to 1 by the browser
effect(() => {
listen(media, ['ratechange'], () => {
update(media.playbackRate);
set(media.playbackRate);
});
});
}
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {() => boolean | undefined} get_value
* @param {(paused: boolean) => void} update
* @param {() => boolean | undefined} get
* @param {(paused: boolean) => void} set
*/
export function bind_paused(media, get_value, update) {
export function bind_paused(media, get, set = get) {
var mounted = hydrating;
var paused = get_value();
var paused = get();
var callback = () => {
if (paused !== media.paused) {
paused = media.paused;
update((paused = media.paused));
set((paused = media.paused));
}
};
@ -160,7 +160,7 @@ export function bind_paused(media, get_value, update) {
}
render_effect(() => {
paused = !!get_value();
paused = !!get();
if (paused !== media.paused) {
var toggle = () => {
@ -169,7 +169,7 @@ export function bind_paused(media, get_value, update) {
media.pause();
} else {
media.play().catch(() => {
update((paused = true));
set((paused = true));
});
}
};
@ -195,22 +195,22 @@ export function bind_paused(media, get_value, update) {
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {() => number | undefined} get_value
* @param {(volume: number) => void} update
* @param {() => number | undefined} get
* @param {(volume: number) => void} set
*/
export function bind_volume(media, get_value, update) {
export function bind_volume(media, get, set = get) {
var callback = () => {
update(media.volume);
set(media.volume);
};
if (get_value() == null) {
if (get() == null) {
callback();
}
listen(media, ['volumechange'], callback, false);
render_effect(() => {
var value = Number(get_value());
var value = Number(get());
if (value !== media.volume && !isNaN(value)) {
media.volume = value;
@ -220,22 +220,22 @@ export function bind_volume(media, get_value, update) {
/**
* @param {HTMLVideoElement | HTMLAudioElement} media
* @param {() => boolean | undefined} get_value
* @param {(muted: boolean) => void} update
* @param {() => boolean | undefined} get
* @param {(muted: boolean) => void} set
*/
export function bind_muted(media, get_value, update) {
export function bind_muted(media, get, set = get) {
var callback = () => {
update(media.muted);
set(media.muted);
};
if (get_value() == null) {
if (get() == null) {
callback();
}
listen(media, ['volumechange'], callback, false);
render_effect(() => {
var value = !!get_value();
var value = !!get();
if (media.muted !== value) media.muted = value;
});

@ -73,11 +73,11 @@ export function init_select(select, get_value) {
/**
* @param {HTMLSelectElement} select
* @param {() => unknown} get_value
* @param {(value: unknown) => void} update
* @param {() => unknown} get
* @param {(value: unknown) => void} set
* @returns {void}
*/
export function bind_select_value(select, get_value, update) {
export function bind_select_value(select, get, set = get) {
var mounting = true;
listen_to_event_and_reset_event(select, 'change', () => {
@ -92,12 +92,12 @@ export function bind_select_value(select, get_value, update) {
value = selected_option && get_option_value(selected_option);
}
update(value);
set(value);
});
// Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated
effect(() => {
var value = get_value();
var value = get();
select_option(select, value, mounting);
// Mounting and value undefined -> take selection from dom
@ -106,7 +106,7 @@ export function bind_select_value(select, get_value, update) {
var selected_option = select.querySelector(':checked');
if (selected_option !== null) {
value = get_option_value(selected_option);
update(value);
set(value);
}
}

@ -78,9 +78,9 @@ var resize_observer_device_pixel_content_box = /* @__PURE__ */ new ResizeObserve
/**
* @param {Element} element
* @param {'contentRect' | 'contentBoxSize' | 'borderBoxSize' | 'devicePixelContentBoxSize'} type
* @param {(entry: keyof ResizeObserverEntry) => void} update
* @param {(entry: keyof ResizeObserverEntry) => void} set
*/
export function bind_resize_observer(element, type, update) {
export function bind_resize_observer(element, type, set) {
var observer =
type === 'contentRect' || type === 'contentBoxSize'
? resize_observer_content_box
@ -88,21 +88,21 @@ export function bind_resize_observer(element, type, update) {
? resize_observer_border_box
: resize_observer_device_pixel_content_box;
var unsub = observer.observe(element, /** @param {any} entry */ (entry) => update(entry[type]));
var unsub = observer.observe(element, /** @param {any} entry */ (entry) => set(entry[type]));
teardown(unsub);
}
/**
* @param {HTMLElement} element
* @param {'clientWidth' | 'clientHeight' | 'offsetWidth' | 'offsetHeight'} type
* @param {(size: number) => void} update
* @param {(size: number) => void} set
*/
export function bind_element_size(element, type, update) {
var unsub = resize_observer_border_box.observe(element, () => update(element[type]));
export function bind_element_size(element, type, set) {
var unsub = resize_observer_border_box.observe(element, () => set(element[type]));
effect(() => {
// The update could contain reads which should be ignored
untrack(() => update(element[type]));
untrack(() => set(element[type]));
return unsub;
});
}

@ -4,24 +4,24 @@ import { listen } from './shared.js';
/**
* @param {'innerHTML' | 'textContent' | 'innerText'} property
* @param {HTMLElement} element
* @param {() => unknown} get_value
* @param {(value: unknown) => void} update
* @param {() => unknown} get
* @param {(value: unknown) => void} set
* @returns {void}
*/
export function bind_content_editable(property, element, get_value, update) {
export function bind_content_editable(property, element, get, set = get) {
element.addEventListener('input', () => {
// @ts-ignore
update(element[property]);
set(element[property]);
});
render_effect(() => {
var value = get_value();
var value = get();
if (element[property] !== value) {
if (value == null) {
// @ts-ignore
var non_null_value = element[property];
update(non_null_value);
set(non_null_value);
} else {
// @ts-ignore
element[property] = value + '';
@ -65,11 +65,11 @@ export function bind_property(property, event_name, element, set, get) {
/**
* @param {HTMLElement} element
* @param {(value: unknown) => void} update
* @param {(value: unknown) => void} set
* @returns {void}
*/
export function bind_focused(element, update) {
export function bind_focused(element, set) {
listen(element, ['focus', 'blur'], () => {
update(element === document.activeElement);
set(element === document.activeElement);
});
}

@ -3,11 +3,11 @@ import { listen } from './shared.js';
/**
* @param {'x' | 'y'} type
* @param {() => number} get_value
* @param {(value: number) => void} update
* @param {() => number} get
* @param {(value: number) => void} set
* @returns {void}
*/
export function bind_window_scroll(type, get_value, update) {
export function bind_window_scroll(type, get, set = get) {
var is_scrolling_x = type === 'x';
var target_handler = () => {
@ -15,7 +15,7 @@ export function bind_window_scroll(type, get_value, update) {
clearTimeout(timeout);
timeout = setTimeout(clear, 100); // TODO use scrollend event if supported (or when supported everywhere?)
update(window[is_scrolling_x ? 'scrollX' : 'scrollY']);
set(window[is_scrolling_x ? 'scrollX' : 'scrollY']);
};
addEventListener('scroll', target_handler, {
@ -32,7 +32,7 @@ export function bind_window_scroll(type, get_value, update) {
var first = true;
render_effect(() => {
var latest_value = get_value();
var latest_value = get();
// Don't scroll to the initial value for accessibility reasons
if (first) {
first = false;
@ -58,8 +58,8 @@ export function bind_window_scroll(type, get_value, update) {
/**
* @param {'innerWidth' | 'innerHeight' | 'outerWidth' | 'outerHeight'} type
* @param {(size: number) => void} update
* @param {(size: number) => void} set
*/
export function bind_window_size(type, update) {
listen(window, ['resize'], () => update(window[type]));
export function bind_window_size(type, set) {
listen(window, ['resize'], () => set(window[type]));
}

Loading…
Cancel
Save