diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index fce64b947..2961ef4eb 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -14,6 +14,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis - Fixed a bug in `sl-select` where no selection with `multiple` resulted in an incorrect value [#457](https://github.com/shoelace-style/shoelace/issues/457) - Fixed a bug in `sl-select` where `sl-change` was emitted immediately after connecting to the DOM [#458](https://github.com/shoelace-style/shoelace/issues/458) - Fixed a bug in `sl-select` where non-printable keys would cause the menu to open +- Reworked the `@watch` decorator to use `update` instead of `updated` resulting in better performance and flexibility ## 2.0.0-beta.43 diff --git a/src/components/alert/alert.ts b/src/components/alert/alert.ts index 08d8531ec..14b635028 100644 --- a/src/components/alert/alert.ts +++ b/src/components/alert/alert.ts @@ -33,7 +33,6 @@ export default class SlAlert extends LitElement { static styles = unsafeCSS(styles); private autoHideTimeout: any; - private hasInitialized = false; @query('[part="base"]') base: HTMLElement; @@ -67,9 +66,6 @@ export default class SlAlert extends LitElement { firstUpdated() { // Set initial visibility this.base.hidden = !this.open; - - // Set the initialized flag after the first render is complete - this.updateComplete.then(() => (this.hasInitialized = true)); } /** Shows the alert. */ @@ -142,12 +138,8 @@ export default class SlAlert extends LitElement { this.restartAutoHide(); } - @watch('open') + @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { - if (!this.hasInitialized) { - return; - } - if (this.open) { // Show this.slShow.emit(); @@ -177,7 +169,7 @@ export default class SlAlert extends LitElement { } } - @watch('duration') + @watch('duration', { waitUntilFirstUpdate: true }) handleDurationChange() { this.restartAutoHide(); } diff --git a/src/components/animation/animation.ts b/src/components/animation/animation.ts index 40b068e77..cf64c116f 100644 --- a/src/components/animation/animation.ts +++ b/src/components/animation/animation.ts @@ -93,7 +93,11 @@ export default class SlAnimation extends LitElement { @watch('iterations') @watch('iterationsStart') @watch('keyframes') - handleAnimationChange() { + async handleAnimationChange() { + if (!this.hasUpdated) { + return; + } + this.createAnimation(); } diff --git a/src/components/checkbox/checkbox.ts b/src/components/checkbox/checkbox.ts index a8dbc5673..3fdf99fc4 100644 --- a/src/components/checkbox/checkbox.ts +++ b/src/components/checkbox/checkbox.ts @@ -111,8 +111,8 @@ export default class SlCheckbox extends LitElement { this.input.focus(); } - @watch('checked') - @watch('indeterminate') + @watch('checked', { waitUntilFirstUpdate: true }) + @watch('indeterminate', { waitUntilFirstUpdate: true }) handleStateChange() { this.input.checked = this.checked; this.input.indeterminate = this.indeterminate; diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 69d01de80..487e49c20 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -554,17 +554,17 @@ export default class SlColorPicker extends LitElement { this.value = this.inputValue; } - @watch('format') + @watch('format', { waitUntilFirstUpdate: true }) handleFormatChange() { this.syncValues(); } - @watch('opacity') + @watch('opacity', { waitUntilFirstUpdate: true }) handleOpacityChange() { this.alpha = 100; } - @watch('value') + @watch('value', { waitUntilFirstUpdate: true }) handleValueChange(oldValue: string, newValue: string) { const newColor = this.parseColor(newValue); diff --git a/src/components/details/details.ts b/src/components/details/details.ts index 43a63601a..9d328331d 100644 --- a/src/components/details/details.ts +++ b/src/components/details/details.ts @@ -37,7 +37,6 @@ export default class SlDetails extends LitElement { @query('.details__body') body: HTMLElement; private componentId = `details-${++id}`; - private hasInitialized = false; /** Indicates whether or not the details is open. You can use this in lieu of the show/hide methods. */ @property({ type: Boolean, reflect: true }) open = false; @@ -68,9 +67,6 @@ export default class SlDetails extends LitElement { firstUpdated() { this.body.hidden = !this.open; this.body.style.height = this.open ? 'auto' : '0'; - - // Set the initialized flag after the first render is complete - this.updateComplete.then(() => (this.hasInitialized = true)); } disconnectedCallback() { @@ -122,12 +118,8 @@ export default class SlDetails extends LitElement { } } - @watch('open') + @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { - if (!this.hasInitialized) { - return; - } - if (this.open) { // Show this.slShow.emit(); diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index c7c5db6ed..f27fbae94 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -54,7 +54,6 @@ export default class SlDialog extends LitElement { @query('.dialog__overlay') overlay: HTMLElement; private componentId = `dialog-${++id}`; - private hasInitialized = false; private modal: Modal; private originalTrigger: HTMLElement | null; @@ -106,9 +105,6 @@ export default class SlDialog extends LitElement { firstUpdated() { // Set initial visibility this.dialog.hidden = !this.open; - - // Set the initialized flag after the first render is complete - this.updateComplete.then(() => (this.hasInitialized = true)); } disconnectedCallback() { @@ -147,12 +143,8 @@ export default class SlDialog extends LitElement { } } - @watch('open') + @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { - if (!this.hasInitialized) { - return; - } - if (this.open) { // Show this.slShow.emit(); diff --git a/src/components/drawer/drawer.ts b/src/components/drawer/drawer.ts index 458449add..1de0628dc 100644 --- a/src/components/drawer/drawer.ts +++ b/src/components/drawer/drawer.ts @@ -62,7 +62,6 @@ export default class SlDrawer extends LitElement { @query('.drawer__overlay') overlay: HTMLElement; private componentId = `drawer-${++id}`; - private hasInitialized = false; private modal: Modal; private originalTrigger: HTMLElement | null; @@ -120,9 +119,6 @@ export default class SlDrawer extends LitElement { firstUpdated() { // Set initial visibility this.drawer.hidden = !this.open; - - // Set the initialized flag after the first render is complete - this.updateComplete.then(() => (this.hasInitialized = true)); } disconnectedCallback() { @@ -161,12 +157,8 @@ export default class SlDrawer extends LitElement { } } - @watch('open') + @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { - if (!this.hasInitialized) { - return; - } - if (this.open) { // Show this.slShow.emit(); diff --git a/src/components/dropdown/dropdown.ts b/src/components/dropdown/dropdown.ts index 6176dd534..081a38a95 100644 --- a/src/components/dropdown/dropdown.ts +++ b/src/components/dropdown/dropdown.ts @@ -37,7 +37,6 @@ export default class SlDropdown extends LitElement { @query('.dropdown__positioner') positioner: HTMLElement; private componentId = `dropdown-${++id}`; - private hasInitialized = false; private popover: PopperInstance; /** Indicates whether or not the dropdown is open. You can use this in lieu of the show/hide methods. */ @@ -131,9 +130,6 @@ export default class SlDropdown extends LitElement { firstUpdated() { // Set initial visibility this.panel.hidden = !this.open; - - // Set the initialized flag after the first render is complete - this.updateComplete.then(() => (this.hasInitialized = true)); } disconnectedCallback() { @@ -364,9 +360,9 @@ export default class SlDropdown extends LitElement { this.popover.update(); } - @watch('open') + @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { - if (!this.hasInitialized || this.disabled) { + if (this.disabled) { return; } diff --git a/src/components/image-comparer/image-comparer.ts b/src/components/image-comparer/image-comparer.ts index 6a72ab34a..679e277d2 100644 --- a/src/components/image-comparer/image-comparer.ts +++ b/src/components/image-comparer/image-comparer.ts @@ -92,7 +92,7 @@ export default class SlImageComparer extends LitElement { } } - @watch('position') + @watch('position', { waitUntilFirstUpdate: true }) handlePositionChange() { this.slChange.emit(); } diff --git a/src/components/include/include.ts b/src/components/include/include.ts index b1a74b897..f0edd6e8c 100644 --- a/src/components/include/include.ts +++ b/src/components/include/include.ts @@ -30,11 +30,6 @@ export default class SlInclude extends LitElement { /** Emitted when the included file fails to load due to an error. */ @event('sl-error') slError: EventEmitter<{ status: number }>; - connectedCallback() { - super.connectedCallback(); - this.loadSource(); - } - executeScript(script: HTMLScriptElement) { // Create a copy of the script and swap it out so the browser executes it const newScript = document.createElement('script'); @@ -44,7 +39,7 @@ export default class SlInclude extends LitElement { } @watch('src') - async loadSource() { + async handleSrcChange() { try { const src = this.src; const file = await requestInclude(src, this.mode); diff --git a/src/components/input/input.ts b/src/components/input/input.ts index b39665163..2647ae7d0 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -255,7 +255,9 @@ export default class SlInput extends LitElement { @watch('value') handleValueChange() { - this.invalid = !this.input.checkValidity(); + if (this.input) { + this.invalid = !this.input.checkValidity(); + } } render() { diff --git a/src/components/progress-ring/progress-ring.ts b/src/components/progress-ring/progress-ring.ts index 9c13795a5..6fe8ddd26 100644 --- a/src/components/progress-ring/progress-ring.ts +++ b/src/components/progress-ring/progress-ring.ts @@ -34,7 +34,7 @@ export default class SlProgressRing extends LitElement { this.updateProgress(); } - @watch('percentage') + @watch('percentage', { waitUntilFirstUpdate: true }) handlePercentageChange() { this.updateProgress(); } diff --git a/src/components/qr-code/qr-code.ts b/src/components/qr-code/qr-code.ts index f58d97a2d..fbc8b7468 100644 --- a/src/components/qr-code/qr-code.ts +++ b/src/components/qr-code/qr-code.ts @@ -49,6 +49,10 @@ export default class SlQrCode extends LitElement { @watch('size') @watch('value') generate() { + if (!this.hasUpdated) { + return; + } + QrCreator.render( { text: this.value, diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index 77950e585..50d3d5387 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -97,7 +97,7 @@ export default class SlRadio extends LitElement { return this.getAllRadios().filter(radio => radio !== this) as this[]; } - @watch('checked') + @watch('checked', { waitUntilFirstUpdate: true }) handleCheckedChange() { if (this.checked) { this.getSiblingRadios().map(radio => (radio.checked = false)); diff --git a/src/components/rating/rating.ts b/src/components/rating/rating.ts index 387dc77b9..22e9cd9ad 100644 --- a/src/components/rating/rating.ts +++ b/src/components/rating/rating.ts @@ -163,7 +163,7 @@ export default class SlRating extends LitElement { event.preventDefault(); } - @watch('value') + @watch('value', { waitUntilFirstUpdate: true }) handleValueChange() { this.slChange.emit(); } diff --git a/src/components/relative-time/relative-time.ts b/src/components/relative-time/relative-time.ts index a134d60ce..851cfeecf 100644 --- a/src/components/relative-time/relative-time.ts +++ b/src/components/relative-time/relative-time.ts @@ -32,11 +32,6 @@ export default class SlRelativeTime extends LitElement { /** Keep the displayed value up to date as time passes. */ @property({ type: Boolean }) sync = false; - connectedCallback() { - super.connectedCallback(); - this.updateTime(); - } - disconnectedCallback() { super.disconnectedCallback(); clearTimeout(this.updateTimeout); diff --git a/src/components/select/select.ts b/src/components/select/select.ts index eabfc4086..76348622f 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -50,7 +50,6 @@ export default class SlSelect extends LitElement { @query('.select__menu') menu: SlMenu; private inputId = `select-${++id}`; - private hasInitialized = false; private helpTextId = `select-help-text-${id}`; private labelId = `select-label-${id}`; private resizeObserver: ResizeObserver; @@ -131,7 +130,6 @@ export default class SlSelect extends LitElement { this.resizeObserver.observe(this); this.shadowRoot!.addEventListener('slotchange', this.handleSlotChange); this.syncItemsFromValue(); - this.hasInitialized = true; }); } @@ -185,7 +183,7 @@ export default class SlSelect extends LitElement { this.syncItemsFromValue(); } - @watch('disabled') + @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { if (this.disabled && this.isOpen) { this.dropdown.hide(); @@ -279,7 +277,7 @@ export default class SlSelect extends LitElement { this.box.focus(); } - @watch('multiple') + @watch('multiple', { waitUntilFirstUpdate: true }) handleMultipleChange() { // Cast to array | string based on `this.multiple` const value = this.getValueAsArray(); @@ -287,8 +285,8 @@ export default class SlSelect extends LitElement { this.syncItemsFromValue(); } - @watch('helpText') - @watch('label') + @watch('helpText', { waitUntilFirstUpdate: true }) + @watch('label', { waitUntilFirstUpdate: true }) async handleSlotChange() { this.hasHelpTextSlot = hasSlot(this, 'help-text'); this.hasLabelSlot = hasSlot(this, 'label'); @@ -314,13 +312,10 @@ export default class SlSelect extends LitElement { } } - @watch('value') + @watch('value', { waitUntilFirstUpdate: true }) handleValueChange() { this.syncItemsFromValue(); - - if (this.hasInitialized) { - this.slChange.emit(); - } + this.slChange.emit(); } resizeMenu() { diff --git a/src/components/tab-group/tab-group.ts b/src/components/tab-group/tab-group.ts index 3240f2867..7d470c8e8 100644 --- a/src/components/tab-group/tab-group.ts +++ b/src/components/tab-group/tab-group.ts @@ -216,7 +216,7 @@ export default class SlTabGroup extends LitElement { }); } - @watch('noScrollControls') + @watch('noScrollControls', { waitUntilFirstUpdate: true }) updateScrollControls() { if (this.noScrollControls) { this.hasScrollControls = false; @@ -270,7 +270,7 @@ export default class SlTabGroup extends LitElement { }); } - @watch('placement') + @watch('placement', { waitUntilFirstUpdate: true }) syncIndicator() { if (this.indicator) { const tab = this.getActiveTab(); diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index 48e725b25..ad6540ebe 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -238,7 +238,7 @@ export default class SlTextarea extends LitElement { this.slFocus.emit(); } - @watch('rows') + @watch('rows', { waitUntilFirstUpdate: true }) handleRowsChange() { this.setTextareaHeight(); } @@ -250,7 +250,7 @@ export default class SlTextarea extends LitElement { this.hasLabelSlot = hasSlot(this, 'label'); } - @watch('value') + @watch('value', { waitUntilFirstUpdate: true }) handleValueChange() { this.invalid = !this.input.checkValidity(); } diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts index bcaf75cad..74bae2717 100644 --- a/src/components/tooltip/tooltip.ts +++ b/src/components/tooltip/tooltip.ts @@ -34,7 +34,6 @@ export default class SlTooltip extends LitElement { @query('.tooltip') tooltip: HTMLElement; private componentId = `tooltip-${++id}`; - private hasInitialized = false; private target: HTMLElement; private popover: PopperInstance; private hoverTimeout: any; @@ -116,9 +115,6 @@ export default class SlTooltip extends LitElement { firstUpdated() { // Set initial visibility this.tooltip.hidden = !this.open; - - // Set the initialized flag after the first render is complete - this.updateComplete.then(() => (this.hasInitialized = true)); } disconnectedCallback() { @@ -210,10 +206,9 @@ export default class SlTooltip extends LitElement { } } - @watch('open') + @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { - // Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher - if (!this.hasInitialized || this.disabled) { + if (this.disabled) { return; } diff --git a/src/internal/decorators.ts b/src/internal/decorators.ts index cdc55ffa4..eb5c97778 100644 --- a/src/internal/decorators.ts +++ b/src/internal/decorators.ts @@ -63,32 +63,41 @@ export class EventEmitter { // @watch decorator // -// Runs after an observed property changes, e.g. @property or @state. +// Runs when an observed property changes, e.g. @property or @state, but before the component updates. // -// Note that changing props in a watch handler *will* trigger a rerender. To make pre-update changes to observed -// properties, use the `update()` method instead. +// To wait for the update to complete after a change, use `await this.updateComplete` in the handler. To determine if +// the component has previously been updated/rendered, check `this.hasUpdated` in the handler. // // Usage: // -// @watch('propName') handlePropChange(oldValue, newValue) { +// @watch('propName') +// handlePropChange(oldValue, newValue) { // ... // } // -export function watch(propName: string) { - return (protoOrDescriptor: any, name: string): any => { - const { updated } = protoOrDescriptor; +interface WatchOptions { + waitUntilFirstUpdate?: boolean; +} - protoOrDescriptor.updated = function (changedProps: Map) { +export function watch(propName: string, options?: WatchOptions) { + return (protoOrDescriptor: any, name: string): any => { + const { update } = protoOrDescriptor; + + options = Object.assign({ waitUntilFirstUpdate: false }, options) as WatchOptions; + + protoOrDescriptor.update = function (changedProps: Map) { if (changedProps.has(propName)) { const oldValue = changedProps.get(propName); const newValue = this[propName]; if (oldValue !== newValue) { - this[name].call(this, oldValue, newValue); + if (!options?.waitUntilFirstUpdate || this.hasUpdated) { + this[name].call(this, oldValue, newValue); + } } } - updated.call(this, changedProps); + update.call(this, changedProps); }; }; }